From 8be551d35f265250dcaf14f7069d9411d0965dcc Mon Sep 17 00:00:00 2001 From: "Dong, Bo1" Date: Mon, 27 Jul 2026 10:27:55 +0800 Subject: [PATCH 001/112] update the code Signed-off-by: Dong, Bo1 --- .../wrapper/include/sycl_tla_moe_mixed.hpp | 7 +- .../include/sycl_tla_moe_prefill_fp8_dpas.hpp | 4 +- .../include/sycl_tla_moe_prefill_int_dpas.hpp | 4 +- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 269 ++++++++++++++---- 4 files changed, 217 insertions(+), 67 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp index e6a3ececcd..2312702563 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp @@ -129,6 +129,7 @@ class MoEUpcastInt2SymToInt8KernelFast; // coalesced burst per row for FP16/BF16 writes, which matches the L1 // cache-line size on the target XPUs. constexpr int WG_N = 32; +constexpr int WG_K_INT4_FAST = 8; constexpr int PACK_K_FP = 4; constexpr int PACK_K_INT8 = 4; constexpr int PACK_K_INT4 = 2; @@ -277,15 +278,17 @@ void launch_dequant_int4(sycl::queue* q, const uint8_t* weights_NKp, const Scala // relative to the byte-per-work-item path. if ((K % PACK_K_INT4_FAST) == 0 && (group_size % PACK_K_INT4_FAST) == 0) { const int k_words = K / PACK_K_INT4_FAST; // == k_packed / 4 - sycl::range<3> global_fast{static_cast(E), static_cast(k_words), + const int k_words_padded = ((k_words + WG_K_INT4_FAST - 1) / WG_K_INT4_FAST) * WG_K_INT4_FAST; + sycl::range<3> global_fast{static_cast(E), static_cast(k_words_padded), static_cast((N + WG_N - 1) / WG_N) * WG_N}; - sycl::range<3> local_fast{1, 1, static_cast(WG_N)}; + sycl::range<3> local_fast{1, static_cast(WG_K_INT4_FAST), static_cast(WG_N)}; q->parallel_for>( sycl::nd_range<3>(global_fast, local_fast), [=](sycl::nd_item<3> it) { const int e = static_cast(it.get_global_id(0)); if (num_tokens_per_expert != nullptr && num_tokens_per_expert[e] == 0) return; const int kw = static_cast(it.get_global_id(1)); + if (kw >= k_words) return; const int n = static_cast(it.get_global_id(2)); if (n >= N) return; const int k_base = kw * PACK_K_INT4_FAST; diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index 1638bf7af0..35db93cac2 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -944,7 +944,7 @@ void moe_prefill_fp8_dpas_per_tensor_dispatch( if (A_avg_M <= 8) { ARK_DPAS_PT_LAUNCH(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { + } else if (A_avg_M <= 512) { ARK_DPAS_PT_LAUNCH(dpas_w8a16_policy_m_32); } else { ARK_DPAS_PT_LAUNCH(dpas_w8a16_policy); @@ -1002,7 +1002,7 @@ void moe_prefill_fp8_dpas_per_group_dispatch( if (A_avg_M <= 8) { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { + } else if (A_avg_M <= 512) { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy_m_32); } else { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy); diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_int_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_int_dpas.hpp index 2e05dfa442..8f7260bd90 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_int_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_int_dpas.hpp @@ -794,7 +794,7 @@ void moe_prefill_int_dpas_per_tensor_dispatch( if (A_avg_M <= 8) { ARK_DPAS_INT_PT_LAUNCH(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { + } else if (A_avg_M <= 512) { ARK_DPAS_INT_PT_LAUNCH(dpas_w8a16_policy_m_32); } else { ARK_DPAS_INT_PT_LAUNCH(dpas_w8a16_policy); @@ -857,7 +857,7 @@ void moe_prefill_int_dpas_per_group_dispatch( if (A_avg_M <= 8) { ARK_DPAS_INT_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { + } else if (A_avg_M <= 512) { ARK_DPAS_INT_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); } else { ARK_DPAS_INT_PG_LAUNCH_SYM(dpas_w8a16_policy); diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index fad8d87eb7..f002a092d1 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -91,6 +91,7 @@ #pragma once #include +#include #include #include #include @@ -134,6 +135,70 @@ using ::ark::moe_dpas_fp8::cute_scalar; using ::ark::moe_dpas_fp8::cute_scalar_t; using ::ark::moe_dpas_fp8::make_moe_tensor; +inline int _moe_prefill_s4_env_int(const char* name, int default_v, + int min_v, int max_v) { + const char* env = std::getenv(name); + if (env == nullptr) return default_v; + char* end = nullptr; + long v = std::strtol(env, &end, 10); + if (end == env || *end != '\0') return default_v; + if (v < static_cast(min_v)) return min_v; + if (v > static_cast(max_v)) return max_v; + return static_cast(v); +} + +inline int moe_prefill_dpas_s4_prefetch_dist() { + // Runtime-tunable A/B prefetch depth for S4 per-group mainloop. + // Default keeps current behavior. + return _moe_prefill_s4_env_int("ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST", + 3, 1, 16); +} + +inline int moe_prefill_dpas_s4_prefetch_dist_scale() { + // Runtime-tunable scale prefetch lookahead (in groups). + // Default keeps current behavior. + return _moe_prefill_s4_env_int( + "ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST_SCALE", 3, 1, 16); +} + +enum class MoePrefillS4PolicyMode { + Auto, + W8A16, + W8A16M16, + W8A16M32, + W16A16, +}; + +inline bool moe_prefill_dpas_s4_force_direct() { + const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4"); + if (env != nullptr) { + std::string s(env); + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + if (s == "1" || s == "true" || s == "on" || s == "yes" || + s == "direct" || s == "force" || s == "packed") return true; + } + + const char* policy = std::getenv("ARK_MOE_PREFILL_DPAS_S4_POLICY"); + if (policy == nullptr) return false; + std::string p(policy); + for (auto& c : p) c = static_cast(std::tolower(static_cast(c))); + return p == "w16a16" || p == "w16" || p == "w8a16" || p == "w8" || + p == "w8a16_m16" || p == "w8_m16" || p == "m16" || + p == "w8a16_m32" || p == "w8_m32" || p == "m32"; +} + +inline MoePrefillS4PolicyMode moe_prefill_dpas_s4_policy_mode() { + const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4_POLICY"); + if (env == nullptr) return MoePrefillS4PolicyMode::Auto; + std::string s(env); + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + if (s == "w16a16" || s == "w16") return MoePrefillS4PolicyMode::W16A16; + if (s == "w8a16_m16" || s == "w8_m16" || s == "m16") return MoePrefillS4PolicyMode::W8A16M16; + if (s == "w8a16_m32" || s == "w8_m32" || s == "m32") return MoePrefillS4PolicyMode::W8A16M32; + if (s == "w8a16" || s == "w8") return MoePrefillS4PolicyMode::W8A16; + return MoePrefillS4PolicyMode::Auto; +} + // --------------------------------------------------------------------------- // Variant B -- per-K-group S4 (sym) mainloop. // @@ -172,8 +237,9 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( const ElementBI* Bias, DTensor& C, // (M,N) -- ElementA Coord blk_coord, - TiledMMA const& mma) { - using TA = typename ATensor::element_type; + TiledMMA const& mma, + int prefetch_dist, + int prefetch_dist_scale) { using TB = typename BTensor::element_type; static_assert(std::is_same_v, "xe_gemm_s4_pergroup: ElementB must be cutlass::int4b_t (sym only)"); @@ -204,8 +270,13 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( auto thr_copy_b = copy_b.get_slice(local_id); auto thr_copy_c = copy_c.get_slice(local_id); - auto tCrA = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); - auto tCrB = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); + // Ping-pong SG fragments for software pipelining: + // while tile k computes on one fragment pair, tile k+1 is unpacked + // into the other pair. + auto tCrA0 = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); + auto tCrB0 = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); + auto tCrA1 = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); + auto tCrB1 = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); auto tArA = thr_copy_a.partition_sg_fragment_D(gA(_, _, 0)); auto tBrB = thr_copy_b.partition_sg_fragment_D(gB(_, _, 0)); @@ -226,11 +297,8 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( auto pAgA = thr_prefetch_A.partition_S(gA); auto pBgB = thr_prefetch_B.partition_S(gB); - // Prefetch distance mirrors `xe_gemm_int_pergroup<>` for now. - // On-hardware perf tuning may want to grow `prefetch_dist` on the - // packed path since the B stream is half the bandwidth. - const int prefetch_dist = 3; - const int prefetch_dist_scale = 3; + // Prefetch distances are host-selected and threaded into the device + // path so no env/stdlib calls are needed inside SYCL kernels. constexpr auto barrier_scope = ScopeWorkgroup; int k_tile_count = ceil_div(shape<1>(A), get<2>(wg_tile)); int k_tile_prefetch = 0; @@ -249,11 +317,8 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( auto n_tile_start = wg_n * tile_n; auto sg_local_n_coord = cutlass::get_sub_group_id() % ATOM_N; - auto sg_local_m_coord = cutlass::get_sub_group_id() / ATOM_N; int sg_local_id = cutlass::get_sub_group_local_id(); int n_sg_start = sg_local_n_coord * SG_N; - int m_sg_start = sg_local_m_coord * SG_M; - int m_tile_start = wg_m * tile_m; int group_num = get<1>(A.shape()) / group_size; // Group-local accumulator: same fragment shape as `tCrC`, cleared at @@ -267,6 +332,16 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( // Per-SG per-N scale cache. Same layout / semantics as the INT8 // per-group path. float sg_scale[sg_n_strides]; + int sg_scale_base[sg_n_strides]; + + // Precompute per-lane base offsets into Scales so the hot loop only + // adds group_idx. + CUTLASS_PRAGMA_UNROLL + for (int sn = 0; sn < sg_n_strides; ++sn) { + int sg_local_n = sn * sg_local_range + sg_local_id; + sg_scale_base[sn] = + (n_tile_start + n_sg_start + sg_local_n) * group_num; + } CUTE_UNROLL for (; k_tile_prefetch < prefetch_dist; k_tile_prefetch++) { @@ -290,27 +365,48 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( } } + if (k_tile_count == 0) { + return; + } + + // Prime the pipeline with tile 0. + barrier_arrive(barrier_scope); + copy(copy_a, tAgA(_, _, _, 0), tArA); + copy(copy_b, tBgB(_, _, _, 0), tBrB); + if (k_tile_prefetch < k_tile_count) { + prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); + prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); + } + reorder(tArA, tCrA0); + reorder(tBrB, tCrB0); + barrier_wait(barrier_scope); + + int curr_buf = 0; + static constexpr int tiles_per_group = group_size / tile_k; + int group_tile_remaining = 0; + int group_idx = 0; for (int k_tile = 0; k_tile < k_tile_count; k_tile++, k_tile_prefetch++) { - barrier_arrive(barrier_scope); - - copy(copy_a, tAgA(_, _, _, k_tile), tArA); - copy(copy_b, tBgB(_, _, _, k_tile), tBrB); - - // Group-boundary scale reload. Same math as the INT8 per-group - // path: `tile_k` is expressed in element units (nibbles), not - // bytes, so `k_tile * tile_k` is the reduction position in - // *element* space and the modulo test against `group_size` matches - // the scale-tensor layout `[E, N, K/group_size]` unchanged. - if (k_tile * tile_k % group_size == 0) { - int group_idx = (k_tile * tile_k) / group_size; + const bool has_next = (k_tile + 1 < k_tile_count); + const int next_tile = k_tile + 1; + + if (has_next) { + barrier_arrive(barrier_scope); + copy(copy_a, tAgA(_, _, _, next_tile), tArA); + copy(copy_b, tBgB(_, _, _, next_tile), tBrB); + } + + // Group-boundary scale reload via a tile counter instead of per-tile + // modulo/div in the hot loop. For all supported S4 shapes in this + // file, `group_size` is a multiple of `tile_k`. + if (group_tile_remaining == 0) { + group_idx = k_tile / tiles_per_group; CUTLASS_PRAGMA_UNROLL for (int sn = 0; sn < sg_n_strides; ++sn) { - int sg_local_n = sn * sg_local_range + sg_local_id; - sg_scale[sn] = static_cast( - Scales[(n_tile_start + n_sg_start + sg_local_n) * group_num + group_idx]); + sg_scale[sn] = + static_cast(Scales[sg_scale_base[sn] + group_idx]); } - if ((group_idx + prefetch_dist_scale) * group_size < shape<1>(A)) { + if (group_idx + prefetch_dist_scale < group_num) { auto next_scales_tensor = make_tensor( make_gmem_ptr(reinterpret_cast( Scales + (n_tile_start + n_sg_start) * group_num + @@ -324,32 +420,41 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( make_identity_tensor(make_shape(Int{}, Int<1>{}))); prefetch(prefetch_scales, pSgS(_, 0, 0)); } + group_tile_remaining = tiles_per_group; } - if (k_tile_prefetch < k_tile_count) { + if (has_next && k_tile_prefetch < k_tile_count) { prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); } - // `reorder` performs the in-register `int4b_t -> ElementA` unpack - // + sign-extend + cast via `cutlass::NumericArrayConverter< - // ElementA, cutlass::int4b_t, N>`. Once `tCrB` carries bf16/fp16 - // values it is compatible with the same DPAS atom used by the FP8 - // / INT8 per-group paths. See the header preamble open-question - // (1) -- if the pinned cutlass-sycl is missing this converter - // specialisation this line is where the build fails. - reorder(tArA, tCrA); - reorder(tBrB, tCrB); + if (has_next) { + // Pipeline stage: unpack tile k+1 while tile k compute is pending. + // `reorder` performs in-register `int4b_t -> ElementA` conversion via + // `NumericArrayConverter`. + if (curr_buf == 0) { + reorder(tArA, tCrA1); + reorder(tBrB, tCrB1); + } else { + reorder(tArA, tCrA0); + reorder(tBrB, tCrB0); + } + } // HOT MAINLOOP -- MMA accumulates into `tCrC_group`. Per-N scale // is applied ONCE at the end of the group in the fold block below. - cute::gemm(mma, tCrA, tCrB, tCrC_group); + if (curr_buf == 0) { + cute::gemm(mma, tCrA0, tCrB0, tCrC_group); + } else { + cute::gemm(mma, tCrA1, tCrB1, tCrC_group); + } - // Group-boundary fold. Fires when either (a) the NEXT k_tile would - // start a new scale group, or (b) we've reached the last k_tile of - // the K reduction (tail-group protection). - const bool is_group_end = (((k_tile + 1) * tile_k) % group_size == 0) || - (k_tile + 1 == k_tile_count); + group_tile_remaining -= 1; + // Group-boundary fold. Fires when either (a) we've consumed all + // tiles in the current group, or (b) this is the last reduction tile + // (tail-group protection). + const bool is_group_end = (group_tile_remaining == 0) || + (k_tile + 1 == k_tile_count); if (is_group_end) { CUTLASS_PRAGMA_UNROLL for (int sn = 0; sn < sg_n_strides; ++sn) { @@ -363,7 +468,10 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( } } - barrier_wait(barrier_scope); + if (has_next) { + barrier_wait(barrier_scope); + curr_buf ^= 1; + } } if (Bias != nullptr) { @@ -408,7 +516,10 @@ CUTE_DEVICE void MoEGEMM_s4(const ElementA* Activations, const int* rows_per_expert, const int32_t num_experts, const int32_t group_size, const int32_t gemm_n, - const int32_t gemm_k, int32_t* atomic_buffer, + const int32_t gemm_k, + int prefetch_dist, + int prefetch_dist_scale, + int32_t* atomic_buffer, const sycl::local_accessor& slm_mem_const) { constexpr char actual_layout_of_B = LayoutKindB ^ ('R' ^ 'C'); @@ -495,7 +606,7 @@ CUTE_DEVICE void MoEGEMM_s4(const ElementA* Activations, #define ARK_MOE_DPAS_S4_GROUP_CALLER(GS) \ xe_gemm_s4_pergroup( \ A_tensor, B_tensor, ptr_Scales_curr_batch, ptr_Bias_curr_batch, \ - D_tensor, tile_coord, mma); + D_tensor, tile_coord, mma, prefetch_dist, prefetch_dist_scale); if (group_size == 32) { ARK_MOE_DPAS_S4_GROUP_CALLER(32) } else if (group_size == 64) { @@ -534,7 +645,10 @@ void MoEGEMMLauncher_s4(sycl::queue& stream, const ElementA* activations, const ElementBI* bias, ElementD* outputs, const int gemm_n, const int gemm_k, const int* rows_per_expert, const int num_experts, - const int group_size, int32_t* atomic_buffer) { + const int group_size, + int prefetch_dist, + int prefetch_dist_scale, + int32_t* atomic_buffer) { using ElementA_non_CV = cutlass::platform::remove_cv_t; // DPAS atom keeps its bf16/fp16 x bf16/fp16 -> fp32 shape; the S4 B // tensor is upcast to ElementA in `reorder(tBrB, tCrB)` in the @@ -581,7 +695,7 @@ void MoEGEMMLauncher_s4(sycl::queue& stream, const ElementA* activations, layoutB, 'R'>( activations, weights, scales, bias, outputs, mma, rows_per_expert, num_experts, group_size, gemm_n, gemm_k, - atomic_buffer, local_mem); + prefetch_dist, prefetch_dist_scale, atomic_buffer, local_mem); }); }); @@ -636,6 +750,9 @@ void moe_prefill_s4_dpas_per_group_dispatch( reinterpret_cast(weights_NKp); int A_avg_M = total_tokens / E; + const int prefetch_dist = moe_prefill_dpas_s4_prefetch_dist(); + const int prefetch_dist_scale = moe_prefill_dpas_s4_prefetch_dist_scale(); + const MoePrefillS4PolicyMode policy_mode = moe_prefill_dpas_s4_policy_mode(); int32_t* atomic_buffer = sycl::malloc_device(1, *q); if (atomic_buffer == nullptr) { @@ -647,14 +764,35 @@ void moe_prefill_s4_dpas_per_group_dispatch( MoEGEMMLauncher_s4<'R', 'C', policy>( \ *q, activations_ca, weights_i4, scales_ca, \ static_cast(nullptr), outputs_ca, N, K, \ - num_tokens_per_expert, E, group_size, atomic_buffer); + num_tokens_per_expert, E, group_size, prefetch_dist, \ + prefetch_dist_scale, atomic_buffer); - if (A_avg_M <= 8) { + if (policy_mode == MoePrefillS4PolicyMode::W16A16) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w16a16_policy); + } else if (policy_mode == MoePrefillS4PolicyMode::W8A16M16) { ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { + } else if (policy_mode == MoePrefillS4PolicyMode::W8A16M32) { ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); + } else if (policy_mode == MoePrefillS4PolicyMode::W8A16) { + if (A_avg_M <= 8) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); + } else if (A_avg_M <= 32) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); + } else { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); + } } else { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); + // Auto policy for the direct path. On BMG MiniMax prefill shapes the + // small-M w8a16_m32 tile is much faster than larger tiles even up to + // 8K prompts (average M ~= 341), because per-expert token counts are + // highly skewed and many experts underfill larger M tiles. + if (A_avg_M <= 8) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); + } else if (A_avg_M <= 512) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); + } else { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); + } } #undef ARK_DPAS_S4_PG_LAUNCH_SYM @@ -662,24 +800,33 @@ void moe_prefill_s4_dpas_per_group_dispatch( } // --------------------------------------------------------------------------- -// Env-flag helper -- `ARK_MOE_PREFILL_DPAS_S4` (default ON, semantics -// identical to `moe_prefill_dpas_int_enabled` / `moe_prefill_dpas_fp8 -// _enabled`). Decoupled from `ARK_MOE_PREFILL_DPAS_INT8` so this new -// single-pass path can be disabled in isolation if it regresses -- -// switching S4 off falls back to the S4->S8 upcast + INT8 DPAS path -// which is itself gated by `ARK_MOE_PREFILL_DPAS_INT8`. +// Env-flag helper -- `ARK_MOE_PREFILL_DPAS_S4`. The packed-nibble direct +// DPAS path is opt-in and uses the auto policy above by default. Current BMG +// MiniMax measurements prefer the w8a16_m32 tile family for real prefill +// distributions; callers can still force another policy for experiments via +// `ARK_MOE_PREFILL_DPAS_S4_POLICY=w16a16|w8a16|w8a16_m16|w8a16_m32`. // // Truthy values (case-insensitive): "1", "true", "on", "yes". // Explicit "0" / "false" / "off" / "no" disable. Re-read on every // call so benchmarks / tests can toggle the path in-process. +// +// Additional runtime tuning knobs for the S4 per-group mainloop: +// - ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST (default 3, clamp [1, 16]) +// - ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST_SCALE (default 3, clamp [1, 16]) +// - ARK_MOE_PREFILL_DPAS_S4_POLICY (auto|w8a16|w8a16_m16| +// w8a16_m32|w16a16, +// default auto) +// +// These tune A/B tile prefetch depth and per-group scale prefetch lookahead +// respectively; `*_POLICY` selects the GEMM tile family. // --------------------------------------------------------------------------- inline bool moe_prefill_dpas_s4_enabled() { const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4"); - if (env == nullptr) return true; // default ON + if (env == nullptr) return false; std::string s(env); for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); if (s == "0" || s == "false" || s == "off" || s == "no") return false; - return true; + return moe_prefill_dpas_s4_force_direct(); } // --------------------------------------------------------------------------- From ff76f390ecfec7dab36313bb7155d13aa2a1beff Mon Sep 17 00:00:00 2001 From: "Dong, Bo1" Date: Thu, 30 Jul 2026 16:10:30 +0800 Subject: [PATCH 002/112] opt 16 bit group gemm Signed-off-by: Dong, Bo1 --- .../wrapper/include/sycl_tla_moe.hpp | 94 ++++++++++++++++--- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe.hpp index cff5626de8..e5a2b59df9 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe.hpp @@ -6,8 +6,10 @@ #pragma once +#include #include #include +#include #include #include @@ -53,27 +55,32 @@ namespace moe_detail { using namespace cute; using namespace MoE; -// Helper to choose TiledMMA based on element types -template -auto choose_tiled_mma(TA* A, TB* B) { +// Helper to choose TiledMMA for a given work-group tile / sub-group layout. +// +// The MMA atom (``XE_DPAS_TT<8, float, ...>``) is fixed; only the work-group +// tile (``WGTile``) and the sub-group tiling (``SGLayout``) vary between tile +// policies. Because every bf16/fp16 policy below keeps the same number of +// sub-group rows in M (8) the per-sub-group tile stays 32x64x32, so the same +// 2D block copy atoms remain valid across all of them. +template +auto choose_tiled_mma() { using TA_non_CV = cutlass::platform::remove_cv_t; using TB_non_CV = cutlass::platform::remove_cv_t; auto op = XE_DPAS_TT<8, float, TA_non_CV, TB_non_CV>{}; - using WGTile = Shape<_256, _128, _32>; // 256x128 WG tile size - using SGLayout = Layout, Stride<_2, _1, _0>>; // 8x2 SG tiling, n-major - using MMA = typename TiledMMAHelper, Layout, SGLayout>::TiledMMA; return MMA{}; } -// Unique kernel name tag -template +// Unique kernel name tag. The tile policy (WGTile / SGLayout) is part of the +// tag so each policy specialization produces a distinct SYCL kernel name. +template class MoEGemmKernel; // MOE GEMM launcher template -template +template void moe_gemm_launcher(sycl::queue* q, const ElementA* activations, const ElementB* weights, const ElementS* scales, ElementD* outputs, const int gemm_n, const int gemm_k, int* num_rows_per_expert_device, const int num_experts) { @@ -86,7 +93,7 @@ void moe_gemm_launcher(sycl::queue* q, const ElementA* activations, const Elemen auto dummy_group_problem_shape = cutlass::gemm::GroupProblemShape>{1, &dummy_problem_shape, nullptr}; - using TileShape = Shape<_256, _128, _32>; + using TileShape = WGTile; using ClusterShape = Shape<_1, _1, _1>; auto scheduler_params = PersistentTileSchedulerXeMoE::to_underlying_arguments( @@ -97,7 +104,7 @@ void moe_gemm_launcher(sycl::queue* q, const ElementA* activations, const Elemen scheduler_params, dummy_group_problem_shape, TileShape{}, ClusterShape{}, hw_info, PersistentTileSchedulerXeMoE::Arguments{1, RasterOrderOptions::AlongN}); - auto mma = choose_tiled_mma(activations, weights); + auto mma = choose_tiled_mma(); auto MaxThreadsPerWorkgroup = size(mma); dim3 local_range{static_cast(MaxThreadsPerWorkgroup), 1, 1}; @@ -110,7 +117,7 @@ void moe_gemm_launcher(sycl::queue* q, const ElementA* activations, const Elemen syclex::properties kernel_props{syclex::sub_group_size<16>, intelex::grf_size<256>}; - auto event = q->parallel_for>( + auto event = q->parallel_for>( sycl::nd_range<3>(global, local), kernel_props, [=](auto) { MoE::MoEGEMM, XE_LOAD_2D_VNNI<16, 32, 16, 16>, XE_STORE_2D<16, 8, 32>, 'R', 'R', 'R'>(activations, weights, scales, outputs, mma, num_rows_per_expert_device, num_experts, gemm_n, @@ -121,6 +128,65 @@ void moe_gemm_launcher(sycl::queue* q, const ElementA* activations, const Elemen event.wait(); } +// Whether the N-based tile-policy heuristic is enabled (default on). +// +// Set ``ARK_MOE_GEMM_FIXED_TILE`` to a truthy value ("1"/"true"/"on"/"yes") +// to always use the historical fixed 256x128 (8x2) tile regardless of N. +// This provides an escape hatch should a specific device regress with the +// wider tiles. +inline bool moe_gemm_fixed_tile() { + const char* env = std::getenv("ARK_MOE_GEMM_FIXED_TILE"); + if (env == nullptr) { + return false; + } + std::string v(env); + for (auto& c : v) { + c = static_cast(std::tolower(static_cast(c))); + } + return !(v == "0" || v == "false" || v == "off" || v == "no" || v.empty()); +} + +// Select the work-group tile policy from the output width ``N`` and dispatch, +// mirroring the ``w16a16`` large-M heuristic in vllm-xpu-kernels grouped GEMM: +// +// * N <= 64 -> 256x64x32, SGLayout 8x1 +// * N <= 512 -> 256x128x32, SGLayout 8x2 (historical default) +// * N > 512 -> 256x256x32, SGLayout 8x4 +// +// Prefill routes many tokens per expert (large M), so the taller/wider N tile +// increases sub-group utilization and reduces the number of work-group tiles +// launched for the large-N up/down projections. All three policies share the +// same per-sub-group tile (32x64x32), so the copy atoms in +// ``moe_gemm_launcher`` remain valid. +template +void moe_gemm_dispatch(sycl::queue* q, const Element* activations, const Element* weights, const Element* scales, + Element* outputs, const int gemm_n, const int gemm_k, int* num_rows_per_expert_device, + const int num_experts) { + using N64 = Shape<_256, _64, _32>; + using SG64 = Layout, Stride<_1, _1, _0>>; + using N128 = Shape<_256, _128, _32>; + using SG128 = Layout, Stride<_2, _1, _0>>; + using N256 = Shape<_256, _256, _32>; + using SG256 = Layout, Stride<_4, _1, _0>>; + + if (moe_gemm_fixed_tile()) { + moe_gemm_launcher<'R', 'R', N128, SG128, Element, Element, Element, Element>( + q, activations, weights, scales, outputs, gemm_n, gemm_k, num_rows_per_expert_device, num_experts); + return; + } + + if (gemm_n <= 64) { + moe_gemm_launcher<'R', 'R', N64, SG64, Element, Element, Element, Element>( + q, activations, weights, scales, outputs, gemm_n, gemm_k, num_rows_per_expert_device, num_experts); + } else if (gemm_n <= 512) { + moe_gemm_launcher<'R', 'R', N128, SG128, Element, Element, Element, Element>( + q, activations, weights, scales, outputs, gemm_n, gemm_k, num_rows_per_expert_device, num_experts); + } else { + moe_gemm_launcher<'R', 'R', N256, SG256, Element, Element, Element, Element>( + q, activations, weights, scales, outputs, gemm_n, gemm_k, num_rows_per_expert_device, num_experts); + } +} + } // namespace moe_detail // Public MOE GEMM API @@ -129,7 +195,7 @@ inline void moe_gemm(sycl::queue* q, void* activations, void* weights, void* sca switch (dtype) { case BTLA_DTYPE::BF16: { using Element = cutlass::bfloat16_t; - moe_detail::moe_gemm_launcher<'R', 'R', Element, Element, Element, Element>( + moe_detail::moe_gemm_dispatch( q, static_cast(activations), static_cast(weights), static_cast(scales), static_cast(outputs), N, K, num_tokens_per_expert, num_experts); @@ -137,7 +203,7 @@ inline void moe_gemm(sycl::queue* q, void* activations, void* weights, void* sca } case BTLA_DTYPE::F16: { using Element = cutlass::half_t; - moe_detail::moe_gemm_launcher<'R', 'R', Element, Element, Element, Element>( + moe_detail::moe_gemm_dispatch( q, static_cast(activations), static_cast(weights), static_cast(scales), static_cast(outputs), N, K, num_tokens_per_expert, num_experts); From 68bb067d54ab539b90b1f4b4561dbf8bd6799ba6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:04:58 +0000 Subject: [PATCH 003/112] perf: dedicated w4a16 tiles for int4 moe prefill + widen decode gemv --- .../wrapper/include/sycl_tla_moe_decode.hpp | 85 ++++++++++++------- .../include/sycl_tla_moe_prefill_fp8_dpas.hpp | 29 +++++++ .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 27 ++++-- .../ark/test/README_MOE_PREFILL_PERF.md | 20 +++++ .../ark/test/README_MOE_PREFILL_PERF_CN.md | 18 ++++ 5 files changed, 143 insertions(+), 36 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 1b8a4feb0b..11535a3257 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -204,6 +204,41 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight // of byte i, the value at k = 2*i+1 is the HIGH nibble. This matches the // existing CPU/XPU `packq` layout for S4_CLIP weights. // ---------------------------------------------------------------------------- + +// Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK/2 +// packed weight bytes + a vec activation block). Templated on +// CHUNK so the caller can run a wide (32) stage first and a narrower (16) +// stage for the remainder, which keeps the fast path active for group sizes +// that are a multiple of 32 (32/64/128/256 -- the shipped quant configs) +// without regressing group_size == 16 (which drops straight to the 16-wide +// stage). The math is identical to the scalar path. +template +static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float zero, + float& acc) { + using ActVec = sycl::vec; + using PackVec = sycl::vec; + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + const ActVec av = *reinterpret_cast(act_ptr); + const PackVec pv = *reinterpret_cast(w_ptr); +#pragma unroll + for (int b = 0; b < CHUNK / 2; ++b) { + int q0, q1; + decode_int4_pair(pv[b], q0, q1); + float w0, w1; + if constexpr (Asym) { + w0 = (static_cast(q0) - zero) * scale; + w1 = (static_cast(q1) - zero) * scale; + } else { + w0 = static_cast(q0) * scale; + w1 = static_cast(q1) * scale; + } + const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); + acc += static_cast(a0) * w0; + acc += static_cast(a1) * w1; + } +} + template void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, const ScalarT* zeros, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, @@ -253,39 +288,27 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig zero = static_cast(z_row[g]); } const int k_base = g * group_size; - // Vectorized path: process 16 K-elements at a time, which is - // 8 packed weight bytes and a vec activation block. - // group_size is a multiple of 16 in every supported config - // (group_size >= 32, even); a scalar tail loop covers leftovers. - constexpr int CHUNK = 16; - using ActVec = sycl::vec; - using PackVec = sycl::vec; - static_assert(sizeof(ScalarT) == sizeof(uint16_t), - "ScalarT must be a 16-bit floating type"); - const int chunk_end = (group_size / CHUNK) * CHUNK; + // Vectorized ladder: process 32 K-elements at a time (16 packed + // weight bytes + vec activation block), then a 16-wide + // stage for the remainder, then a scalar tail. Widening the first + // stage to 32 amortizes the per-group scale load and loop overhead + // across twice as many multiply-adds for the shipped group sizes + // (32/64/128/256), while the 16-wide stage keeps group_size == 16 + // on the fast path. int kk = 0; - for (; kk < chunk_end; kk += CHUNK) { - const ActVec av = *reinterpret_cast(act_row + k_base + kk); - const PackVec pv = *reinterpret_cast(w_row + (k_base + kk) / 2); -#pragma unroll - for (int b = 0; b < CHUNK / 2; ++b) { - int q0, q1; - decode_int4_pair(pv[b], q0, q1); - float w0, w1; - if constexpr (Asym) { - w0 = (static_cast(q0) - zero) * scale; - w1 = (static_cast(q1) - zero) * scale; - } else { - w0 = static_cast(q0) * scale; - w1 = static_cast(q1) * scale; - } - const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); - acc += static_cast(a0) * w0; - acc += static_cast(a1) * w1; - } + constexpr int CHUNK32 = 32; + const int end32 = (group_size / CHUNK32) * CHUNK32; + for (; kk < end32; kk += CHUNK32) { + int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, + zero, acc); + } + constexpr int CHUNK16 = 16; + const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; + for (; kk < end16; kk += CHUNK16) { + int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, + zero, acc); } - // Scalar tail for group_size not divisible by CHUNK. + // Scalar tail for group_size not divisible by 16. for (; kk < group_size; kk += 2) { const uint8_t packed = w_row[(k_base + kk) / 2]; int q0, q1; diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index 1638bf7af0..b9898f81f0 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -193,6 +193,35 @@ class dpas_w8a16_policy_m_32 : public dpas_policy_base { using SGLayout = Layout, Stride<_4, _1, _0>>; }; +// --------------------------------------------------------------------------- +// 4-bit (S4 / w4a16) tile policies. +// +// The S4 mixed-input prefill mainloop (`sycl_tla_moe_prefill_s4_dpas.hpp`) +// reads a *halved* B-side byte stream (two nibbles per byte). With the same +// M/N footprint the packed weight fits in half the L2/GRF traffic of the INT8 +// path, so a larger N tile pays off: the default large-M policy uses a +// 128x256x32 WG tile (vs. the INT8 128x128x16), matching the reference +// `w4a16_policy` in vllm-xpu-kernels `csrc/xpu/grouped_gemm/xe_2/ +// gemm_xe2_policy.hpp`. The small-M buckets (m_8/m_16/m_32) reuse the same +// 64-wide N tiles as the INT8 path -- the reference uses identical shapes +// there. Only the default (large-M) tile and the new m_8 bucket differ, so we +// define those two here and alias m_16/m_32 to the shared shapes in the S4 +// header. +// --------------------------------------------------------------------------- +class dpas_w4a16_policy : public dpas_policy_base { + public: + using WGTile = Shape<_128, _256, _32>; + using SGLayout = Layout, Stride<_8, _1, _0>>; + + using GmemTiledCopyD = XE_STORE_2D<16, 8, 32>; +}; + +class dpas_w4a16_policy_m_8 : public dpas_policy_base { + public: + using WGTile = Shape<_8, _64, _32>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + // --------------------------------------------------------------------------- // `apply_scale` -- inline-asm per-lane multiply of a bf16/fp16 fragment by // an FP32 scalar. Copied verbatim from vllm-xpu-kernels `gemm_xe2.hpp`. diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index fad8d87eb7..693a8675e5 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -129,6 +129,15 @@ using ::ark::moe_dpas_fp8::dpas_w16a16_policy; using ::ark::moe_dpas_fp8::dpas_w8a16_policy; using ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_16; using ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_32; +// Dedicated 4-bit tile policies. The default (large-M) tile is 128x256x32 +// (halved packed-B stream lets a wider N tile pay off) and the m_8 bucket is +// new; both mirror the reference `w4a16_policy*` in vllm-xpu-kernels. The +// m_16 / m_32 buckets share the INT8 64-wide N tiles, so they are aliased to +// the existing `dpas_w8a16_policy_m_16 / _m_32` shapes rather than duplicated. +using ::ark::moe_dpas_fp8::dpas_w4a16_policy; +using ::ark::moe_dpas_fp8::dpas_w4a16_policy_m_8; +using dpas_w4a16_policy_m_16 = ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_16; +using dpas_w4a16_policy_m_32 = ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_32; using ::ark::moe_dpas_fp8::ScaleMode; using ::ark::moe_dpas_fp8::cute_scalar; using ::ark::moe_dpas_fp8::cute_scalar_t; @@ -649,12 +658,20 @@ void moe_prefill_s4_dpas_per_group_dispatch( static_cast(nullptr), outputs_ca, N, K, \ num_tokens_per_expert, E, group_size, atomic_buffer); - if (A_avg_M <= 8) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); + // Four-tier bucketing on the average tokens-per-expert, matching the + // reference `w4a16` dispatch in vllm-xpu-kernels + // (`grouped_gemm_xe2_interface.hpp`): tiny M uses an 8-row tile, and the + // 32-row tile now covers M up to 128 (instead of jumping to the wide + // large-M tile at 33) so mid-size chunked-prefill batches avoid the + // padding waste of the 128-row tile. + if (A_avg_M <= 4) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_8); + } else if (A_avg_M <= 8) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_16); + } else if (A_avg_M <= 128) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_32); } else { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy); } #undef ARK_DPAS_S4_PG_LAUNCH_SYM diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index ff97deaa13..c1ed20ef5b 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -291,6 +291,26 @@ these fail: - `group_size ∈ {32, 64, 128, 256}` - `asym == false` (asym S4 is out of scope for both DPAS paths) +**S4 DPAS tile policies** — the single-pass mainloop (precedence 1) +now selects a dedicated 4-bit tile policy by the average tokens-per- +expert (`A_avg_M = total_tokens / E`), mirroring the reference +`w4a16` dispatch in `vllm-project/vllm-xpu-kernels` +(`grouped_gemm_xe2_interface.hpp`). Because the packed-nibble B stream +is half the byte volume of the INT8 path, the large-M tile is widened +to `128×256×32` (vs. the INT8 `128×128×16`) so the DPAS accumulators +and the halved B-side bandwidth are better utilised: + +| `A_avg_M` bucket | WG tile (M×N×K) | Policy (`sycl_tla_moe_prefill_fp8_dpas.hpp`) | +| ---------------- | --------------- | -------------------------------------------- | +| `≤ 4` | `8×64×32` | `dpas_w4a16_policy_m_8` | +| `≤ 8` | `16×64×32` | `dpas_w4a16_policy_m_16` (= `w8a16_m_16`) | +| `≤ 128` | `32×64×32` | `dpas_w4a16_policy_m_32` (= `w8a16_m_32`) | +| `> 128` | `128×256×32` | `dpas_w4a16_policy` | + +The mid-size `32×64` tile now covers `A_avg_M` up to 128 (previously it +jumped to the wide tile at 33), which avoids padding waste on the +common chunked-prefill batch sizes. + Accuracy parity is covered by `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group`, which forces `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 732cfec70c..713474651d 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -222,6 +222,24 @@ S4-sym 有两条独立的 DPAS 路径;asym S4 始终回退到 dequant 路径。 - `group_size ∈ {32, 64, 128, 256}` - `asym == false`(asym S4 不在两条 DPAS 路径的支持范围内) +**S4 DPAS tile 策略** — 单遍 mainloop(优先级 1)现在按每专家平均 +token 数(`A_avg_M = total_tokens / E`)选择专用的 4-bit tile 策略, +与参考实现 `vllm-project/vllm-xpu-kernels` +(`grouped_gemm_xe2_interface.hpp`)的 `w4a16` 分派一致。由于 packed- +nibble 的 B 流字节量是 INT8 路径的一半,大 M tile 加宽到 `128×256×32` +(相比 INT8 的 `128×128×16`),以更充分利用 DPAS 累加器与减半的 B 侧 +带宽: + +| `A_avg_M` 分档 | WG tile (M×N×K) | 策略(`sycl_tla_moe_prefill_fp8_dpas.hpp`) | +| -------------- | --------------- | ------------------------------------------ | +| `≤ 4` | `8×64×32` | `dpas_w4a16_policy_m_8` | +| `≤ 8` | `16×64×32` | `dpas_w4a16_policy_m_16`(= `w8a16_m_16`) | +| `≤ 128` | `32×64×32` | `dpas_w4a16_policy_m_32`(= `w8a16_m_32`) | +| `> 128` | `128×256×32` | `dpas_w4a16_policy` | + +中等大小的 `32×64` tile 现在覆盖 `A_avg_M` 至 128(此前在 33 就跳到大 +tile),避免了常见 chunked-prefill batch 大小下的 padding 浪费。 + 精度对齐由 `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group` 覆盖,该用例强制 `ARK_MOE_PREFILL_DPAS_S4=1` + From 6cda6c8cd3e183ab84f08e7bdf769e79094fbe32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:25:32 +0000 Subject: [PATCH 004/112] fix: avoid illegal sycl::vec in int4 MoE decode kernel --- .../wrapper/include/sycl_tla_moe_decode.hpp | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 11535a3257..de480993b0 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -215,27 +215,36 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight template static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float zero, float& acc) { - using ActVec = sycl::vec; - using PackVec = sycl::vec; static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - const ActVec av = *reinterpret_cast(act_ptr); - const PackVec pv = *reinterpret_cast(w_ptr); + static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); + // sycl::vec only supports widths of 1, 2, 3, 4, 8 or 16, so a single + // vec load is illegal. Process the chunk in 16-wide sub-blocks + // (16 activations + 8 packed weight bytes each), which keeps CHUNK == 32 + // valid while reusing the same code path for CHUNK == 16. + constexpr int SUB = 16; + using ActVec = sycl::vec; + using PackVec = sycl::vec; #pragma unroll - for (int b = 0; b < CHUNK / 2; ++b) { - int q0, q1; - decode_int4_pair(pv[b], q0, q1); - float w0, w1; - if constexpr (Asym) { - w0 = (static_cast(q0) - zero) * scale; - w1 = (static_cast(q1) - zero) * scale; - } else { - w0 = static_cast(q0) * scale; - w1 = static_cast(q1) * scale; + for (int s = 0; s < CHUNK / SUB; ++s) { + const ActVec av = *reinterpret_cast(act_ptr + s * SUB); + const PackVec pv = *reinterpret_cast(w_ptr + s * (SUB / 2)); +#pragma unroll + for (int b = 0; b < SUB / 2; ++b) { + int q0, q1; + decode_int4_pair(pv[b], q0, q1); + float w0, w1; + if constexpr (Asym) { + w0 = (static_cast(q0) - zero) * scale; + w1 = (static_cast(q1) - zero) * scale; + } else { + w0 = static_cast(q0) * scale; + w1 = static_cast(q1) * scale; + } + const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); + acc += static_cast(a0) * w0; + acc += static_cast(a1) * w1; } - const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); - acc += static_cast(a0) * w0; - acc += static_cast(a1) * w1; } } From ed2c78faffabd60722f4331a6be38e83160f009e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:48:34 +0000 Subject: [PATCH 005/112] perf: widen int8 MoE decode to 32-wide vectorized ladder --- .../wrapper/include/sycl_tla_moe_decode.hpp | 83 +++++++++++++------ 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index de480993b0..5bd28df973 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -349,6 +349,42 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // type is used for sym and asym; the only difference is the sign interpretation // performed at decode time. // ---------------------------------------------------------------------------- + +// Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK weight +// bytes + a vec activation block). Templated on CHUNK so the +// caller can run a wide (32) stage first and a narrower (16) stage for the +// remainder, mirroring the int4 path: widening the first stage amortizes the +// per-group scale load and loop overhead across twice as many multiply-adds for +// the shipped group sizes (32/64/128/256) without regressing group_size == 16. +// sycl::vec only supports widths of 1, 2, 3, 4, 8 or 16, so CHUNK is processed +// in 16-wide sub-blocks. The math is identical to the scalar path. +template +static inline void int8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float zero, + float& acc) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); + constexpr int SUB = 16; + using ActVec = sycl::vec; + using ByteVec = sycl::vec; +#pragma unroll + for (int s = 0; s < CHUNK / SUB; ++s) { + const ActVec av = *reinterpret_cast(act_ptr + s * SUB); + const ByteVec wv = *reinterpret_cast(w_ptr + s * SUB); +#pragma unroll + for (int u = 0; u < SUB; ++u) { + const int qv = decode_int8(wv[u]); + float w; + if constexpr (Asym) { + w = (static_cast(qv) - zero) * scale; + } else { + w = static_cast(qv) * scale; + } + const ScalarT a = sycl::bit_cast(static_cast(av[u])); + acc += static_cast(a) * w; + } + } +} + template void launch_int8(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, const ScalarT* zeros, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, @@ -397,39 +433,32 @@ void launch_int8(sycl::queue* q, const ScalarT* activations, const uint8_t* weig zero = static_cast(z_row[g]); } const int k_base = g * group_size; - // Vectorized path: 16 weights (16 bytes) + 16 activations per load. - // group_size is typically 128 (mult of 16); scalar tail handles - // anything that doesn't divide evenly. - constexpr int CHUNK = 16; - using ActVec = sycl::vec; - using ByteVec = sycl::vec; - static_assert(sizeof(ScalarT) == sizeof(uint16_t), - "ScalarT must be a 16-bit floating type"); - const int chunk_end = (group_size / CHUNK) * CHUNK; + // Vectorized ladder mirroring the int4 path: process 32 K-elements + // (32 weight bytes + vec activations) at a time, then a + // 16-wide stage for the remainder, then a scalar tail. Widening the + // first stage amortizes the per-group scale load and loop overhead + // for the shipped group sizes (32/64/128/256), while the 16-wide + // stage keeps group_size == 16 on the fast path. int kk = 0; - for (; kk < chunk_end; kk += CHUNK) { - const ActVec av = *reinterpret_cast(act_row + k_base + kk); - const ByteVec wv = *reinterpret_cast(w_row + k_base + kk); -#pragma unroll - for (int u = 0; u < CHUNK; ++u) { - const int q = decode_int8(wv[u]); - float w; - if constexpr (Asym) { - w = (static_cast(q) - zero) * scale; - } else { - w = static_cast(q) * scale; - } - const ScalarT a = sycl::bit_cast(static_cast(av[u])); - acc += static_cast(a) * w; - } + constexpr int CHUNK32 = 32; + const int end32 = (group_size / CHUNK32) * CHUNK32; + for (; kk < end32; kk += CHUNK32) { + int8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, scale, zero, + acc); + } + constexpr int CHUNK16 = 16; + const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; + for (; kk < end16; kk += CHUNK16) { + int8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, scale, zero, + acc); } for (; kk < group_size; ++kk) { - const int q = decode_int8(w_row[k_base + kk]); + const int qv = decode_int8(w_row[k_base + kk]); float w; if constexpr (Asym) { - w = (static_cast(q) - zero) * scale; + w = (static_cast(qv) - zero) * scale; } else { - w = static_cast(q) * scale; + w = static_cast(qv) * scale; } acc += static_cast(act_row[k_base + kk]) * w; } From 488fa34b2fb9a14d0eb75fdc7f27345fcf43193a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:49:21 +0000 Subject: [PATCH 006/112] perf: widen int2 MoE decode to 32-wide vectorized ladder --- .../wrapper/include/sycl_tla_moe_decode.hpp | 104 +++++++++++------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 5bd28df973..c4ef83a8c7 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -477,6 +477,54 @@ void launch_int8(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // Asym=false: signed 2-bit value in [-2, 1]; dequant = q * scale // Asym=true : unsigned 2-bit value in [0, 3]; dequant = (q - zero) * scale // ---------------------------------------------------------------------------- + +// Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK/4 +// packed weight bytes + a vec activation block). Templated on +// CHUNK so the caller can run a wide (32) stage first and a narrower (16) stage +// for the remainder, mirroring the int4/int8 paths. sycl::vec only supports +// widths of 1, 2, 3, 4, 8 or 16, so CHUNK is processed in 16-wide sub-blocks +// (16 activations + 4 packed bytes each). The math is identical to the scalar +// path. +template +static inline void int2_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float zero, + float& acc) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); + constexpr int SUB = 16; + using ActVec = sycl::vec; + using PackVec = sycl::vec; +#pragma unroll + for (int s = 0; s < CHUNK / SUB; ++s) { + const ActVec av = *reinterpret_cast(act_ptr + s * SUB); + const PackVec pv = *reinterpret_cast(w_ptr + s * (SUB / 4)); +#pragma unroll + for (int b = 0; b < SUB / 4; ++b) { + int qq[4]; + decode_int2_quad(pv[b], qq); + float w0, w1, w2, w3; + if constexpr (Asym) { + w0 = (static_cast(qq[0]) - zero) * scale; + w1 = (static_cast(qq[1]) - zero) * scale; + w2 = (static_cast(qq[2]) - zero) * scale; + w3 = (static_cast(qq[3]) - zero) * scale; + } else { + w0 = static_cast(qq[0]) * scale; + w1 = static_cast(qq[1]) * scale; + w2 = static_cast(qq[2]) * scale; + w3 = static_cast(qq[3]) * scale; + } + const ScalarT a0 = sycl::bit_cast(static_cast(av[4 * b + 0])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[4 * b + 1])); + const ScalarT a2 = sycl::bit_cast(static_cast(av[4 * b + 2])); + const ScalarT a3 = sycl::bit_cast(static_cast(av[4 * b + 3])); + acc += static_cast(a0) * w0; + acc += static_cast(a1) * w1; + acc += static_cast(a2) * w2; + acc += static_cast(a3) * w3; + } + } +} + template void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, const ScalarT* zeros, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, @@ -530,47 +578,23 @@ void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weig zero = static_cast(z_row[g]); } const int k_base = g * group_size; - // Vectorized: 16 K-elements per chunk = 4 packed bytes (4 values - // each) plus a vec activation block. group_size is a - // multiple of 4 and typically 128 (mult of 16); scalar tail covers - // any leftover. We load activations via uint16_t to stay portable - // across SYCL implementations that may not provide - // sycl::vec. - constexpr int CHUNK = 16; - using ActVec = sycl::vec; - using PackVec = sycl::vec; - static_assert(sizeof(ScalarT) == sizeof(uint16_t), - "ScalarT must be a 16-bit floating type"); - const int chunk_end = (group_size / CHUNK) * CHUNK; + // Vectorized ladder mirroring the int4/int8 paths: process 32 + // K-elements (8 packed bytes + vec activations) at a + // time, then a 16-wide stage for the remainder, then a scalar tail. + // group_size is a multiple of 4; the wide stage amortizes the + // per-group scale load for the shipped group sizes (32/64/128/256). int kk = 0; - for (; kk < chunk_end; kk += CHUNK) { - const ActVec av = *reinterpret_cast(act_row + k_base + kk); - const PackVec pv = *reinterpret_cast(w_row + (k_base + kk) / 4); -#pragma unroll - for (int b = 0; b < CHUNK / 4; ++b) { - int q[4]; - decode_int2_quad(pv[b], q); - float w0, w1, w2, w3; - if constexpr (Asym) { - w0 = (static_cast(q[0]) - zero) * scale; - w1 = (static_cast(q[1]) - zero) * scale; - w2 = (static_cast(q[2]) - zero) * scale; - w3 = (static_cast(q[3]) - zero) * scale; - } else { - w0 = static_cast(q[0]) * scale; - w1 = static_cast(q[1]) * scale; - w2 = static_cast(q[2]) * scale; - w3 = static_cast(q[3]) * scale; - } - const ScalarT a0 = sycl::bit_cast(static_cast(av[4 * b + 0])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[4 * b + 1])); - const ScalarT a2 = sycl::bit_cast(static_cast(av[4 * b + 2])); - const ScalarT a3 = sycl::bit_cast(static_cast(av[4 * b + 3])); - acc += static_cast(a0) * w0; - acc += static_cast(a1) * w1; - acc += static_cast(a2) * w2; - acc += static_cast(a3) * w3; - } + constexpr int CHUNK32 = 32; + const int end32 = (group_size / CHUNK32) * CHUNK32; + for (; kk < end32; kk += CHUNK32) { + int2_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 4, scale, + zero, acc); + } + constexpr int CHUNK16 = 16; + const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; + for (; kk < end16; kk += CHUNK16) { + int2_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 4, scale, + zero, acc); } // Scalar tail (4 values per byte). for (; kk < group_size; kk += 4) { From 2284e77c83596321499046a3a56f01b1ee92964f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:53:12 +0000 Subject: [PATCH 007/112] perf: widen fp8 MoE decode to 32-wide vectorized ladder --- .../wrapper/include/sycl_tla_moe_decode.hpp | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index c4ef83a8c7..76366f3ec1 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -632,6 +632,33 @@ void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // inline bit-manipulation path at compile time. The choice is driven at // launch time by the env var `ARK_FP8_DECODE_USE_LUT` (default: ON). // ---------------------------------------------------------------------------- + +// Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK weight +// bytes + a vec activation block). Templated on CHUNK so the +// caller can run a wide (32) stage first and a narrower (16) stage for the +// remainder, mirroring the int4/int8 paths. sycl::vec only supports widths of +// 1, 2, 3, 4, 8 or 16, so CHUNK is processed in 16-wide sub-blocks. The math is +// identical to the scalar path. +template +static inline void fp8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float& acc) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); + constexpr int SUB = 16; + using ActVec = sycl::vec; + using ByteVec = sycl::vec; +#pragma unroll + for (int s = 0; s < CHUNK / SUB; ++s) { + const ActVec av = *reinterpret_cast(act_ptr + s * SUB); + const ByteVec wv = *reinterpret_cast(w_ptr + s * SUB); +#pragma unroll + for (int u = 0; u < SUB; ++u) { + const float w = decode_fp8(wv[u]) * scale; + const ScalarT a = sycl::bit_cast(static_cast(av[u])); + acc += static_cast(a) * w; + } + } +} + template void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, int group_size) { @@ -669,27 +696,24 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh for (int g = 0; g < num_groups_k; ++g) { const float scale = static_cast(s_row[g]); const int k_base = g * group_size; - // Vectorized: 16 weights (16 bytes) + 16 activations per load. - // Decode each FP8 byte to float inline, then apply the per-group - // scale. group_size is typically 128 (mult of 16); scalar tail - // covers anything that doesn't divide evenly. - constexpr int CHUNK = 16; - using ActVec = sycl::vec; - using ByteVec = sycl::vec; - static_assert(sizeof(ScalarT) == sizeof(uint16_t), - "ScalarT must be a 16-bit floating type"); - const int chunk_end = (group_size / CHUNK) * CHUNK; + // Vectorized ladder mirroring the int4/int8 paths: process 32 + // K-elements (32 weight bytes + vec activations) at a + // time, then a 16-wide stage for the remainder, then a scalar tail. + // Decode each FP8 byte to float, then apply the per-group scale. + // Widening the first stage amortizes the per-group scale load for + // the shipped group sizes (32/64/128/256). int kk = 0; - for (; kk < chunk_end; kk += CHUNK) { - const ActVec av = *reinterpret_cast(act_row + k_base + kk); - const ByteVec wv = *reinterpret_cast(w_row + k_base + kk); -#pragma unroll - for (int u = 0; u < CHUNK; ++u) { - const uint8_t raw = wv[u]; - const float w = decode_fp8(raw) * scale; - const ScalarT a = sycl::bit_cast(static_cast(av[u])); - acc += static_cast(a) * w; - } + constexpr int CHUNK32 = 32; + const int end32 = (group_size / CHUNK32) * CHUNK32; + for (; kk < end32; kk += CHUNK32) { + fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, scale, + acc); + } + constexpr int CHUNK16 = 16; + const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; + for (; kk < end16; kk += CHUNK16) { + fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, scale, + acc); } for (; kk < group_size; ++kk) { const uint8_t raw = w_row[k_base + kk]; From a039ad95a88d2d1e447624e224756d37cfe143bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:07:34 +0000 Subject: [PATCH 008/112] perf: hoist per-group/per-expert scale out of fp8 MoE decode inner loop --- .../wrapper/include/sycl_tla_moe_decode.hpp | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 76366f3ec1..0f03604ccf 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -637,10 +637,16 @@ void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // bytes + a vec activation block). Templated on CHUNK so the // caller can run a wide (32) stage first and a narrower (16) stage for the // remainder, mirroring the int4/int8 paths. sycl::vec only supports widths of -// 1, 2, 3, 4, 8 or 16, so CHUNK is processed in 16-wide sub-blocks. The math is -// identical to the scalar path. +// 1, 2, 3, 4, 8 or 16, so CHUNK is processed in 16-wide sub-blocks. +// +// The per-group scale is constant across the whole group, so it is NOT applied +// here: this accumulates the raw dot product (sum of act * decoded_fp8) and the +// caller multiplies the group total by the scale once (Σ a·(w·s) == s·Σ a·w). +// For the per-expert / per-tensor scale case (group_size == K, one scale per +// output row) this collapses the whole K reduction to a single scale multiply, +// removing one multiply per K element on the decode hot path. template -static inline void fp8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float& acc) { +static inline void fp8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc) { static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); constexpr int SUB = 16; @@ -652,7 +658,7 @@ static inline void fp8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr const ByteVec wv = *reinterpret_cast(w_ptr + s * SUB); #pragma unroll for (int u = 0; u < SUB; ++u) { - const float w = decode_fp8(wv[u]) * scale; + const float w = decode_fp8(wv[u]); const ScalarT a = sycl::bit_cast(static_cast(av[u])); acc += static_cast(a) * w; } @@ -699,27 +705,32 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh // Vectorized ladder mirroring the int4/int8 paths: process 32 // K-elements (32 weight bytes + vec activations) at a // time, then a 16-wide stage for the remainder, then a scalar tail. - // Decode each FP8 byte to float, then apply the per-group scale. - // Widening the first stage amortizes the per-group scale load for - // the shipped group sizes (32/64/128/256). + // The per-group scale is constant across the group, so accumulate the + // raw dot product here and apply the scale once below (Σ a·(w·s) == + // s·Σ a·w). Widening the first stage amortizes the per-group scale + // load for the shipped group sizes (32/64/128/256); hoisting the + // scale removes one multiply per K element, which is the dominant + // cost for the per-expert / per-tensor scale case (group_size == K). + float group_acc = 0.0f; int kk = 0; constexpr int CHUNK32 = 32; const int end32 = (group_size / CHUNK32) * CHUNK32; for (; kk < end32; kk += CHUNK32) { - fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, scale, - acc); + fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, + group_acc); } constexpr int CHUNK16 = 16; const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; for (; kk < end16; kk += CHUNK16) { - fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, scale, - acc); + fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, + group_acc); } for (; kk < group_size; ++kk) { const uint8_t raw = w_row[k_base + kk]; - const float w = decode_fp8(raw) * scale; - acc += static_cast(act_row[k_base + kk]) * w; + const float w = decode_fp8(raw); + group_acc += static_cast(act_row[k_base + kk]) * w; } + acc += group_acc * scale; } outputs[static_cast(token) * N + n_global] = static_cast(acc); From f2c47391ec13670047edb036a545bfe4366ef415 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:55:53 +0000 Subject: [PATCH 009/112] perf: defer per-group scale fold in native fp8 MoE prefill sym mainloop --- .../sycl_tla_moe_prefill_fp8_native.hpp | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_native.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_native.hpp index e3bed66e04..db8835aa1e 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_native.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_native.hpp @@ -75,10 +75,12 @@ // for g in [0, K/group_size): // scale group; barrier + scale reload // stage A[BM][group_size] into SLM // ONE cooperative load + barrier // scale = scales[e, n_col, g] // per-lane scale, loaded ONCE +// group_acc[m] = 0 // per-group deferred-scale acc // for sub in [0, group_size/BK): // BK sub-tile inside the group // load BK fp8 bytes for this lane // 4-byte chunked, unrolled -// w_col[k] = decode_fp8(byte) * scale -// acc[m] += sum_k a_slm[m, sub*BK+k] * w_col[k] +// w_col[k] = decode_fp8(byte) // scale deferred, not folded here +// group_acc[m] += sum_k a_slm[m, sub*BK+k] * w_col[k] +// acc[m] += group_acc[m] * scale // fold group scale ONCE per group // // vs. the original one-level loop that reloaded A + issued a barrier // once per BK-wide K-tile and reloaded the scale on every iteration. @@ -255,11 +257,15 @@ sycl::event launch_moe_prefill_fp8_native(sycl::queue* q, const ScalarT* activat // 3. Inner loop over `sub in [0, group_size/BK)` runs BK-wide // sub-tiles fully from SLM + registers with no extra barrier: // a. Fetch BK fp8 weight bytes (4-byte chunked, unrolled). - // b. Decode + fold scale in registers into w_col[BK]. - // c. MAC into acc[m] for each of the BM output rows. + // b. Decode fp8 -> float in registers into w_col[BK]. + // c. MAC into group_acc[m] for each of the BM output rows. + // Then fold the per-group scale into acc[m] once at the group + // boundary (deferred-scale), so the per-element `* scale` is + // lifted out of the weight-decode hot loop. // - // Per-BK partial-sum accumulation order is preserved bit-for-bit, - // so the FP8 parity tests (7e-2 tolerance) remain valid. + // Per-BK partial-sum accumulation order within a group is preserved, + // and folding the constant group scale once is distributive with the + // old per-element fold, so the FP8 parity tests (7e-2 tol) hold. // ----------------------------------------------------------------- const size_t w_row_stride = static_cast(K); // [E, N, K] row-major const size_t w_expert_stride = static_cast(N) * w_row_stride; @@ -298,6 +304,18 @@ sycl::event launch_moe_prefill_fp8_native(sycl::queue* q, const ScalarT* activat // in the prior revision). const float scale = static_cast(scales[s_row_base + static_cast(g)]); + // Per-scale-group deferred-scale accumulator (one FP32 per output + // row). The group scale is constant across the whole group, so we + // accumulate the raw A*W dot product here and fold in `scale` + // ONCE per group below (sum_k a*(w*s) == s * sum_k a*w). This + // removes the per-element `* scale` multiply from the weight-decode + // stage (group_size multiplies per lane per group) in exchange for + // BM multiplies at the group boundary — the same deferred-scale + // design the DPAS variant-B mainloop uses (`tCrC_group`). + float group_acc[BM]; +#pragma unroll + for (int m = 0; m < BM; ++m) group_acc[m] = 0.0f; + // --------- 3. Inner BK-sub-tile loop ------------------------- for (int sub = 0; sub < gs_per_tile; ++sub) { const int base_k_sub = sub * BK; @@ -307,7 +325,8 @@ sycl::event launch_moe_prefill_fp8_native(sycl::queue* q, const ScalarT* activat // aligned: `weights_NK` is 4-byte aligned (tensor storage) // and the offset `w_row_base + base_gk + base_k_sub` is a // multiple of BK = 32 (K % BK == 0, base_gk multiple of - // group_size which is a multiple of BK). + // group_size which is a multiple of BK). The per-group scale + // is NOT folded here — it is deferred to the group boundary. const size_t w_off = w_row_base + static_cast(base_gk) + static_cast(base_k_sub); const uint32_t* w_u32 = reinterpret_cast(weights_NK + w_off); @@ -320,16 +339,17 @@ sycl::event launch_moe_prefill_fp8_native(sycl::queue* q, const ScalarT* activat const uint8_t b1 = static_cast((w >> 8) & 0xFFu); const uint8_t b2 = static_cast((w >> 16) & 0xFFu); const uint8_t b3 = static_cast((w >> 24) & 0xFFu); - w_col[wi * 4 + 0] = moe_dequant::decode_fp8(b0) * scale; - w_col[wi * 4 + 1] = moe_dequant::decode_fp8(b1) * scale; - w_col[wi * 4 + 2] = moe_dequant::decode_fp8(b2) * scale; - w_col[wi * 4 + 3] = moe_dequant::decode_fp8(b3) * scale; + w_col[wi * 4 + 0] = moe_dequant::decode_fp8(b0); + w_col[wi * 4 + 1] = moe_dequant::decode_fp8(b1); + w_col[wi * 4 + 2] = moe_dequant::decode_fp8(b2); + w_col[wi * 4 + 3] = moe_dequant::decode_fp8(b3); } // 3b. MAC. For each output row m in this tile, dot-product // the length-BK slice of A[m] (staged in SLM) with - // `w_col`, accumulate into `acc[m]`. Same per-BK partial- - // sum shape as the original kernel to preserve numerics. + // `w_col`, accumulate into `group_acc[m]` (raw, unscaled). + // Same per-BK partial-sum shape as before to preserve + // numerics; the group scale is folded in once below. const size_t a_col_base = static_cast(base_k_sub); #pragma unroll for (int m = 0; m < BM; ++m) { @@ -341,10 +361,17 @@ sycl::event launch_moe_prefill_fp8_native(sycl::queue* q, const ScalarT* activat const float a_f = static_cast(a_slm[a_off]); sum += a_f * w_col[k]; } - acc[m] += sum; + group_acc[m] += sum; } } + // Fold the per-group scale into the running accumulator once per + // scale group (deferred-scale). Distributive with the per-element + // fold used previously, modulo FP32-accumulator ordering (within + // the 7e-2 FP8 tolerance in test_moe_prefill_accuracy.py). +#pragma unroll + for (int m = 0; m < BM; ++m) acc[m] += group_acc[m] * scale; + // Barrier before the next scale group re-stages A[]. it.barrier(sycl::access::fence_space::local_space); } From 90ba433293f82e6f71b5896c31c40d5dc0e64d32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:45:29 +0000 Subject: [PATCH 010/112] fix: resolve S4 MoE dispatch conflict (opt_moe env-policy + our w4a16 tiles) --- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index 3b2532ed44..ea499f7b88 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -776,22 +776,13 @@ void moe_prefill_s4_dpas_per_group_dispatch( num_tokens_per_expert, E, group_size, prefetch_dist, \ prefetch_dist_scale, atomic_buffer); -<<<<<<< HEAD - // Four-tier bucketing on the average tokens-per-expert, matching the - // reference `w4a16` dispatch in vllm-xpu-kernels - // (`grouped_gemm_xe2_interface.hpp`): tiny M uses an 8-row tile, and the - // 32-row tile now covers M up to 128 (instead of jumping to the wide - // large-M tile at 33) so mid-size chunked-prefill batches avoid the - // padding waste of the 128-row tile. - if (A_avg_M <= 4) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_8); - } else if (A_avg_M <= 8) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_16); - } else if (A_avg_M <= 128) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_32); - } else { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy); -======= + // Explicit env overrides (from the opt_moe_kernel policy infrastructure) + // take precedence and let benchmarks pin a specific tile family. The + // default (Auto) path uses our dedicated four-tier `w4a16` bucketing: + // the halved packed-B stream lets a wider N tile pay off, tiny M uses an + // 8-row tile, and the 32-row tile covers M up to 128 (instead of jumping + // to the wide large-M tile at 33) so mid-size chunked-prefill batches + // avoid the padding waste of the 128-row tile. if (policy_mode == MoePrefillS4PolicyMode::W16A16) { ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w16a16_policy); } else if (policy_mode == MoePrefillS4PolicyMode::W8A16M16) { @@ -807,18 +798,16 @@ void moe_prefill_s4_dpas_per_group_dispatch( ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); } } else { - // Auto policy for the direct path. On BMG MiniMax prefill shapes the - // small-M w8a16_m32 tile is much faster than larger tiles even up to - // 8K prompts (average M ~= 341), because per-expert token counts are - // highly skewed and many experts underfill larger M tiles. - if (A_avg_M <= 8) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 512) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); + // Auto policy for the direct path -- dedicated w4a16 tiles. + if (A_avg_M <= 4) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_8); + } else if (A_avg_M <= 8) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_16); + } else if (A_avg_M <= 128) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_32); } else { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy); } ->>>>>>> origin/opt_moe_kernel } #undef ARK_DPAS_S4_PG_LAUNCH_SYM From f4efc89518d97f8eac6163323f994972b2dc37b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:22:28 +0000 Subject: [PATCH 011/112] fix: restore INT4 MoE perf by defaulting S4 direct DPAS path ON --- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index ea499f7b88..153f13802c 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -178,24 +178,6 @@ enum class MoePrefillS4PolicyMode { W16A16, }; -inline bool moe_prefill_dpas_s4_force_direct() { - const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4"); - if (env != nullptr) { - std::string s(env); - for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); - if (s == "1" || s == "true" || s == "on" || s == "yes" || - s == "direct" || s == "force" || s == "packed") return true; - } - - const char* policy = std::getenv("ARK_MOE_PREFILL_DPAS_S4_POLICY"); - if (policy == nullptr) return false; - std::string p(policy); - for (auto& c : p) c = static_cast(std::tolower(static_cast(c))); - return p == "w16a16" || p == "w16" || p == "w8a16" || p == "w8" || - p == "w8a16_m16" || p == "w8_m16" || p == "m16" || - p == "w8a16_m32" || p == "w8_m32" || p == "m32"; -} - inline MoePrefillS4PolicyMode moe_prefill_dpas_s4_policy_mode() { const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4_POLICY"); if (env == nullptr) return MoePrefillS4PolicyMode::Auto; @@ -815,11 +797,14 @@ void moe_prefill_s4_dpas_per_group_dispatch( } // --------------------------------------------------------------------------- -// Env-flag helper -- `ARK_MOE_PREFILL_DPAS_S4`. The packed-nibble direct -// DPAS path is opt-in and uses the auto policy above by default. Current BMG -// MiniMax measurements prefer the w8a16_m32 tile family for real prefill -// distributions; callers can still force another policy for experiments via -// `ARK_MOE_PREFILL_DPAS_S4_POLICY=w16a16|w8a16|w8a16_m16|w8a16_m32`. +// Env-flag helper -- `ARK_MOE_PREFILL_DPAS_S4` (default ON). The packed-nibble +// direct DPAS path is the fast INT4 path: it reads the halved packed-B stream +// and folds the nibble->act upcast into the DPAS mainloop, so it must stay +// enabled by default to preserve INT4 MoE performance. By default it uses the +// auto policy above (our dedicated four-tier `w4a16` tiles). Callers can still +// pin another tile family for experiments via +// `ARK_MOE_PREFILL_DPAS_S4_POLICY=w16a16|w8a16|w8a16_m16|w8a16_m32`, which only +// selects the GEMM tile -- it does not gate the path. // // Truthy values (case-insensitive): "1", "true", "on", "yes". // Explicit "0" / "false" / "off" / "no" disable. Re-read on every @@ -837,11 +822,11 @@ void moe_prefill_s4_dpas_per_group_dispatch( // --------------------------------------------------------------------------- inline bool moe_prefill_dpas_s4_enabled() { const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4"); - if (env == nullptr) return false; + if (env == nullptr) return true; // default ON -- fast INT4 direct path std::string s(env); for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); if (s == "0" || s == "false" || s == "off" || s == "no") return false; - return moe_prefill_dpas_s4_force_direct(); + return true; } // --------------------------------------------------------------------------- From 5466928079d00e85c09c29551382ea0ae091f8be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:44:21 +0000 Subject: [PATCH 012/112] revert: restore int4 MoE to original base implementation --- .../wrapper/include/sycl_tla_moe_decode.hpp | 94 ++---- .../wrapper/include/sycl_tla_moe_mixed.hpp | 7 +- .../include/sycl_tla_moe_prefill_fp8_dpas.hpp | 29 -- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 269 ++++-------------- 4 files changed, 94 insertions(+), 305 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 0f03604ccf..adfb775aa3 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -204,50 +204,6 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight // of byte i, the value at k = 2*i+1 is the HIGH nibble. This matches the // existing CPU/XPU `packq` layout for S4_CLIP weights. // ---------------------------------------------------------------------------- - -// Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK/2 -// packed weight bytes + a vec activation block). Templated on -// CHUNK so the caller can run a wide (32) stage first and a narrower (16) -// stage for the remainder, which keeps the fast path active for group sizes -// that are a multiple of 32 (32/64/128/256 -- the shipped quant configs) -// without regressing group_size == 16 (which drops straight to the 16-wide -// stage). The math is identical to the scalar path. -template -static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float zero, - float& acc) { - static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); - // sycl::vec only supports widths of 1, 2, 3, 4, 8 or 16, so a single - // vec load is illegal. Process the chunk in 16-wide sub-blocks - // (16 activations + 8 packed weight bytes each), which keeps CHUNK == 32 - // valid while reusing the same code path for CHUNK == 16. - constexpr int SUB = 16; - using ActVec = sycl::vec; - using PackVec = sycl::vec; -#pragma unroll - for (int s = 0; s < CHUNK / SUB; ++s) { - const ActVec av = *reinterpret_cast(act_ptr + s * SUB); - const PackVec pv = *reinterpret_cast(w_ptr + s * (SUB / 2)); -#pragma unroll - for (int b = 0; b < SUB / 2; ++b) { - int q0, q1; - decode_int4_pair(pv[b], q0, q1); - float w0, w1; - if constexpr (Asym) { - w0 = (static_cast(q0) - zero) * scale; - w1 = (static_cast(q1) - zero) * scale; - } else { - w0 = static_cast(q0) * scale; - w1 = static_cast(q1) * scale; - } - const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); - acc += static_cast(a0) * w0; - acc += static_cast(a1) * w1; - } - } -} - template void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, const ScalarT* zeros, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, @@ -297,27 +253,39 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig zero = static_cast(z_row[g]); } const int k_base = g * group_size; - // Vectorized ladder: process 32 K-elements at a time (16 packed - // weight bytes + vec activation block), then a 16-wide - // stage for the remainder, then a scalar tail. Widening the first - // stage to 32 amortizes the per-group scale load and loop overhead - // across twice as many multiply-adds for the shipped group sizes - // (32/64/128/256), while the 16-wide stage keeps group_size == 16 - // on the fast path. + // Vectorized path: process 16 K-elements at a time, which is + // 8 packed weight bytes and a vec activation block. + // group_size is a multiple of 16 in every supported config + // (group_size >= 32, even); a scalar tail loop covers leftovers. + constexpr int CHUNK = 16; + using ActVec = sycl::vec; + using PackVec = sycl::vec; + static_assert(sizeof(ScalarT) == sizeof(uint16_t), + "ScalarT must be a 16-bit floating type"); + const int chunk_end = (group_size / CHUNK) * CHUNK; int kk = 0; - constexpr int CHUNK32 = 32; - const int end32 = (group_size / CHUNK32) * CHUNK32; - for (; kk < end32; kk += CHUNK32) { - int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, - zero, acc); - } - constexpr int CHUNK16 = 16; - const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; - for (; kk < end16; kk += CHUNK16) { - int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, - zero, acc); + for (; kk < chunk_end; kk += CHUNK) { + const ActVec av = *reinterpret_cast(act_row + k_base + kk); + const PackVec pv = *reinterpret_cast(w_row + (k_base + kk) / 2); +#pragma unroll + for (int b = 0; b < CHUNK / 2; ++b) { + int q0, q1; + decode_int4_pair(pv[b], q0, q1); + float w0, w1; + if constexpr (Asym) { + w0 = (static_cast(q0) - zero) * scale; + w1 = (static_cast(q1) - zero) * scale; + } else { + w0 = static_cast(q0) * scale; + w1 = static_cast(q1) * scale; + } + const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); + acc += static_cast(a0) * w0; + acc += static_cast(a1) * w1; + } } - // Scalar tail for group_size not divisible by 16. + // Scalar tail for group_size not divisible by CHUNK. for (; kk < group_size; kk += 2) { const uint8_t packed = w_row[(k_base + kk) / 2]; int q0, q1; diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp index 2312702563..e6a3ececcd 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_mixed.hpp @@ -129,7 +129,6 @@ class MoEUpcastInt2SymToInt8KernelFast; // coalesced burst per row for FP16/BF16 writes, which matches the L1 // cache-line size on the target XPUs. constexpr int WG_N = 32; -constexpr int WG_K_INT4_FAST = 8; constexpr int PACK_K_FP = 4; constexpr int PACK_K_INT8 = 4; constexpr int PACK_K_INT4 = 2; @@ -278,17 +277,15 @@ void launch_dequant_int4(sycl::queue* q, const uint8_t* weights_NKp, const Scala // relative to the byte-per-work-item path. if ((K % PACK_K_INT4_FAST) == 0 && (group_size % PACK_K_INT4_FAST) == 0) { const int k_words = K / PACK_K_INT4_FAST; // == k_packed / 4 - const int k_words_padded = ((k_words + WG_K_INT4_FAST - 1) / WG_K_INT4_FAST) * WG_K_INT4_FAST; - sycl::range<3> global_fast{static_cast(E), static_cast(k_words_padded), + sycl::range<3> global_fast{static_cast(E), static_cast(k_words), static_cast((N + WG_N - 1) / WG_N) * WG_N}; - sycl::range<3> local_fast{1, static_cast(WG_K_INT4_FAST), static_cast(WG_N)}; + sycl::range<3> local_fast{1, 1, static_cast(WG_N)}; q->parallel_for>( sycl::nd_range<3>(global_fast, local_fast), [=](sycl::nd_item<3> it) { const int e = static_cast(it.get_global_id(0)); if (num_tokens_per_expert != nullptr && num_tokens_per_expert[e] == 0) return; const int kw = static_cast(it.get_global_id(1)); - if (kw >= k_words) return; const int n = static_cast(it.get_global_id(2)); if (n >= N) return; const int k_base = kw * PACK_K_INT4_FAST; diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index 05bce99f76..35db93cac2 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -193,35 +193,6 @@ class dpas_w8a16_policy_m_32 : public dpas_policy_base { using SGLayout = Layout, Stride<_4, _1, _0>>; }; -// --------------------------------------------------------------------------- -// 4-bit (S4 / w4a16) tile policies. -// -// The S4 mixed-input prefill mainloop (`sycl_tla_moe_prefill_s4_dpas.hpp`) -// reads a *halved* B-side byte stream (two nibbles per byte). With the same -// M/N footprint the packed weight fits in half the L2/GRF traffic of the INT8 -// path, so a larger N tile pays off: the default large-M policy uses a -// 128x256x32 WG tile (vs. the INT8 128x128x16), matching the reference -// `w4a16_policy` in vllm-xpu-kernels `csrc/xpu/grouped_gemm/xe_2/ -// gemm_xe2_policy.hpp`. The small-M buckets (m_8/m_16/m_32) reuse the same -// 64-wide N tiles as the INT8 path -- the reference uses identical shapes -// there. Only the default (large-M) tile and the new m_8 bucket differ, so we -// define those two here and alias m_16/m_32 to the shared shapes in the S4 -// header. -// --------------------------------------------------------------------------- -class dpas_w4a16_policy : public dpas_policy_base { - public: - using WGTile = Shape<_128, _256, _32>; - using SGLayout = Layout, Stride<_8, _1, _0>>; - - using GmemTiledCopyD = XE_STORE_2D<16, 8, 32>; -}; - -class dpas_w4a16_policy_m_8 : public dpas_policy_base { - public: - using WGTile = Shape<_8, _64, _32>; - using SGLayout = Layout, Stride<_4, _1, _0>>; -}; - // --------------------------------------------------------------------------- // `apply_scale` -- inline-asm per-lane multiply of a bf16/fp16 fragment by // an FP32 scalar. Copied verbatim from vllm-xpu-kernels `gemm_xe2.hpp`. diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index 153f13802c..fad8d87eb7 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -91,7 +91,6 @@ #pragma once #include -#include #include #include #include @@ -130,66 +129,11 @@ using ::ark::moe_dpas_fp8::dpas_w16a16_policy; using ::ark::moe_dpas_fp8::dpas_w8a16_policy; using ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_16; using ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_32; -// Dedicated 4-bit tile policies. The default (large-M) tile is 128x256x32 -// (halved packed-B stream lets a wider N tile pay off) and the m_8 bucket is -// new; both mirror the reference `w4a16_policy*` in vllm-xpu-kernels. The -// m_16 / m_32 buckets share the INT8 64-wide N tiles, so they are aliased to -// the existing `dpas_w8a16_policy_m_16 / _m_32` shapes rather than duplicated. -using ::ark::moe_dpas_fp8::dpas_w4a16_policy; -using ::ark::moe_dpas_fp8::dpas_w4a16_policy_m_8; -using dpas_w4a16_policy_m_16 = ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_16; -using dpas_w4a16_policy_m_32 = ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_32; using ::ark::moe_dpas_fp8::ScaleMode; using ::ark::moe_dpas_fp8::cute_scalar; using ::ark::moe_dpas_fp8::cute_scalar_t; using ::ark::moe_dpas_fp8::make_moe_tensor; -inline int _moe_prefill_s4_env_int(const char* name, int default_v, - int min_v, int max_v) { - const char* env = std::getenv(name); - if (env == nullptr) return default_v; - char* end = nullptr; - long v = std::strtol(env, &end, 10); - if (end == env || *end != '\0') return default_v; - if (v < static_cast(min_v)) return min_v; - if (v > static_cast(max_v)) return max_v; - return static_cast(v); -} - -inline int moe_prefill_dpas_s4_prefetch_dist() { - // Runtime-tunable A/B prefetch depth for S4 per-group mainloop. - // Default keeps current behavior. - return _moe_prefill_s4_env_int("ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST", - 3, 1, 16); -} - -inline int moe_prefill_dpas_s4_prefetch_dist_scale() { - // Runtime-tunable scale prefetch lookahead (in groups). - // Default keeps current behavior. - return _moe_prefill_s4_env_int( - "ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST_SCALE", 3, 1, 16); -} - -enum class MoePrefillS4PolicyMode { - Auto, - W8A16, - W8A16M16, - W8A16M32, - W16A16, -}; - -inline MoePrefillS4PolicyMode moe_prefill_dpas_s4_policy_mode() { - const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4_POLICY"); - if (env == nullptr) return MoePrefillS4PolicyMode::Auto; - std::string s(env); - for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); - if (s == "w16a16" || s == "w16") return MoePrefillS4PolicyMode::W16A16; - if (s == "w8a16_m16" || s == "w8_m16" || s == "m16") return MoePrefillS4PolicyMode::W8A16M16; - if (s == "w8a16_m32" || s == "w8_m32" || s == "m32") return MoePrefillS4PolicyMode::W8A16M32; - if (s == "w8a16" || s == "w8") return MoePrefillS4PolicyMode::W8A16; - return MoePrefillS4PolicyMode::Auto; -} - // --------------------------------------------------------------------------- // Variant B -- per-K-group S4 (sym) mainloop. // @@ -228,9 +172,8 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( const ElementBI* Bias, DTensor& C, // (M,N) -- ElementA Coord blk_coord, - TiledMMA const& mma, - int prefetch_dist, - int prefetch_dist_scale) { + TiledMMA const& mma) { + using TA = typename ATensor::element_type; using TB = typename BTensor::element_type; static_assert(std::is_same_v, "xe_gemm_s4_pergroup: ElementB must be cutlass::int4b_t (sym only)"); @@ -261,13 +204,8 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( auto thr_copy_b = copy_b.get_slice(local_id); auto thr_copy_c = copy_c.get_slice(local_id); - // Ping-pong SG fragments for software pipelining: - // while tile k computes on one fragment pair, tile k+1 is unpacked - // into the other pair. - auto tCrA0 = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); - auto tCrB0 = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); - auto tCrA1 = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); - auto tCrB1 = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); + auto tCrA = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); + auto tCrB = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); auto tArA = thr_copy_a.partition_sg_fragment_D(gA(_, _, 0)); auto tBrB = thr_copy_b.partition_sg_fragment_D(gB(_, _, 0)); @@ -288,8 +226,11 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( auto pAgA = thr_prefetch_A.partition_S(gA); auto pBgB = thr_prefetch_B.partition_S(gB); - // Prefetch distances are host-selected and threaded into the device - // path so no env/stdlib calls are needed inside SYCL kernels. + // Prefetch distance mirrors `xe_gemm_int_pergroup<>` for now. + // On-hardware perf tuning may want to grow `prefetch_dist` on the + // packed path since the B stream is half the bandwidth. + const int prefetch_dist = 3; + const int prefetch_dist_scale = 3; constexpr auto barrier_scope = ScopeWorkgroup; int k_tile_count = ceil_div(shape<1>(A), get<2>(wg_tile)); int k_tile_prefetch = 0; @@ -308,8 +249,11 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( auto n_tile_start = wg_n * tile_n; auto sg_local_n_coord = cutlass::get_sub_group_id() % ATOM_N; + auto sg_local_m_coord = cutlass::get_sub_group_id() / ATOM_N; int sg_local_id = cutlass::get_sub_group_local_id(); int n_sg_start = sg_local_n_coord * SG_N; + int m_sg_start = sg_local_m_coord * SG_M; + int m_tile_start = wg_m * tile_m; int group_num = get<1>(A.shape()) / group_size; // Group-local accumulator: same fragment shape as `tCrC`, cleared at @@ -323,16 +267,6 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( // Per-SG per-N scale cache. Same layout / semantics as the INT8 // per-group path. float sg_scale[sg_n_strides]; - int sg_scale_base[sg_n_strides]; - - // Precompute per-lane base offsets into Scales so the hot loop only - // adds group_idx. - CUTLASS_PRAGMA_UNROLL - for (int sn = 0; sn < sg_n_strides; ++sn) { - int sg_local_n = sn * sg_local_range + sg_local_id; - sg_scale_base[sn] = - (n_tile_start + n_sg_start + sg_local_n) * group_num; - } CUTE_UNROLL for (; k_tile_prefetch < prefetch_dist; k_tile_prefetch++) { @@ -356,48 +290,27 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( } } - if (k_tile_count == 0) { - return; - } - - // Prime the pipeline with tile 0. - barrier_arrive(barrier_scope); - copy(copy_a, tAgA(_, _, _, 0), tArA); - copy(copy_b, tBgB(_, _, _, 0), tBrB); - if (k_tile_prefetch < k_tile_count) { - prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); - prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); - } - reorder(tArA, tCrA0); - reorder(tBrB, tCrB0); - barrier_wait(barrier_scope); - - int curr_buf = 0; - static constexpr int tiles_per_group = group_size / tile_k; - int group_tile_remaining = 0; - int group_idx = 0; for (int k_tile = 0; k_tile < k_tile_count; k_tile++, k_tile_prefetch++) { - const bool has_next = (k_tile + 1 < k_tile_count); - const int next_tile = k_tile + 1; - - if (has_next) { - barrier_arrive(barrier_scope); - copy(copy_a, tAgA(_, _, _, next_tile), tArA); - copy(copy_b, tBgB(_, _, _, next_tile), tBrB); - } - - // Group-boundary scale reload via a tile counter instead of per-tile - // modulo/div in the hot loop. For all supported S4 shapes in this - // file, `group_size` is a multiple of `tile_k`. - if (group_tile_remaining == 0) { - group_idx = k_tile / tiles_per_group; + barrier_arrive(barrier_scope); + + copy(copy_a, tAgA(_, _, _, k_tile), tArA); + copy(copy_b, tBgB(_, _, _, k_tile), tBrB); + + // Group-boundary scale reload. Same math as the INT8 per-group + // path: `tile_k` is expressed in element units (nibbles), not + // bytes, so `k_tile * tile_k` is the reduction position in + // *element* space and the modulo test against `group_size` matches + // the scale-tensor layout `[E, N, K/group_size]` unchanged. + if (k_tile * tile_k % group_size == 0) { + int group_idx = (k_tile * tile_k) / group_size; CUTLASS_PRAGMA_UNROLL for (int sn = 0; sn < sg_n_strides; ++sn) { - sg_scale[sn] = - static_cast(Scales[sg_scale_base[sn] + group_idx]); + int sg_local_n = sn * sg_local_range + sg_local_id; + sg_scale[sn] = static_cast( + Scales[(n_tile_start + n_sg_start + sg_local_n) * group_num + group_idx]); } - if (group_idx + prefetch_dist_scale < group_num) { + if ((group_idx + prefetch_dist_scale) * group_size < shape<1>(A)) { auto next_scales_tensor = make_tensor( make_gmem_ptr(reinterpret_cast( Scales + (n_tile_start + n_sg_start) * group_num + @@ -411,41 +324,32 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( make_identity_tensor(make_shape(Int{}, Int<1>{}))); prefetch(prefetch_scales, pSgS(_, 0, 0)); } - group_tile_remaining = tiles_per_group; } - if (has_next && k_tile_prefetch < k_tile_count) { + if (k_tile_prefetch < k_tile_count) { prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); } - if (has_next) { - // Pipeline stage: unpack tile k+1 while tile k compute is pending. - // `reorder` performs in-register `int4b_t -> ElementA` conversion via - // `NumericArrayConverter`. - if (curr_buf == 0) { - reorder(tArA, tCrA1); - reorder(tBrB, tCrB1); - } else { - reorder(tArA, tCrA0); - reorder(tBrB, tCrB0); - } - } + // `reorder` performs the in-register `int4b_t -> ElementA` unpack + // + sign-extend + cast via `cutlass::NumericArrayConverter< + // ElementA, cutlass::int4b_t, N>`. Once `tCrB` carries bf16/fp16 + // values it is compatible with the same DPAS atom used by the FP8 + // / INT8 per-group paths. See the header preamble open-question + // (1) -- if the pinned cutlass-sycl is missing this converter + // specialisation this line is where the build fails. + reorder(tArA, tCrA); + reorder(tBrB, tCrB); // HOT MAINLOOP -- MMA accumulates into `tCrC_group`. Per-N scale // is applied ONCE at the end of the group in the fold block below. - if (curr_buf == 0) { - cute::gemm(mma, tCrA0, tCrB0, tCrC_group); - } else { - cute::gemm(mma, tCrA1, tCrB1, tCrC_group); - } + cute::gemm(mma, tCrA, tCrB, tCrC_group); - group_tile_remaining -= 1; - // Group-boundary fold. Fires when either (a) we've consumed all - // tiles in the current group, or (b) this is the last reduction tile - // (tail-group protection). - const bool is_group_end = (group_tile_remaining == 0) || - (k_tile + 1 == k_tile_count); + // Group-boundary fold. Fires when either (a) the NEXT k_tile would + // start a new scale group, or (b) we've reached the last k_tile of + // the K reduction (tail-group protection). + const bool is_group_end = (((k_tile + 1) * tile_k) % group_size == 0) || + (k_tile + 1 == k_tile_count); if (is_group_end) { CUTLASS_PRAGMA_UNROLL for (int sn = 0; sn < sg_n_strides; ++sn) { @@ -459,10 +363,7 @@ CUTE_DEVICE void xe_gemm_s4_pergroup( } } - if (has_next) { - barrier_wait(barrier_scope); - curr_buf ^= 1; - } + barrier_wait(barrier_scope); } if (Bias != nullptr) { @@ -507,10 +408,7 @@ CUTE_DEVICE void MoEGEMM_s4(const ElementA* Activations, const int* rows_per_expert, const int32_t num_experts, const int32_t group_size, const int32_t gemm_n, - const int32_t gemm_k, - int prefetch_dist, - int prefetch_dist_scale, - int32_t* atomic_buffer, + const int32_t gemm_k, int32_t* atomic_buffer, const sycl::local_accessor& slm_mem_const) { constexpr char actual_layout_of_B = LayoutKindB ^ ('R' ^ 'C'); @@ -597,7 +495,7 @@ CUTE_DEVICE void MoEGEMM_s4(const ElementA* Activations, #define ARK_MOE_DPAS_S4_GROUP_CALLER(GS) \ xe_gemm_s4_pergroup( \ A_tensor, B_tensor, ptr_Scales_curr_batch, ptr_Bias_curr_batch, \ - D_tensor, tile_coord, mma, prefetch_dist, prefetch_dist_scale); + D_tensor, tile_coord, mma); if (group_size == 32) { ARK_MOE_DPAS_S4_GROUP_CALLER(32) } else if (group_size == 64) { @@ -636,10 +534,7 @@ void MoEGEMMLauncher_s4(sycl::queue& stream, const ElementA* activations, const ElementBI* bias, ElementD* outputs, const int gemm_n, const int gemm_k, const int* rows_per_expert, const int num_experts, - const int group_size, - int prefetch_dist, - int prefetch_dist_scale, - int32_t* atomic_buffer) { + const int group_size, int32_t* atomic_buffer) { using ElementA_non_CV = cutlass::platform::remove_cv_t; // DPAS atom keeps its bf16/fp16 x bf16/fp16 -> fp32 shape; the S4 B // tensor is upcast to ElementA in `reorder(tBrB, tCrB)` in the @@ -686,7 +581,7 @@ void MoEGEMMLauncher_s4(sycl::queue& stream, const ElementA* activations, layoutB, 'R'>( activations, weights, scales, bias, outputs, mma, rows_per_expert, num_experts, group_size, gemm_n, gemm_k, - prefetch_dist, prefetch_dist_scale, atomic_buffer, local_mem); + atomic_buffer, local_mem); }); }); @@ -741,9 +636,6 @@ void moe_prefill_s4_dpas_per_group_dispatch( reinterpret_cast(weights_NKp); int A_avg_M = total_tokens / E; - const int prefetch_dist = moe_prefill_dpas_s4_prefetch_dist(); - const int prefetch_dist_scale = moe_prefill_dpas_s4_prefetch_dist_scale(); - const MoePrefillS4PolicyMode policy_mode = moe_prefill_dpas_s4_policy_mode(); int32_t* atomic_buffer = sycl::malloc_device(1, *q); if (atomic_buffer == nullptr) { @@ -755,41 +647,14 @@ void moe_prefill_s4_dpas_per_group_dispatch( MoEGEMMLauncher_s4<'R', 'C', policy>( \ *q, activations_ca, weights_i4, scales_ca, \ static_cast(nullptr), outputs_ca, N, K, \ - num_tokens_per_expert, E, group_size, prefetch_dist, \ - prefetch_dist_scale, atomic_buffer); - - // Explicit env overrides (from the opt_moe_kernel policy infrastructure) - // take precedence and let benchmarks pin a specific tile family. The - // default (Auto) path uses our dedicated four-tier `w4a16` bucketing: - // the halved packed-B stream lets a wider N tile pay off, tiny M uses an - // 8-row tile, and the 32-row tile covers M up to 128 (instead of jumping - // to the wide large-M tile at 33) so mid-size chunked-prefill batches - // avoid the padding waste of the 128-row tile. - if (policy_mode == MoePrefillS4PolicyMode::W16A16) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w16a16_policy); - } else if (policy_mode == MoePrefillS4PolicyMode::W8A16M16) { + num_tokens_per_expert, E, group_size, atomic_buffer); + + if (A_avg_M <= 8) { ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); - } else if (policy_mode == MoePrefillS4PolicyMode::W8A16M32) { + } else if (A_avg_M <= 32) { ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); - } else if (policy_mode == MoePrefillS4PolicyMode::W8A16) { - if (A_avg_M <= 8) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); - } else { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); - } } else { - // Auto policy for the direct path -- dedicated w4a16 tiles. - if (A_avg_M <= 4) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_8); - } else if (A_avg_M <= 8) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_16); - } else if (A_avg_M <= 128) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_32); - } else { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy); - } + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); } #undef ARK_DPAS_S4_PG_LAUNCH_SYM @@ -797,32 +662,20 @@ void moe_prefill_s4_dpas_per_group_dispatch( } // --------------------------------------------------------------------------- -// Env-flag helper -- `ARK_MOE_PREFILL_DPAS_S4` (default ON). The packed-nibble -// direct DPAS path is the fast INT4 path: it reads the halved packed-B stream -// and folds the nibble->act upcast into the DPAS mainloop, so it must stay -// enabled by default to preserve INT4 MoE performance. By default it uses the -// auto policy above (our dedicated four-tier `w4a16` tiles). Callers can still -// pin another tile family for experiments via -// `ARK_MOE_PREFILL_DPAS_S4_POLICY=w16a16|w8a16|w8a16_m16|w8a16_m32`, which only -// selects the GEMM tile -- it does not gate the path. +// Env-flag helper -- `ARK_MOE_PREFILL_DPAS_S4` (default ON, semantics +// identical to `moe_prefill_dpas_int_enabled` / `moe_prefill_dpas_fp8 +// _enabled`). Decoupled from `ARK_MOE_PREFILL_DPAS_INT8` so this new +// single-pass path can be disabled in isolation if it regresses -- +// switching S4 off falls back to the S4->S8 upcast + INT8 DPAS path +// which is itself gated by `ARK_MOE_PREFILL_DPAS_INT8`. // // Truthy values (case-insensitive): "1", "true", "on", "yes". // Explicit "0" / "false" / "off" / "no" disable. Re-read on every // call so benchmarks / tests can toggle the path in-process. -// -// Additional runtime tuning knobs for the S4 per-group mainloop: -// - ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST (default 3, clamp [1, 16]) -// - ARK_MOE_PREFILL_DPAS_S4_PREFETCH_DIST_SCALE (default 3, clamp [1, 16]) -// - ARK_MOE_PREFILL_DPAS_S4_POLICY (auto|w8a16|w8a16_m16| -// w8a16_m32|w16a16, -// default auto) -// -// These tune A/B tile prefetch depth and per-group scale prefetch lookahead -// respectively; `*_POLICY` selects the GEMM tile family. // --------------------------------------------------------------------------- inline bool moe_prefill_dpas_s4_enabled() { const char* env = std::getenv("ARK_MOE_PREFILL_DPAS_S4"); - if (env == nullptr) return true; // default ON -- fast INT4 direct path + if (env == nullptr) return true; // default ON std::string s(env); for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); if (s == "0" || s == "false" || s == "off" || s == "no") return false; From 515f839a0d2d377a4eddbacd0a3d0f39abb86485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:57:12 +0000 Subject: [PATCH 013/112] Changes before error encountered Agent-Logs-Url: https://github.com/intel/auto-round/sessions/9f204578-f116-4469-ac32-efbb1cdcfadc --- .../wrapper/include/sycl_tla_moe_decode.hpp | 94 +++++++++++++------ .../include/sycl_tla_moe_prefill_fp8_dpas.hpp | 29 ++++++ .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 27 +++++- 3 files changed, 114 insertions(+), 36 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index adfb775aa3..0f03604ccf 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -204,6 +204,50 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight // of byte i, the value at k = 2*i+1 is the HIGH nibble. This matches the // existing CPU/XPU `packq` layout for S4_CLIP weights. // ---------------------------------------------------------------------------- + +// Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK/2 +// packed weight bytes + a vec activation block). Templated on +// CHUNK so the caller can run a wide (32) stage first and a narrower (16) +// stage for the remainder, which keeps the fast path active for group sizes +// that are a multiple of 32 (32/64/128/256 -- the shipped quant configs) +// without regressing group_size == 16 (which drops straight to the 16-wide +// stage). The math is identical to the scalar path. +template +static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float zero, + float& acc) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); + // sycl::vec only supports widths of 1, 2, 3, 4, 8 or 16, so a single + // vec load is illegal. Process the chunk in 16-wide sub-blocks + // (16 activations + 8 packed weight bytes each), which keeps CHUNK == 32 + // valid while reusing the same code path for CHUNK == 16. + constexpr int SUB = 16; + using ActVec = sycl::vec; + using PackVec = sycl::vec; +#pragma unroll + for (int s = 0; s < CHUNK / SUB; ++s) { + const ActVec av = *reinterpret_cast(act_ptr + s * SUB); + const PackVec pv = *reinterpret_cast(w_ptr + s * (SUB / 2)); +#pragma unroll + for (int b = 0; b < SUB / 2; ++b) { + int q0, q1; + decode_int4_pair(pv[b], q0, q1); + float w0, w1; + if constexpr (Asym) { + w0 = (static_cast(q0) - zero) * scale; + w1 = (static_cast(q1) - zero) * scale; + } else { + w0 = static_cast(q0) * scale; + w1 = static_cast(q1) * scale; + } + const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); + acc += static_cast(a0) * w0; + acc += static_cast(a1) * w1; + } + } +} + template void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, const ScalarT* zeros, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, @@ -253,39 +297,27 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig zero = static_cast(z_row[g]); } const int k_base = g * group_size; - // Vectorized path: process 16 K-elements at a time, which is - // 8 packed weight bytes and a vec activation block. - // group_size is a multiple of 16 in every supported config - // (group_size >= 32, even); a scalar tail loop covers leftovers. - constexpr int CHUNK = 16; - using ActVec = sycl::vec; - using PackVec = sycl::vec; - static_assert(sizeof(ScalarT) == sizeof(uint16_t), - "ScalarT must be a 16-bit floating type"); - const int chunk_end = (group_size / CHUNK) * CHUNK; + // Vectorized ladder: process 32 K-elements at a time (16 packed + // weight bytes + vec activation block), then a 16-wide + // stage for the remainder, then a scalar tail. Widening the first + // stage to 32 amortizes the per-group scale load and loop overhead + // across twice as many multiply-adds for the shipped group sizes + // (32/64/128/256), while the 16-wide stage keeps group_size == 16 + // on the fast path. int kk = 0; - for (; kk < chunk_end; kk += CHUNK) { - const ActVec av = *reinterpret_cast(act_row + k_base + kk); - const PackVec pv = *reinterpret_cast(w_row + (k_base + kk) / 2); -#pragma unroll - for (int b = 0; b < CHUNK / 2; ++b) { - int q0, q1; - decode_int4_pair(pv[b], q0, q1); - float w0, w1; - if constexpr (Asym) { - w0 = (static_cast(q0) - zero) * scale; - w1 = (static_cast(q1) - zero) * scale; - } else { - w0 = static_cast(q0) * scale; - w1 = static_cast(q1) * scale; - } - const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); - acc += static_cast(a0) * w0; - acc += static_cast(a1) * w1; - } + constexpr int CHUNK32 = 32; + const int end32 = (group_size / CHUNK32) * CHUNK32; + for (; kk < end32; kk += CHUNK32) { + int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, + zero, acc); + } + constexpr int CHUNK16 = 16; + const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; + for (; kk < end16; kk += CHUNK16) { + int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, + zero, acc); } - // Scalar tail for group_size not divisible by CHUNK. + // Scalar tail for group_size not divisible by 16. for (; kk < group_size; kk += 2) { const uint8_t packed = w_row[(k_base + kk) / 2]; int q0, q1; diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index 35db93cac2..05bce99f76 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -193,6 +193,35 @@ class dpas_w8a16_policy_m_32 : public dpas_policy_base { using SGLayout = Layout, Stride<_4, _1, _0>>; }; +// --------------------------------------------------------------------------- +// 4-bit (S4 / w4a16) tile policies. +// +// The S4 mixed-input prefill mainloop (`sycl_tla_moe_prefill_s4_dpas.hpp`) +// reads a *halved* B-side byte stream (two nibbles per byte). With the same +// M/N footprint the packed weight fits in half the L2/GRF traffic of the INT8 +// path, so a larger N tile pays off: the default large-M policy uses a +// 128x256x32 WG tile (vs. the INT8 128x128x16), matching the reference +// `w4a16_policy` in vllm-xpu-kernels `csrc/xpu/grouped_gemm/xe_2/ +// gemm_xe2_policy.hpp`. The small-M buckets (m_8/m_16/m_32) reuse the same +// 64-wide N tiles as the INT8 path -- the reference uses identical shapes +// there. Only the default (large-M) tile and the new m_8 bucket differ, so we +// define those two here and alias m_16/m_32 to the shared shapes in the S4 +// header. +// --------------------------------------------------------------------------- +class dpas_w4a16_policy : public dpas_policy_base { + public: + using WGTile = Shape<_128, _256, _32>; + using SGLayout = Layout, Stride<_8, _1, _0>>; + + using GmemTiledCopyD = XE_STORE_2D<16, 8, 32>; +}; + +class dpas_w4a16_policy_m_8 : public dpas_policy_base { + public: + using WGTile = Shape<_8, _64, _32>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + // --------------------------------------------------------------------------- // `apply_scale` -- inline-asm per-lane multiply of a bf16/fp16 fragment by // an FP32 scalar. Copied verbatim from vllm-xpu-kernels `gemm_xe2.hpp`. diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index fad8d87eb7..693a8675e5 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -129,6 +129,15 @@ using ::ark::moe_dpas_fp8::dpas_w16a16_policy; using ::ark::moe_dpas_fp8::dpas_w8a16_policy; using ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_16; using ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_32; +// Dedicated 4-bit tile policies. The default (large-M) tile is 128x256x32 +// (halved packed-B stream lets a wider N tile pay off) and the m_8 bucket is +// new; both mirror the reference `w4a16_policy*` in vllm-xpu-kernels. The +// m_16 / m_32 buckets share the INT8 64-wide N tiles, so they are aliased to +// the existing `dpas_w8a16_policy_m_16 / _m_32` shapes rather than duplicated. +using ::ark::moe_dpas_fp8::dpas_w4a16_policy; +using ::ark::moe_dpas_fp8::dpas_w4a16_policy_m_8; +using dpas_w4a16_policy_m_16 = ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_16; +using dpas_w4a16_policy_m_32 = ::ark::moe_dpas_fp8::dpas_w8a16_policy_m_32; using ::ark::moe_dpas_fp8::ScaleMode; using ::ark::moe_dpas_fp8::cute_scalar; using ::ark::moe_dpas_fp8::cute_scalar_t; @@ -649,12 +658,20 @@ void moe_prefill_s4_dpas_per_group_dispatch( static_cast(nullptr), outputs_ca, N, K, \ num_tokens_per_expert, E, group_size, atomic_buffer); - if (A_avg_M <= 8) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy_m_32); + // Four-tier bucketing on the average tokens-per-expert, matching the + // reference `w4a16` dispatch in vllm-xpu-kernels + // (`grouped_gemm_xe2_interface.hpp`): tiny M uses an 8-row tile, and the + // 32-row tile now covers M up to 128 (instead of jumping to the wide + // large-M tile at 33) so mid-size chunked-prefill batches avoid the + // padding waste of the 128-row tile. + if (A_avg_M <= 4) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_8); + } else if (A_avg_M <= 8) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_16); + } else if (A_avg_M <= 128) { + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy_m_32); } else { - ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w8a16_policy); + ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy); } #undef ARK_DPAS_S4_PG_LAUNCH_SYM From 93cde8c02bb7b46dcb9ca35afc7406ecd7f35605 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:47:05 +0000 Subject: [PATCH 014/112] fix: restore fp8 per-expert MoE prefill large-M tile policy threshold Merge ec61621 accidentally widened the dpas_w8a16_policy_m_32 bucket in the fp8 per-tensor (per-expert) prefill dispatch from A_avg_M <= 32 to <= 512, routing large-M prefill through the small 32x64 tile instead of the large-M 128x128 default tile and regressing performance. Restore the <= 32 threshold. --- .../wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index 05bce99f76..b67694e80b 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -973,7 +973,7 @@ void moe_prefill_fp8_dpas_per_tensor_dispatch( if (A_avg_M <= 8) { ARK_DPAS_PT_LAUNCH(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 512) { + } else if (A_avg_M <= 32) { ARK_DPAS_PT_LAUNCH(dpas_w8a16_policy_m_32); } else { ARK_DPAS_PT_LAUNCH(dpas_w8a16_policy); From f8877638ec441eb8a28027196af26ff9e534b74e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:13:59 +0000 Subject: [PATCH 015/112] fix: restore fp8 per-group MoE prefill large-M tile policy threshold Merge ec61621 also widened the dpas_w8a16_policy_m_32 bucket in the fp8 per-group prefill dispatch from A_avg_M <= 32 to <= 512, regressing large-M group-size prefill for the same reason as the per-tensor path. Restore the <= 32 threshold so large-M prefill uses the 128x128 default tile. --- .../wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index b67694e80b..b9898f81f0 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -1031,7 +1031,7 @@ void moe_prefill_fp8_dpas_per_group_dispatch( if (A_avg_M <= 8) { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 512) { + } else if (A_avg_M <= 32) { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy_m_32); } else { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy); From c8374a86a77781cdafcccb98838e58c671f5c71b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:35:36 +0000 Subject: [PATCH 016/112] revert: undo fp8 per-group MoE prefill threshold change Revert commit f887763, restoring the fp8 per-group prefill dispatch threshold to A_avg_M <= 512. The per-expert (per-tensor) fix from 93cde8c is retained. --- .../wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index b9898f81f0..b67694e80b 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -1031,7 +1031,7 @@ void moe_prefill_fp8_dpas_per_group_dispatch( if (A_avg_M <= 8) { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy_m_16); - } else if (A_avg_M <= 32) { + } else if (A_avg_M <= 512) { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy_m_32); } else { ARK_DPAS_PG_LAUNCH(dpas_w8a16_policy); From 3b3d7148229fdb10b199a369bce67e30bd4137d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:08:38 +0000 Subject: [PATCH 017/112] fix: switch ark.moe auto dispatch to total-token threshold Migrate the auto-dispatch logic from branch copilot/update-phase-auto-dispatch-logic (commit 9605fe4): phase="auto" now dispatches to decode when activations.shape[0] <= threshold (total tokens) instead of inspecting num_tokens_per_expert.max(), avoiding a host-device sync. Adds ARK_MOE_AUTO_DECODE_MAX_TOKENS env override (default 256) and updates test_moe_unified.py accordingly. --- .../ark/auto_round_kernel/__init__.py | 69 +++++---- .../ark/test/test_moe_unified.py | 138 +++++++++++++++--- 2 files changed, 158 insertions(+), 49 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index b66e180d82..4b5a5726ba 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -2331,8 +2331,9 @@ def _native_fp8_prefill_enabled() -> bool: # # `moe_gemm_decode` and `moe_gemm_prefill` accept identical argument shapes # and dtypes -- the only difference is which underlying SYCL kernel is -# launched (a GEMV variant tuned for 1-2 tokens/expert vs. a Grouped GEMM -# variant tuned for many tokens/expert). Model code that runs through both +# launched (a GEMV variant tuned for smaller total-token workloads vs. a +# Grouped GEMM variant tuned for larger total-token workloads). Model code +# that runs through both # regimes (prefill of a prompt, then autoregressive decode) traditionally # has to keep two call sites and branch on phase. `moe(...)` collapses that # into a single API and auto-selects the right kernel from the token @@ -2340,21 +2341,40 @@ def _native_fp8_prefill_enabled() -> bool: # # Callers that already know the phase (e.g., a model's generation loop knows # whether it's in prefill or decode) should pass it via the `phase` argument -# to avoid the small host-device sync that `phase="auto"` needs to inspect -# `num_tokens_per_expert.max()`. +# to bypass the auto-dispatch heuristic entirely. # --------------------------------------------------------------------------- -# Default tokens-per-expert threshold used by `phase="auto"`. The decode -# GEMV kernel is faster when every expert sees only a handful of tokens -# (TopK >= 1 with batch size 1-4); above that the GEMM-tuned prefill kernel -# wins. The crossover is hardware-dependent but `4` is a conservative default -# that matches the regime `moe_gemm_decode`'s docstring describes -# ("typically only 1-2 tokens", up to top-k * small batch). -_MOE_AUTO_DECODE_MAX_TOKENS_PER_EXPERT = 4 +# Default total-token threshold used by `phase="auto"`: dispatch to decode +# when `activations.shape[0] <= threshold`, otherwise prefill. This threshold +# is hardware-dependent and can be overridden via +# `ARK_MOE_AUTO_DECODE_MAX_TOKENS`. +_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS = 256 _MOE_VALID_PHASES = ("auto", "decode", "prefill") +def _moe_auto_decode_max_total_tokens() -> int: + """Return auto decode threshold from env or the module default. + + ``ARK_MOE_AUTO_DECODE_MAX_TOKENS`` is accepted when it is a positive + integer. Unset/empty/invalid values fall back to + ``_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS``. + """ + env = os.environ.get("ARK_MOE_AUTO_DECODE_MAX_TOKENS") + if env is None: + return _MOE_AUTO_DECODE_MAX_TOTAL_TOKENS + env = env.strip() + if not env: + return _MOE_AUTO_DECODE_MAX_TOTAL_TOKENS + try: + value = int(env) + except ValueError: + return _MOE_AUTO_DECODE_MAX_TOTAL_TOKENS + if value <= 0: + return _MOE_AUTO_DECODE_MAX_TOTAL_TOKENS + return value + + def moe( activations: torch.Tensor, weights: torch.Tensor, @@ -2366,7 +2386,7 @@ def moe( group_size: int = 128, asym: bool = False, phase: str = "auto", - decode_threshold: int = _MOE_AUTO_DECODE_MAX_TOKENS_PER_EXPERT, + decode_threshold: Optional[int] = None, ) -> torch.Tensor: """Unified MoE GEMM entry point that dispatches to decode or prefill. @@ -2387,18 +2407,18 @@ def moe( underlying kernel; see :func:`moe_gemm_decode`. phase: dispatch mode. - * ``"auto"`` (default): inspect ``num_tokens_per_expert.max()`` - and pick decode if every expert sees ``<= decode_threshold`` - tokens, otherwise prefill. This incurs one small host-device - sync per call. + * ``"auto"`` (default): dispatch to decode when + ``activations.shape[0] <= decode_threshold`` (total tokens), + otherwise prefill. * ``"decode"``: always dispatch to :func:`moe_gemm_decode`. Use when the model's generation loop already knows it is in the - decode phase; avoids the sync. + decode phase. * ``"prefill"``: always dispatch to :func:`moe_gemm_prefill`. Use when the model knows it is in the prefill phase. - decode_threshold: ``"auto"`` mode dispatches to decode when - ``num_tokens_per_expert.max() <= decode_threshold``. Defaults to - 4 (the regime the decode GEMV kernel is tuned for). + decode_threshold: Total-token threshold for ``"auto"`` mode. If not + provided, uses ``ARK_MOE_AUTO_DECODE_MAX_TOKENS`` when set to a + valid positive integer, otherwise defaults to 256. Explicit + argument values take precedence over the environment variable. Returns: ``[total_tokens, N]`` in the activations dtype. Bit-identical to the @@ -2408,14 +2428,11 @@ def moe( raise ValueError(f"phase must be one of {_MOE_VALID_PHASES}, got {phase!r}") if phase == "auto": - # `.max().item()` triggers a host-device sync; callers in tight - # decode loops should pass `phase="decode"` explicitly to skip this. - # We tolerate a non-int32 / non-contiguous tensor here because the - # downstream kernel wrappers will normalise it anyway. + threshold = _moe_auto_decode_max_total_tokens() if decode_threshold is None else int(decode_threshold) if num_tokens_per_expert.numel() == 0: raise ValueError("num_tokens_per_expert must be non-empty") - max_tpe = int(num_tokens_per_expert.max().item()) - phase = "decode" if max_tpe <= int(decode_threshold) else "prefill" + total_tokens = int(activations.shape[0]) + phase = "decode" if total_tokens <= threshold else "prefill" if phase == "decode": return moe_gemm_decode( diff --git a/auto_round_extension/ark/test/test_moe_unified.py b/auto_round_extension/ark/test/test_moe_unified.py index 4e769456fc..98bea8057b 100644 --- a/auto_round_extension/ark/test/test_moe_unified.py +++ b/auto_round_extension/ark/test/test_moe_unified.py @@ -25,8 +25,8 @@ This file checks: - * Dispatch correctness: ``phase="auto"`` picks decode when every expert - sees few tokens and prefill otherwise. + * Dispatch correctness: ``phase="auto"`` picks decode when total tokens are + below threshold and prefill otherwise. * Bit-parity: ``moe(phase="auto")`` matches the kernel it dispatched to. * Explicit-phase parity: ``moe(phase="decode")`` matches ``moe_gemm_decode``, ``moe(phase="prefill")`` matches @@ -88,11 +88,12 @@ def _unified_skip_reason() -> str: # --------------------------------------------------------------------------- -# Small shapes (one decode-shaped, one prefill-shaped) -- keep wall-clock low. +# Small shapes (one decode-sized by total tokens, one prefill-sized by total +# tokens) -- keep wall-clock low. # --------------------------------------------------------------------------- -_DECODE_SHAPE = dict(num_experts=4, tokens_per_expert=[1, 2, 0, 2], N=128, K=256) -_PREFILL_SHAPE = dict(num_experts=4, tokens_per_expert=[16, 8, 0, 20], N=128, K=256) +_AUTO_DECODE_SHAPE = dict(num_experts=4, tokens_per_expert=[64, 64, 64, 64], N=128, K=256) # total_tokens=256 +_AUTO_PREFILL_SHAPE = dict(num_experts=4, tokens_per_expert=[80, 80, 80, 80], N=128, K=256) # total_tokens=320 def _make_int4_sym(E, N, K, group_size, dtype, total_tokens): @@ -163,8 +164,8 @@ def _make_fp8(E, N, K, group_size, dtype, total_tokens, fp8_dtype): class TestMoeUnifiedDispatch: """Tests for the auto-dispatch logic itself.""" - def test_auto_picks_decode_for_small_tokens_per_expert(self): - shape = _DECODE_SHAPE + def test_auto_picks_decode_for_small_total_tokens(self): + shape = _AUTO_DECODE_SHAPE total_tokens = sum(shape["tokens_per_expert"]) E, N, K = shape["num_experts"], shape["N"], shape["K"] group_size = 128 @@ -192,12 +193,12 @@ def test_auto_picks_decode_for_small_tokens_per_expert(self): group_size=group_size, asym=False, ) - # max tokens/expert = 2 (<= default threshold 4) -> dispatched to decode + # total tokens = 256 (<= default threshold 256) -> dispatched to decode # -> output must be bit-identical to moe_gemm_decode. torch.testing.assert_close(out_auto, out_decode, rtol=0, atol=0) - def test_auto_picks_prefill_for_large_tokens_per_expert(self): - shape = _PREFILL_SHAPE + def test_auto_picks_prefill_for_large_total_tokens(self): + shape = _AUTO_PREFILL_SHAPE total_tokens = sum(shape["tokens_per_expert"]) E, N, K = shape["num_experts"], shape["N"], shape["K"] group_size = 128 @@ -228,9 +229,66 @@ def test_auto_picks_prefill_for_large_tokens_per_expert(self): torch.testing.assert_close(out_auto, out_prefill, rtol=0, atol=0) def test_decode_threshold_override(self): - # Same prefill-shaped input but bump the threshold above the max - # tokens/expert -> auto must now pick decode. - shape = _PREFILL_SHAPE + decode_shape = _AUTO_DECODE_SHAPE + decode_total_tokens = sum(decode_shape["tokens_per_expert"]) + E, N, K = decode_shape["num_experts"], decode_shape["N"], decode_shape["K"] + group_size = 128 + dtype = torch.float16 + + activations, packed, scales, _ = _make_int4_sym(E, N, K, group_size, dtype, decode_total_tokens) + ntpe = torch.tensor(decode_shape["tokens_per_expert"], dtype=torch.int32, device="xpu") + + out_auto = ark.moe( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + phase="auto", + decode_threshold=128, + ) + out_prefill = ark.moe_gemm_prefill( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + torch.testing.assert_close(out_auto, out_prefill, rtol=0, atol=0) + + prefill_shape = _AUTO_PREFILL_SHAPE + prefill_total_tokens = sum(prefill_shape["tokens_per_expert"]) + activations, packed, scales, _ = _make_int4_sym(E, N, K, group_size, dtype, prefill_total_tokens) + ntpe = torch.tensor(prefill_shape["tokens_per_expert"], dtype=torch.int32, device="xpu") + + out_auto = ark.moe( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + phase="auto", + decode_threshold=prefill_total_tokens, + ) + out_decode = ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + torch.testing.assert_close(out_auto, out_decode, rtol=0, atol=0) + + def test_decode_threshold_env_override(self, monkeypatch): + shape = _AUTO_PREFILL_SHAPE total_tokens = sum(shape["tokens_per_expert"]) E, N, K = shape["num_experts"], shape["N"], shape["K"] group_size = 128 @@ -239,7 +297,7 @@ def test_decode_threshold_override(self): activations, packed, scales, _ = _make_int4_sym(E, N, K, group_size, dtype, total_tokens) ntpe = torch.tensor(shape["tokens_per_expert"], dtype=torch.int32, device="xpu") - max_tpe = max(shape["tokens_per_expert"]) + monkeypatch.setenv("ARK_MOE_AUTO_DECODE_MAX_TOKENS", "512") out_auto = ark.moe( activations, packed, @@ -249,7 +307,6 @@ def test_decode_threshold_override(self): group_size=group_size, asym=False, phase="auto", - decode_threshold=max_tpe + 1, ) out_decode = ark.moe_gemm_decode( activations, @@ -262,8 +319,43 @@ def test_decode_threshold_override(self): ) torch.testing.assert_close(out_auto, out_decode, rtol=0, atol=0) + out_auto = ark.moe( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + phase="auto", + decode_threshold=128, + ) + out_prefill = ark.moe_gemm_prefill( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + torch.testing.assert_close(out_auto, out_prefill, rtol=0, atol=0) + + monkeypatch.setenv("ARK_MOE_AUTO_DECODE_MAX_TOKENS", "invalid") + out_auto = ark.moe( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + phase="auto", + ) + torch.testing.assert_close(out_auto, out_prefill, rtol=0, atol=0) + def test_invalid_phase_raises(self): - shape = _DECODE_SHAPE + shape = _AUTO_DECODE_SHAPE total_tokens = sum(shape["tokens_per_expert"]) E, N, K = shape["num_experts"], shape["N"], shape["K"] group_size = 128 @@ -298,8 +390,8 @@ class TestMoeUnifiedBitParity: @pytest.mark.parametrize( "shape_name,shape", [ - ("decode-shape", _DECODE_SHAPE), - ("prefill-shape", _PREFILL_SHAPE), + ("decode-shape", _AUTO_DECODE_SHAPE), + ("prefill-shape", _AUTO_PREFILL_SHAPE), ], ) def test_fp_unquantized(self, dtype, shape_name, shape): @@ -324,8 +416,8 @@ def test_fp_unquantized(self, dtype, shape_name, shape): @pytest.mark.parametrize( "shape_name,shape", [ - ("decode-shape", _DECODE_SHAPE), - ("prefill-shape", _PREFILL_SHAPE), + ("decode-shape", _AUTO_DECODE_SHAPE), + ("prefill-shape", _AUTO_PREFILL_SHAPE), ], ) def test_int4(self, dtype, asym, shape_name, shape): @@ -353,7 +445,7 @@ def test_int4(self, dtype, asym, shape_name, shape): def test_int8(self, dtype, asym): # Single shape -- the quant path is the same on both shapes, so # iterating both would just slow the test suite down. - shape = _PREFILL_SHAPE + shape = _AUTO_PREFILL_SHAPE E, N, K = shape["num_experts"], shape["N"], shape["K"] total_tokens = sum(shape["tokens_per_expert"]) group_size = 128 @@ -375,7 +467,7 @@ def test_int8(self, dtype, asym): @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("asym", [False, True]) def test_int2(self, dtype, asym): - shape = _PREFILL_SHAPE + shape = _AUTO_PREFILL_SHAPE E, N, K = shape["num_experts"], shape["N"], shape["K"] total_tokens = sum(shape["tokens_per_expert"]) group_size = 128 @@ -397,7 +489,7 @@ def test_int2(self, dtype, asym): @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) def test_fp8(self, dtype, fp8_dtype): - shape = _PREFILL_SHAPE + shape = _AUTO_PREFILL_SHAPE E, N, K = shape["num_experts"], shape["N"], shape["K"] total_tokens = sum(shape["tokens_per_expert"]) group_size = 128 From 6c3d26f28c07260b26fde9ad8edb403dec20a824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:38:06 +0000 Subject: [PATCH 018/112] feat: route int4-sym MoE decode to shared S4 DPAS grouped GEMM Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 60 +++++++++++++++++-- auto_round_extension/ark/test/test_moe.py | 49 +++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 0f03604ccf..9ec3e0a64a 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -35,10 +35,17 @@ #include #include +#include #include +#include #include "bestla/bestla.h" #include "sycl_tla_moe_dequant.hpp" +// S4-sym per-group DPAS grouped-GEMM (shared with the prefill path). The +// header self-guards on `ARK_XPU && ARK_SYCL_TLA`, so including it here is a +// no-op when the DPAS backend is disabled. Decode routes small-M int4-sym +// GEMV through this kernel; see `moe_gemm_decode` below. +#include "sycl_tla_moe_prefill_s4_dpas.hpp" #ifdef ARK_XPU #include @@ -740,7 +747,30 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh } // namespace moe_decode_detail // ---------------------------------------------------------------------------- -// Public API +// Env-flag helper -- `ARK_MOE_DECODE_DPAS_S4` (default ON). When ON, int4-sym +// (S4_CLIP, !asym) decode is routed to the shared per-group S4 DPAS grouped +// GEMM (`moe_dpas_s4::moe_prefill_s4_dpas_per_group_dispatch`) instead of the +// scalar FMA GEMV (`launch_int4`). The DPAS path already handles the tiny +// total-token counts typical of decode (its `A_avg_M <= 4` bucket selects the +// 8-row `dpas_w4a16_policy_m_8` tile) and reads the same `[E, N, K/2]` packed +// weights + `[E, N, K/group]` scales, so no repack is needed. +// +// Setting the var to "0" / "false" / "off" / "no" (case-insensitive) forces +// the legacy scalar GEMV, for A/B comparison and regression escape. Asym +// weights and shapes that fail the DPAS shape gate always fall back to the +// scalar path regardless of this flag. Re-read on every call so tests / +// benchmarks can toggle the path in-process. +// ---------------------------------------------------------------------------- +inline bool moe_decode_dpas_s4_enabled() { + const char* env = std::getenv("ARK_MOE_DECODE_DPAS_S4"); + if (env == nullptr) return true; // default ON + std::string s(env); + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + if (s == "0" || s == "false" || s == "off" || s == "no") return false; + return true; +} + +// ---------------------------------------------------------------------------- // // weight_dtype: // BTLA_DTYPE::F16 / BF16 : weights stored as [E, N, K] in matching @@ -783,6 +813,30 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo } if (weight_dtype == BTLA_DTYPE::S4_CLIP) { + if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { + throw std::invalid_argument("moe_gemm_decode(int4): act_dtype must be FP16 or BF16"); + } + // Fast path: sym int4 through the shared per-group S4 DPAS grouped GEMM. + // Falls back to the scalar GEMV for asym weights (DPAS S4 is sym-only), + // when the env flag is off, or when the shape gate rejects the tile + // geometry (e.g. N%64!=0, K%32!=0, unsupported group_size). + if (!asym && moe_decode_dpas_s4_enabled() && + moe_dpas_s4::moe_prefill_dpas_s4_pergroup_shape_ok(N, K, group_size)) { + if (act_dtype == BTLA_DTYPE::F16) { + moe_dpas_s4::moe_prefill_s4_dpas_per_group_dispatch( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(outputs), num_tokens_per_expert, + num_experts, N, K, group_size, total_tokens); + } else { + using BF = sycl::ext::oneapi::bfloat16; + moe_dpas_s4::moe_prefill_s4_dpas_per_group_dispatch( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(outputs), num_tokens_per_expert, num_experts, N, K, + group_size, total_tokens); + } + return; + } + // Scalar FMA GEMV fallback (asym, flag off, or shape gate miss). if (act_dtype == BTLA_DTYPE::F16) { if (asym) { moe_decode_detail::launch_int4( @@ -795,7 +849,7 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo static_cast(scales), static_cast(zeros), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size); } - } else if (act_dtype == BTLA_DTYPE::BF16) { + } else { using BF = sycl::ext::oneapi::bfloat16; if (asym) { moe_decode_detail::launch_int4( @@ -808,8 +862,6 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo static_cast(scales), static_cast(zeros), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size); } - } else { - throw std::invalid_argument("moe_gemm_decode(int4): act_dtype must be FP16 or BF16"); } return; } diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index 8bad20cf98..ebeef5da52 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -480,6 +480,55 @@ def test_decode_int4_sym(self, dtype, group_size): assert out.shape == (total_tokens, N) torch.testing.assert_close(out, ref, rtol=5e-2, atol=5e-2) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("group_size", [32, 128]) + @pytest.mark.parametrize("tokens_per_expert", [[1, 0, 1, 1], [1, 1, 1, 1], [2, 0, 0, 1]]) + def test_decode_int4_sym_dpas_matches_scalar(self, monkeypatch, dtype, group_size, tokens_per_expert): + """int4-sym decode: the S4 DPAS path (ARK_MOE_DECODE_DPAS_S4=1, default) + must match both the scalar GEMV fallback (ARK_MOE_DECODE_DPAS_S4=0) and + the dequant->bmm reference within quantization tolerance. + + Shapes satisfy the DPAS per-group shape gate (N%64==0, K%32==0, + group_size in {32,64,128,256}) so the fast path is actually taken. + """ + num_experts = 4 + total_tokens = sum(tokens_per_expert) + N, K = 320, 256 # N%64==0, K%32==0 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_sym(w_float, scales, group_size) + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + dequant = _dequant_int4_sym(packed, scales, group_size).to(dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + out_dpas = _run() + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + out_scalar = _run() + + assert out_dpas.shape == (total_tokens, N) + assert out_scalar.shape == (total_tokens, N) + # Both kernels approximate the same dequant reference. + torch.testing.assert_close(out_dpas, ref, rtol=5e-2, atol=5e-2) + torch.testing.assert_close(out_scalar, ref, rtol=5e-2, atol=5e-2) + # And they must agree with each other within the same tolerance. + torch.testing.assert_close(out_dpas, out_scalar, rtol=5e-2, atol=5e-2) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) def test_decode_int4_asym(self, dtype): num_experts = 4 From 0c946597e3012e855e011e71b3a9eefac49ef206 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:39:11 +0000 Subject: [PATCH 019/112] test: add int4-sym decode DPAS vs scalar perf comparison; docs Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 7 +++- .../ark/test/test_moe_decode_perf.py | 41 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 9ec3e0a64a..003c924dff 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -780,7 +780,12 @@ inline bool moe_decode_dpas_s4_enabled() { // unsigned with zero-points when asym==true) // BTLA_DTYPE::S4_CLIP : packed int4 weights [E, N, K/2] (uint8), // scales [E, N, K/group_size] in act dtype, -// zeros optional (asym==true requires it) +// zeros optional (asym==true requires it). +// Sym weights are routed to the shared +// per-group S4 DPAS grouped GEMM by default +// (`ARK_MOE_DECODE_DPAS_S4`, default ON); +// asym, a disabled flag, or a shape-gate +// miss falls back to the scalar GEMV. // BTLA_DTYPE::S2_CLIP : packed int2 weights [E, N, K/4] (uint8), // 4 values per byte, sym/asym like int4 // BTLA_DTYPE::F8_E4M3 / F8_E5M2 : FP8 weights [E, N, K] (uint8 buffer), diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index cf9b9a74cb..94017e5c8b 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -370,8 +370,45 @@ def test_perf_int4(self, dtype, asym): _print_row(label, N, K, total_tokens, base_ms, ark_ms) @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) - @pytest.mark.parametrize("asym", [False, True]) - def test_perf_int8(self, dtype, asym): + def test_perf_int4_sym_dpas_vs_scalar(self, monkeypatch, dtype): + """int4-sym decode: compare the S4 DPAS path (ARK_MOE_DECODE_DPAS_S4=1) + against the scalar GEMV fallback (ARK_MOE_DECODE_DPAS_S4=0). + + ``speedup`` here is ``scalar / dpas`` (the DPAS path is the "ark" + column), isolating the DPAS routing win from the dequant reference. + Only shapes that clear the DPAS shape gate are timed on both paths. + """ + group_size = 128 + _print_header( + f"INT4 sym DPAS vs scalar (group_size={group_size}, " + f"act={str(dtype).split('.')[-1]}) -- scalar GEMV (baseline) vs S4 DPAS (ark)" + ) + for label, E, tpe, N, K in DECODE_SHAPES: + if K % group_size != 0 or N % 64 != 0 or K % 32 != 0: + continue + total_tokens = sum(tpe) + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_sym(w_float, scales, group_size) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + scalar_ms = _xpu_time_ms(_run) + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + dpas_ms = _xpu_time_ms(_run) + _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) group_size = 128 kind = "asym" if asym else "sym" _print_header( From ffd2926c6136a38bdb4961049784d1d516e605ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:52:24 +0000 Subject: [PATCH 020/112] fix: split orphaned int8 block out of int4-sym dpas perf test Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- auto_round_extension/ark/test/test_moe_decode_perf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index 94017e5c8b..ccbc08edc3 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -409,6 +409,10 @@ def _run(): monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") dpas_ms = _xpu_time_ms(_run) _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) + + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("asym", [False, True]) + def test_perf_int8(self, dtype, asym): group_size = 128 kind = "asym" if asym else "sym" _print_header( From 87b617ea4c90b51910d8afe810b737ea3fe1c467 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:15:24 +0000 Subject: [PATCH 021/112] fix: lower default MoE auto-decode threshold 256 -> 32 The shared S4 DPAS grouped-GEMM (prefill path) already beats the scalar GEMV decode kernel by ~2x at 256 tokens (bs32) and only loses at the single-stream bs1 (8-token) extreme. Routing 256-token batches to decode was leaving ~2x on the table, so lower the auto-dispatch default and update coupled unified-dispatch tests and perf-test notes. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 13 +++++++++++-- .../ark/test/test_moe_decode_perf.py | 6 ++++++ auto_round_extension/ark/test/test_moe_unified.py | 7 ++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index 4b5a5726ba..69147abc2f 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -2348,7 +2348,16 @@ def _native_fp8_prefill_enabled() -> bool: # when `activations.shape[0] <= threshold`, otherwise prefill. This threshold # is hardware-dependent and can be overridden via # `ARK_MOE_AUTO_DECODE_MAX_TOKENS`. -_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS = 256 +# +# Empirically (see `test_perf_int4_sym_dpas_vs_scalar`) the shared S4 DPAS +# grouped-GEMM that the prefill path uses already beats the scalar-GEMV decode +# kernel by ~2x once total tokens reach 256 (bs32), while it loses (~0.5x) only +# at the single-stream bs1 extreme (8 tokens). The crossover therefore sits far +# below 256, so the default is kept small: the scalar decode kernel is only +# preferred for the tiny single-/few-stream case where every expert sees well +# under one DPAS tile row. Mirrors vLLM-xpu-kernels' `w4a16` dispatch, which +# buckets on average tokens-per-expert rather than a large total-token cutoff. +_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS = 32 _MOE_VALID_PHASES = ("auto", "decode", "prefill") @@ -2417,7 +2426,7 @@ def moe( Use when the model knows it is in the prefill phase. decode_threshold: Total-token threshold for ``"auto"`` mode. If not provided, uses ``ARK_MOE_AUTO_DECODE_MAX_TOKENS`` when set to a - valid positive integer, otherwise defaults to 256. Explicit + valid positive integer, otherwise defaults to 32. Explicit argument values take precedence over the environment variable. Returns: diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index ccbc08edc3..59158a3a8e 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -377,6 +377,12 @@ def test_perf_int4_sym_dpas_vs_scalar(self, monkeypatch, dtype): ``speedup`` here is ``scalar / dpas`` (the DPAS path is the "ark" column), isolating the DPAS routing win from the dequant reference. Only shapes that clear the DPAS shape gate are timed on both paths. + + Observed crossover: at bs1 (8 tokens, ~1 token/expert) the DPAS tile is + nearly empty and the scalar GEMV wins (~0.5x), but by bs32 (256 tokens) + the shared S4 DPAS grouped-GEMM already wins ~2x. The auto-dispatch + default threshold (`_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS`) is therefore kept + small so 256-token batches are routed to prefill/DPAS, not decode. """ group_size = 128 _print_header( diff --git a/auto_round_extension/ark/test/test_moe_unified.py b/auto_round_extension/ark/test/test_moe_unified.py index 98bea8057b..2d5eb232fe 100644 --- a/auto_round_extension/ark/test/test_moe_unified.py +++ b/auto_round_extension/ark/test/test_moe_unified.py @@ -92,7 +92,7 @@ def _unified_skip_reason() -> str: # tokens) -- keep wall-clock low. # --------------------------------------------------------------------------- -_AUTO_DECODE_SHAPE = dict(num_experts=4, tokens_per_expert=[64, 64, 64, 64], N=128, K=256) # total_tokens=256 +_AUTO_DECODE_SHAPE = dict(num_experts=4, tokens_per_expert=[4, 4, 4, 4], N=128, K=256) # total_tokens=16 _AUTO_PREFILL_SHAPE = dict(num_experts=4, tokens_per_expert=[80, 80, 80, 80], N=128, K=256) # total_tokens=320 @@ -193,7 +193,7 @@ def test_auto_picks_decode_for_small_total_tokens(self): group_size=group_size, asym=False, ) - # total tokens = 256 (<= default threshold 256) -> dispatched to decode + # total tokens = 16 (<= default threshold 32) -> dispatched to decode # -> output must be bit-identical to moe_gemm_decode. torch.testing.assert_close(out_auto, out_decode, rtol=0, atol=0) @@ -238,6 +238,7 @@ def test_decode_threshold_override(self): activations, packed, scales, _ = _make_int4_sym(E, N, K, group_size, dtype, decode_total_tokens) ntpe = torch.tensor(decode_shape["tokens_per_expert"], dtype=torch.int32, device="xpu") + # A threshold strictly below the shape's total tokens forces prefill. out_auto = ark.moe( activations, packed, @@ -247,7 +248,7 @@ def test_decode_threshold_override(self): group_size=group_size, asym=False, phase="auto", - decode_threshold=128, + decode_threshold=decode_total_tokens - 1, ) out_prefill = ark.moe_gemm_prefill( activations, From 23ba0dbfca17c23d59e68238ad2df2ebd9cf9c96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:43:44 +0000 Subject: [PATCH 022/112] test: add int4-sym DPAS vs scalar token-count sweep for MoE decode threshold tuning Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/test_moe_decode_perf.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index 59158a3a8e..584287528d 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -214,6 +214,32 @@ def _default_moe_decode(activations, dequant_weights, num_tokens_per_expert): # Backwards-compatible alias (older code/tests referenced ``_MINIMAX_TPE``). _MINIMAX_TPE = _MINIMAX_TPE_BS1 + +def _spread_tokens(total_tokens: int, num_experts: int = 192) -> list: + """Distribute ``total_tokens`` across ``num_experts`` round-robin. + + Returns a ``[num_experts]`` histogram summing to ``total_tokens`` where the + load is striped across the expert range (expert ``i`` gets a token before + ``i+1`` gets its second), mirroring the spread a real top-k router produces + rather than clustering all tokens onto the first few experts. Used by the + threshold-sweep test to synthesise decode workloads of an exact size. + """ + tpe = [0] * num_experts + for i in range(total_tokens): + tpe[i % num_experts] += 1 + return tpe + + +# Total-token counts swept by ``test_perf_int4_sym_dpas_vs_scalar_threshold`` +# to locate the DPAS-vs-scalar crossover for the auto-dispatch threshold. +_INT4_THRESHOLD_TOKEN_COUNTS = [16, 32, 64, 128] + +# MiniMax-M2 up/down-proj (N, K) pairs reused by the threshold sweep. +_INT4_THRESHOLD_NK = [ + (1536, 3072), # gate/up-proj + (3072, 1536), # down-proj +] + DECODE_SHAPES = [ # (label, num_experts, tokens_per_expert, N, K) # batch=1 decode (single-stream). @@ -416,6 +442,55 @@ def _run(): dpas_ms = _xpu_time_ms(_run) _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + def test_perf_int4_sym_dpas_vs_scalar_threshold(self, monkeypatch, dtype): + """int4-sym decode threshold sweep: DPAS vs scalar GEMV across a range + of total-token counts (16/32/64/128) at ``group_size=32``. + + Same comparison as ``test_perf_int4_sym_dpas_vs_scalar`` (``speedup`` is + ``scalar / dpas``, DPAS is the "ark" column) but instead of the fixed + MiniMax bs1/bs32 shapes it synthesises decode workloads of an exact + total-token size via :func:`_spread_tokens`. The ``speedup`` column + crosses 1.0x at the total-token count where the shared S4 DPAS + grouped-GEMM starts beating the scalar GEMV, which is the value to feed + into ``ARK_MOE_AUTO_DECODE_MAX_TOKENS`` / the ``moe(...)`` + ``decode_threshold`` auto-dispatch cutoff. + """ + group_size = 32 + E = 192 + _print_header( + f"INT4 sym DPAS vs scalar threshold sweep (group_size={group_size}, " + f"act={str(dtype).split('.')[-1]}) -- scalar GEMV (baseline) vs S4 DPAS (ark)" + ) + for N, K in _INT4_THRESHOLD_NK: + if K % group_size != 0 or N % 64 != 0 or K % 32 != 0: + continue + for total_tokens in _INT4_THRESHOLD_TOKEN_COUNTS: + tpe = _spread_tokens(total_tokens, E) + label = f"int4 {N}x{K} t{total_tokens}" + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_sym(w_float, scales, group_size) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + scalar_ms = _xpu_time_ms(_run) + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + dpas_ms = _xpu_time_ms(_run) + _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("asym", [False, True]) def test_perf_int8(self, dtype, asym): From a1480af3c661a706cec653488c45bb650177f275 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:33:38 +0000 Subject: [PATCH 023/112] perf: hoist per-group scale and split accumulators in int4 MoE decode GEMV Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 77 ++++++++++++------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 003c924dff..6a00ea4c75 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -218,10 +218,23 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight // stage for the remainder, which keeps the fast path active for group sizes // that are a multiple of 32 (32/64/128/256 -- the shipped quant configs) // without regressing group_size == 16 (which drops straight to the 16-wide -// stage). The math is identical to the scalar path. +// stage). +// +// The per-group scale (and asym zero-point) is NOT applied here: this +// accumulates the raw integer-weighted dot product ``Σ a·q`` into ``acc_q`` +// and, for the asym case, the plain activation sum ``Σ a`` into ``acc_a``. +// The caller folds the group's scale/zero in once (Σ a·((q−z)·s) == +// s·(Σ a·q − z·Σ a); sym collapses to s·Σ a·q). Hoisting the scale removes one +// float multiply per K element on the decode hot path, and because the scale +// fold is exact-once per group the result stays well within the kernel's +// existing quantization tolerance. +// +// Two independent partial accumulators (``acc_q0``/``acc_q1``) break the +// single fp32 dependency chain so the FMA pipeline is not latency-bound; the +// caller reduces the pair. ``acc_a`` (asym only) reuses the same split. template -static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float scale, float zero, - float& acc) { +static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc_q0, float& acc_q1, + float& acc_a) { static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); // sycl::vec only supports widths of 1, 2, 3, 4, 8 or 16, so a single @@ -239,18 +252,15 @@ static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_pt for (int b = 0; b < SUB / 2; ++b) { int q0, q1; decode_int4_pair(pv[b], q0, q1); - float w0, w1; - if constexpr (Asym) { - w0 = (static_cast(q0) - zero) * scale; - w1 = (static_cast(q1) - zero) * scale; - } else { - w0 = static_cast(q0) * scale; - w1 = static_cast(q1) * scale; - } const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); - acc += static_cast(a0) * w0; - acc += static_cast(a1) * w1; + const float fa0 = static_cast(a0); + const float fa1 = static_cast(a1); + acc_q0 += fa0 * static_cast(q0); + acc_q1 += fa1 * static_cast(q1); + if constexpr (Asym) { + acc_a += fa0 + fa1; + } } } } @@ -311,35 +321,50 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // across twice as many multiply-adds for the shipped group sizes // (32/64/128/256), while the 16-wide stage keeps group_size == 16 // on the fast path. + // + // The scale (and asym zero) is constant across the group, so the + // wide stages accumulate the raw integer-weighted dot product + // ``Σ a·q`` (split across two partial accumulators to break the + // fp32 dependency chain) plus ``Σ a`` for asym, and the fold below + // applies the scale/zero exactly once per group: + // sym : acc += scale * (acc_q0 + acc_q1) + // asym: acc += scale * ((acc_q0 + acc_q1) - zero * acc_a) + float acc_q0 = 0.0f; + float acc_q1 = 0.0f; + float acc_a = 0.0f; int kk = 0; constexpr int CHUNK32 = 32; const int end32 = (group_size / CHUNK32) * CHUNK32; for (; kk < end32; kk += CHUNK32) { - int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, - zero, acc); + int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, acc_q0, + acc_q1, acc_a); } constexpr int CHUNK16 = 16; const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; for (; kk < end16; kk += CHUNK16) { - int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, scale, - zero, acc); + int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, acc_q0, + acc_q1, acc_a); } - // Scalar tail for group_size not divisible by 16. + // Scalar tail for group_size not divisible by 16. Uses the same + // raw-accumulation convention as the wide stages so the single + // scale/zero fold below stays valid. for (; kk < group_size; kk += 2) { const uint8_t packed = w_row[(k_base + kk) / 2]; int q0, q1; decode_int4_pair(packed, q0, q1); - float w0, w1; + const float fa0 = static_cast(act_row[k_base + kk]); + const float fa1 = static_cast(act_row[k_base + kk + 1]); + acc_q0 += fa0 * static_cast(q0); + acc_q1 += fa1 * static_cast(q1); if constexpr (Asym) { - w0 = (static_cast(q0) - zero) * scale; - w1 = (static_cast(q1) - zero) * scale; - } else { - w0 = static_cast(q0) * scale; - w1 = static_cast(q1) * scale; + acc_a += fa0 + fa1; } - acc += static_cast(act_row[k_base + kk]) * w0; - acc += static_cast(act_row[k_base + kk + 1]) * w1; } + float group_dot = acc_q0 + acc_q1; + if constexpr (Asym) { + group_dot -= zero * acc_a; + } + acc += scale * group_dot; } outputs[static_cast(token) * N + n_global] = static_cast(acc); From c20c0b2c7c3f6e7e206d0c08a033c31dbb33f293 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:59:22 +0000 Subject: [PATCH 024/112] perf: coalesce int4 MoE decode weight loads via on-device N-tiled repack Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 237 ++++++++++++++++-- auto_round_extension/ark/test/test_moe.py | 62 +++++ 2 files changed, 281 insertions(+), 18 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 6a00ea4c75..de9df4cdf8 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -11,7 +11,11 @@ // int8 per byte (sym: signed -128..127; // asym: unsigned 0..255 with zero-point) // - weights (int4 packed): [num_experts, N, K/2] row-major, two -// 4-bit values per byte (low nibble at lower K) +// 4-bit values per byte (low nibble at lower K). +// The scalar-GEMV fallback repacks this on-device +// into an N-tiled [E, N/16, K/2, 16] layout so that +// sub-group weight loads are coalesced; the external +// [E, N, K/2] contract is unchanged. // - weights (int2 packed): [num_experts, N, K/4] row-major, four // 2-bit values per byte (field j at K index // 4*i+j is bits [2j+1:2j]) @@ -87,6 +91,11 @@ class MoEDecodeKernelFP; template class MoEDecodeKernelInt4; +template +class MoEDecodeKernelInt4Coalesced; + +class MoEDecodeRepackInt4; + template class MoEDecodeKernelInt8; @@ -371,6 +380,146 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig }); } +// ---------------------------------------------------------------------------- +// INT4 (S4_CLIP) coalesced-load GEMV. +// +// The scalar `launch_int4` above is memory-bandwidth-bound: for a single decode +// token it just streams the whole packed weight matrix once with ~1 MAC per +// byte, so the arithmetic tweaks (split accumulators, hoisted scale) cannot +// help. Its real cost is that weight loads are *not coalesced across the +// sub-group*: with the `[E, N, K/2]` (K-contiguous) layout, lane `l` and lane +// `l+1` of a sub-group read packed bytes `K/2` apart at a fixed `k`, so each +// step issues 16 scattered transactions instead of one contiguous cache line. +// +// This path fixes that by first repacking the weights on-device into an +// N-tiled layout `[E, N/16, K/2, 16]`: the trailing dim of 16 holds one packed +// byte for each of the 16 columns owned by a sub-group tile, so at a fixed +// packed-byte index the 16 lanes read 16 contiguous bytes -> a single coalesced +// load. The dequant math is byte-for-byte identical to `launch_int4` (same +// `decode_int4_pair`, same per-group scale/zero fold), only the weight memory +// access pattern changes. The repack buffer is a transient USM device +// allocation freed after the queue drains; the caller's `[E, N, K/2]` weight +// contract is unchanged. +// +// The trailing lane stride means each lane's own K-bytes are 16 apart, so the +// vectorized `int4_decode_chunk` (contiguous per-lane load) does not apply +// here; the inner loop reads one packed byte per lane per step, which the +// hardware coalesces across the sub-group into one wide transaction. +// ---------------------------------------------------------------------------- +template +void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, + const ScalarT* scales, const ScalarT* zeros, ScalarT* outputs, + const int* expert_id_per_token, int total_tokens, int N, int K, int group_size, + int num_experts) { + if (N % N_TILE != 0) { + throw std::invalid_argument("moe_gemm_decode(int4): N must be a multiple of 16"); + } + if (K % group_size != 0 || (group_size & 1) != 0) { + throw std::invalid_argument("moe_gemm_decode(int4): K must be a multiple of group_size and group_size must be even"); + } + if (Asym && zeros == nullptr) { + throw std::invalid_argument("moe_gemm_decode(int4): zeros pointer required when asym=true"); + } + if (total_tokens == 0) return; + + const int n_tiles = N / N_TILE; + const int num_groups_k = K / group_size; + const int k_packed = K / 2; // bytes of packed weight per (expert, n) + + const size_t repacked_bytes = + static_cast(num_experts) * static_cast(n_tiles) * + static_cast(k_packed) * static_cast(N_TILE); + uint8_t* repacked = sycl::malloc_device(repacked_bytes, *q); + if (repacked == nullptr) { + throw std::runtime_error("moe_gemm_decode(int4): failed to allocate repack buffer"); + } + + // Repack kernel: one work-item per (expert, column, packed byte). The write + // index places the 16 columns of a tile contiguously in the trailing dim. + { + sycl::range<3> rp_global{static_cast(num_experts), static_cast(N), + static_cast(k_packed)}; + q->parallel_for(rp_global, [=](sycl::id<3> id) { + const int e = static_cast(id[0]); + const int n = static_cast(id[1]); + const int kb = static_cast(id[2]); + const int t = n / N_TILE; + const int l = n % N_TILE; + const size_t src = (static_cast(e) * N + static_cast(n)) * k_packed + kb; + const size_t dst = + ((static_cast(e) * n_tiles + t) * k_packed + kb) * N_TILE + l; + repacked[dst] = weights[src]; + }); + } + + sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), + [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int n_tile = static_cast(it.get_group(1)); + const int lane = static_cast(it.get_local_id(1)); + const int n_global = n_tile * N_TILE + lane; + + const int expert = expert_id_per_token[token]; + const ScalarT* act_row = activations + static_cast(token) * K; + + // Base of this (expert, n_tile) weight tile in the repacked buffer. + // Layout [E, N/16, K/2, 16]; this lane reads byte kb at + // w_tile[kb*16 + lane], so adjacent lanes read adjacent bytes. + const uint8_t* w_tile = + repacked + ((static_cast(expert) * n_tiles + n_tile) * k_packed) * N_TILE; + const ScalarT* s_row = + scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; + const ScalarT* z_row = Asym + ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k + : nullptr; + + float acc = 0.0f; + for (int g = 0; g < num_groups_k; ++g) { + const float scale = static_cast(s_row[g]); + float zero = 0.0f; + if constexpr (Asym) { + zero = static_cast(z_row[g]); + } + const int k_base = g * group_size; + // Two partial accumulators break the fp32 dependency chain; the + // per-group scale/zero is folded once after the K-loop, exactly as + // in the scalar path. Each iteration processes two K-elements (one + // packed byte); the byte load is coalesced across the sub-group. + float acc_q0 = 0.0f; + float acc_q1 = 0.0f; + float acc_a = 0.0f; + const int kb_base = k_base / 2; + const int kb_count = group_size / 2; + for (int kb = 0; kb < kb_count; ++kb) { + const uint8_t packed = w_tile[(kb_base + kb) * N_TILE + lane]; + int q0, q1; + decode_int4_pair(packed, q0, q1); + const float fa0 = static_cast(act_row[k_base + 2 * kb]); + const float fa1 = static_cast(act_row[k_base + 2 * kb + 1]); + acc_q0 += fa0 * static_cast(q0); + acc_q1 += fa1 * static_cast(q1); + if constexpr (Asym) { + acc_a += fa0 + fa1; + } + } + float group_dot = acc_q0 + acc_q1; + if constexpr (Asym) { + group_dot -= zero * acc_a; + } + acc += scale * group_dot; + } + + outputs[static_cast(token) * N + n_global] = static_cast(acc); + }); + + q->wait(); + sycl::free(repacked, *q); +} + // ---------------------------------------------------------------------------- // INT8 (S8) GEMV with group-wise dequantization. // @@ -795,6 +944,24 @@ inline bool moe_decode_dpas_s4_enabled() { return true; } +// ---------------------------------------------------------------------------- +// Env-flag helper -- `ARK_MOE_DECODE_COALESCE_INT4` (default ON). When ON, the +// int4 scalar-GEMV fallback (asym, or sym with the DPAS path disabled / shape +// gate miss) uses `launch_int4_coalesced`, which repacks the weights on-device +// into an N-tiled layout so sub-group weight loads are coalesced. Setting the +// var to "0" / "false" / "off" / "no" (case-insensitive) forces the legacy +// per-lane-strided `launch_int4`, for A/B comparison and regression escape. +// Re-read on every call so tests / benchmarks can toggle it in-process. +// ---------------------------------------------------------------------------- +inline bool moe_decode_coalesce_int4_enabled() { + const char* env = std::getenv("ARK_MOE_DECODE_COALESCE_INT4"); + if (env == nullptr) return true; // default ON + std::string s(env); + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + if (s == "0" || s == "false" || s == "off" || s == "no") return false; + return true; +} + // ---------------------------------------------------------------------------- // // weight_dtype: @@ -866,31 +1033,65 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo } return; } - // Scalar FMA GEMV fallback (asym, flag off, or shape gate miss). + // Scalar FMA GEMV fallback (asym, flag off, or shape gate miss). By + // default this uses the coalesced-load variant, which repacks the weights + // on-device so sub-group loads are contiguous; `ARK_MOE_DECODE_COALESCE_INT4=0` + // forces the legacy per-lane-strided kernel. + const bool coalesce = moe_decode_coalesce_int4_enabled(); if (act_dtype == BTLA_DTYPE::F16) { if (asym) { - moe_decode_detail::launch_int4( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(zeros), - static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size); + if (coalesce) { + moe_decode_detail::launch_int4_coalesced( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), + static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size, + num_experts); + } else { + moe_decode_detail::launch_int4( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), + static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size); + } } else { - moe_decode_detail::launch_int4( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(zeros), - static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size); + if (coalesce) { + moe_decode_detail::launch_int4_coalesced( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), + static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size, + num_experts); + } else { + moe_decode_detail::launch_int4( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), + static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, group_size); + } } } else { using BF = sycl::ext::oneapi::bfloat16; if (asym) { - moe_decode_detail::launch_int4( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(zeros), static_cast(outputs), - expert_id_per_token_buf, total_tokens, N, K, group_size); + if (coalesce) { + moe_decode_detail::launch_int4_coalesced( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), static_cast(outputs), + expert_id_per_token_buf, total_tokens, N, K, group_size, num_experts); + } else { + moe_decode_detail::launch_int4( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), static_cast(outputs), + expert_id_per_token_buf, total_tokens, N, K, group_size); + } } else { - moe_decode_detail::launch_int4( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(zeros), static_cast(outputs), - expert_id_per_token_buf, total_tokens, N, K, group_size); + if (coalesce) { + moe_decode_detail::launch_int4_coalesced( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), static_cast(outputs), + expert_id_per_token_buf, total_tokens, N, K, group_size, num_experts); + } else { + moe_decode_detail::launch_int4( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(zeros), static_cast(outputs), + expert_id_per_token_buf, total_tokens, N, K, group_size); + } } } return; diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index ebeef5da52..3a53e0701e 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -560,6 +560,68 @@ def test_decode_int4_asym(self, dtype): assert out.shape == (total_tokens, N) torch.testing.assert_close(out, ref, rtol=5e-2, atol=5e-2) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("asym", [False, True]) + @pytest.mark.parametrize("group_size", [32, 128]) + def test_decode_int4_coalesced_matches_scalar(self, monkeypatch, dtype, asym, group_size): + """int4 scalar-GEMV fallback: the coalesced-load variant + (ARK_MOE_DECODE_COALESCE_INT4=1, default) must match both the legacy + per-lane-strided kernel (ARK_MOE_DECODE_COALESCE_INT4=0) and the + dequant->bmm reference within quantization tolerance. + + The S4 DPAS fast path is disabled so both runs exercise the scalar + fallback (this is the only path the coalesce flag affects). Shapes use + N%16==0 so the N-tiled repack is exact. + """ + num_experts = 4 + tokens_per_expert = [1, 0, 2, 1] + total_tokens = sum(tokens_per_expert) + N, K = 256, 256 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + if asym: + zeros = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_asym(w_float, scales, zeros, group_size) + dequant = _dequant_int4_asym(packed, scales, zeros, group_size).to(dtype) + else: + zeros = None + packed = _pack_int4_sym(w_float, scales, group_size) + dequant = _dequant_int4_sym(packed, scales, group_size).to(dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + zeros=zeros, + weight_bits=4, + group_size=group_size, + asym=asym, + ) + + # Force the scalar-GEMV fallback so the coalesce flag actually applies. + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "1") + out_coalesced = _run() + + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "0") + out_scalar = _run() + + assert out_coalesced.shape == (total_tokens, N) + assert out_scalar.shape == (total_tokens, N) + torch.testing.assert_close(out_coalesced, ref, rtol=5e-2, atol=5e-2) + torch.testing.assert_close(out_scalar, ref, rtol=5e-2, atol=5e-2) + # The two kernels are numerically identical (same dequant math, only + # the weight memory layout differs), so require a tight match. + torch.testing.assert_close(out_coalesced, out_scalar, rtol=1e-3, atol=1e-3) + def test_decode_validation_errors(self): """Sanity-check that Python-side validation catches misuse.""" num_experts = 2 From 8c732ed8c16e3523572ed39116985ee547f4d876 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:49:19 +0000 Subject: [PATCH 025/112] perf: block tokens-per-expert in coalesced int4 MoE decode GEMV Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 164 +++++++++++++----- auto_round_extension/ark/test/test_moe.py | 63 +++++++ 2 files changed, 182 insertions(+), 45 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index de9df4cdf8..0f052f6c90 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -82,6 +82,16 @@ namespace moe_decode_detail { constexpr int SG_SIZE = 16; constexpr int N_TILE = SG_SIZE; // one output element per sub-group lane +// Token-blocking factor for the coalesced int4 decode GEMV. A work-item that +// owns one (n_tile, lane) output column processes up to TOKEN_BLOCK consecutive +// tokens, loading each packed weight byte from the (expert, n_tile) tile once +// and applying it to every token in the block that routes to the same expert. +// When decode routing is bursty (runs of tokens hitting the same expert), this +// amortizes the dominant weight traffic across the block instead of re-reading +// the tile once per token, moving the problem from pure GEMV toward GEMM. A +// value of 1 reproduces the one-token-per-work-item behaviour exactly. +constexpr int TOKEN_BLOCK = 4; + // ---------------------------------------------------------------------------- // Kernel name tags (one per specialization, required for SYCL kernel naming) // ---------------------------------------------------------------------------- @@ -405,6 +415,17 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // vectorized `int4_decode_chunk` (contiguous per-lane load) does not apply // here; the inner loop reads one packed byte per lane per step, which the // hardware coalesces across the sub-group into one wide transaction. +// +// On top of coalescing, this path blocks tokens: each work-item owns one +// output column but processes up to `TOKEN_BLOCK` consecutive tokens. For each +// distinct expert appearing in the block it makes a single weight-streaming +// pass and reuses every loaded (coalesced) byte across all tokens in the block +// routed to that expert. When decode routing is bursty -- runs of tokens +// hitting the same expert -- this amortizes the dominant weight traffic across +// the block (GEMV -> small GEMM). Fully-scattered routing degrades gracefully +// to one pass per token with the same per-pass weight reads as before, so the +// result is bit-identical to the one-token-per-work-item kernel regardless of +// routing. // ---------------------------------------------------------------------------- template void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, @@ -452,68 +473,121 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin }); } - sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; + sycl::range<2> global{static_cast((total_tokens + TOKEN_BLOCK - 1) / TOKEN_BLOCK), + static_cast(n_tiles * SG_SIZE)}; sycl::range<2> local{1, static_cast(SG_SIZE)}; q->parallel_for>( sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); + const int token_base = static_cast(it.get_global_id(0)) * TOKEN_BLOCK; const int n_tile = static_cast(it.get_group(1)); const int lane = static_cast(it.get_local_id(1)); const int n_global = n_tile * N_TILE + lane; - const int expert = expert_id_per_token[token]; - const ScalarT* act_row = activations + static_cast(token) * K; + // Number of tokens this work-item owns (last block may be short). + int block = TOKEN_BLOCK; + if (token_base + block > total_tokens) { + block = total_tokens - token_base; + } - // Base of this (expert, n_tile) weight tile in the repacked buffer. - // Layout [E, N/16, K/2, 16]; this lane reads byte kb at - // w_tile[kb*16 + lane], so adjacent lanes read adjacent bytes. - const uint8_t* w_tile = - repacked + ((static_cast(expert) * n_tiles + n_tile) * k_packed) * N_TILE; - const ScalarT* s_row = - scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; - const ScalarT* z_row = Asym - ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k - : nullptr; + // Experts routed by each token in the block. The tile weight byte is + // loaded once per k-step and reused only for tokens whose expert + // matches the byte's owning expert, so blocking tokens that share an + // expert amortizes the dominant weight traffic; tokens with a + // different expert contribute nothing from this pass and are handled + // by the pass whose leader expert matches theirs. + int experts[TOKEN_BLOCK]; + for (int b = 0; b < block; ++b) { + experts[b] = expert_id_per_token[token_base + b]; + } - float acc = 0.0f; - for (int g = 0; g < num_groups_k; ++g) { - const float scale = static_cast(s_row[g]); - float zero = 0.0f; - if constexpr (Asym) { - zero = static_cast(z_row[g]); + // Which distinct experts appear in this block. For each we make one + // weight-streaming pass, reusing every loaded byte across all tokens + // in the block routed to that expert. Bursty routing collapses to a + // single pass; fully-scattered routing degrades to one pass per token + // (i.e. the previous behaviour) with no extra weight reads per pass. + for (int lead = 0; lead < block; ++lead) { + const int expert = experts[lead]; + // Skip experts already streamed by an earlier token in this block. + bool seen = false; + for (int p = 0; p < lead; ++p) { + if (experts[p] == expert) { + seen = true; + break; + } } - const int k_base = g * group_size; - // Two partial accumulators break the fp32 dependency chain; the - // per-group scale/zero is folded once after the K-loop, exactly as - // in the scalar path. Each iteration processes two K-elements (one - // packed byte); the byte load is coalesced across the sub-group. - float acc_q0 = 0.0f; - float acc_q1 = 0.0f; - float acc_a = 0.0f; - const int kb_base = k_base / 2; - const int kb_count = group_size / 2; - for (int kb = 0; kb < kb_count; ++kb) { - const uint8_t packed = w_tile[(kb_base + kb) * N_TILE + lane]; - int q0, q1; - decode_int4_pair(packed, q0, q1); - const float fa0 = static_cast(act_row[k_base + 2 * kb]); - const float fa1 = static_cast(act_row[k_base + 2 * kb + 1]); - acc_q0 += fa0 * static_cast(q0); - acc_q1 += fa1 * static_cast(q1); + if (seen) continue; + + // Base of this (expert, n_tile) weight tile in the repacked buffer. + // Layout [E, N/16, K/2, 16]; this lane reads byte kb at + // w_tile[kb*16 + lane], so adjacent lanes read adjacent bytes. + const uint8_t* w_tile = + repacked + ((static_cast(expert) * n_tiles + n_tile) * k_packed) * N_TILE; + const ScalarT* s_row = + scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; + const ScalarT* z_row = Asym + ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k + : nullptr; + + float acc[TOKEN_BLOCK]; + for (int b = 0; b < block; ++b) acc[b] = 0.0f; + + for (int g = 0; g < num_groups_k; ++g) { + const float scale = static_cast(s_row[g]); + float zero = 0.0f; if constexpr (Asym) { - acc_a += fa0 + fa1; + zero = static_cast(z_row[g]); + } + const int k_base = g * group_size; + // Per-token split accumulators; the per-group scale/zero is folded + // once after the K-loop, exactly as in the scalar path. Each + // iteration processes two K-elements (one packed byte); the byte + // load is coalesced across the sub-group and reused across every + // token in the block routed to `expert`. + float acc_q0[TOKEN_BLOCK]; + float acc_q1[TOKEN_BLOCK]; + float acc_a[TOKEN_BLOCK]; + for (int b = 0; b < block; ++b) { + acc_q0[b] = 0.0f; + acc_q1[b] = 0.0f; + acc_a[b] = 0.0f; + } + const int kb_base = k_base / 2; + const int kb_count = group_size / 2; + for (int kb = 0; kb < kb_count; ++kb) { + const uint8_t packed = w_tile[(kb_base + kb) * N_TILE + lane]; + int q0, q1; + decode_int4_pair(packed, q0, q1); + const float fq0 = static_cast(q0); + const float fq1 = static_cast(q1); + for (int b = 0; b < block; ++b) { + if (experts[b] != expert) continue; + const ScalarT* act_row = activations + static_cast(token_base + b) * K; + const float fa0 = static_cast(act_row[k_base + 2 * kb]); + const float fa1 = static_cast(act_row[k_base + 2 * kb + 1]); + acc_q0[b] += fa0 * fq0; + acc_q1[b] += fa1 * fq1; + if constexpr (Asym) { + acc_a[b] += fa0 + fa1; + } + } + } + for (int b = 0; b < block; ++b) { + if (experts[b] != expert) continue; + float group_dot = acc_q0[b] + acc_q1[b]; + if constexpr (Asym) { + group_dot -= zero * acc_a[b]; + } + acc[b] += scale * group_dot; } } - float group_dot = acc_q0 + acc_q1; - if constexpr (Asym) { - group_dot -= zero * acc_a; + + for (int b = 0; b < block; ++b) { + if (experts[b] != expert) continue; + outputs[static_cast(token_base + b) * N + n_global] = static_cast(acc[b]); } - acc += scale * group_dot; } - - outputs[static_cast(token) * N + n_global] = static_cast(acc); }); q->wait(); diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index 3a53e0701e..bcecc1446e 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -622,6 +622,69 @@ def _run(): # the weight memory layout differs), so require a tight match. torch.testing.assert_close(out_coalesced, out_scalar, rtol=1e-3, atol=1e-3) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("asym", [False, True]) + def test_decode_int4_coalesced_token_blocking(self, monkeypatch, dtype, asym): + """The coalesced int4 fallback blocks up to TOKEN_BLOCK consecutive + tokens per work-item, reusing each loaded weight byte across tokens + routed to the same expert. This must stay bit-identical to the legacy + per-lane-strided kernel regardless of routing, so exercise: + - a run of many tokens on one expert (full + short trailing block), + - blocks that straddle an expert boundary (mixed experts per block), + - an expert with zero tokens. + Shapes keep N%16==0 so the N-tiled repack is exact. + """ + num_experts = 4 + # 7 tokens on expert 0, none on expert 1, 5 on expert 2, 3 on expert 3. + # With TOKEN_BLOCK=4 this yields full blocks, short trailing blocks and + # at least one block straddling the 0->2 and 2->3 expert boundaries. + tokens_per_expert = [7, 0, 5, 3] + total_tokens = sum(tokens_per_expert) + N, K = 256, 128 + group_size = 32 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + if asym: + zeros = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_asym(w_float, scales, zeros, group_size) + dequant = _dequant_int4_asym(packed, scales, zeros, group_size).to(dtype) + else: + zeros = None + packed = _pack_int4_sym(w_float, scales, group_size) + dequant = _dequant_int4_sym(packed, scales, group_size).to(dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + zeros=zeros, + weight_bits=4, + group_size=group_size, + asym=asym, + ) + + # Force the scalar-GEMV fallback so the coalesce/token-blocking path runs. + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "1") + out_blocked = _run() + + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "0") + out_scalar = _run() + + assert out_blocked.shape == (total_tokens, N) + torch.testing.assert_close(out_blocked, ref, rtol=5e-2, atol=5e-2) + # Token blocking only changes weight reuse, not the dequant math, so the + # blocked and legacy kernels must match tightly for every routing shape. + torch.testing.assert_close(out_blocked, out_scalar, rtol=1e-3, atol=1e-3) + def test_decode_validation_errors(self): """Sanity-check that Python-side validation catches misuse.""" num_experts = 2 From cc939f8d6cb81d442368f62b092c219ddc75dcc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:28:37 +0000 Subject: [PATCH 026/112] fix: parameterize MoEDecodeRepackInt4 kernel name to avoid duplicate mangled names Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 0f052f6c90..298df971f2 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -104,6 +104,7 @@ class MoEDecodeKernelInt4; template class MoEDecodeKernelInt4Coalesced; +template class MoEDecodeRepackInt4; template @@ -460,7 +461,7 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin { sycl::range<3> rp_global{static_cast(num_experts), static_cast(N), static_cast(k_packed)}; - q->parallel_for(rp_global, [=](sycl::id<3> id) { + q->parallel_for>(rp_global, [=](sycl::id<3> id) { const int e = static_cast(id[0]); const int n = static_cast(id[1]); const int kb = static_cast(id[2]); From 590ce59a0528db1b0463046c64046b84a9f46df5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:51:11 +0000 Subject: [PATCH 027/112] perf: gate coalesced int4 decode on repack amortization to fix tiny-batch regression Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 298df971f2..d66b5b4c2b 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1037,6 +1037,32 @@ inline bool moe_decode_coalesce_int4_enabled() { return true; } +// ---------------------------------------------------------------------------- +// Amortization gate for the coalesced int4 decode kernel. The coalesced path +// repacks the *entire* weight tensor for all `num_experts` on every call +// (cost proportional to num_experts * N * K/2) before running the GEMV. That +// one-time repack only pays off when a work-group reuses each repacked weight +// tile across many tokens -- i.e. when there are enough active tokens relative +// to the number of experts. For tiny decode batches (e.g. 8 tokens spread +// across 192 experts) the repack dominates and the coalesced kernel is far +// slower than the per-lane-strided `launch_int4`, which reads the weights in +// place with no repack. Require at least one full TOKEN_BLOCK worth of tokens +// per expert on average before coalescing; otherwise fall back to `launch_int4`. +// `ARK_MOE_DECODE_COALESCE_MIN_TOKENS` overrides the threshold (tokens per +// expert scaled by TOKEN_BLOCK); "0" disables the gate (always coalesce). +// ---------------------------------------------------------------------------- +inline bool moe_decode_coalesce_int4_amortized(int total_tokens, int num_experts) { + if (num_experts <= 0) return true; + long long min_tokens = static_cast(num_experts) * TOKEN_BLOCK; + const char* env = std::getenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS"); + if (env != nullptr) { + char* end = nullptr; + long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 0) min_tokens = v; + } + return static_cast(total_tokens) >= min_tokens; +} + // ---------------------------------------------------------------------------- // // weight_dtype: @@ -1112,7 +1138,8 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo // default this uses the coalesced-load variant, which repacks the weights // on-device so sub-group loads are contiguous; `ARK_MOE_DECODE_COALESCE_INT4=0` // forces the legacy per-lane-strided kernel. - const bool coalesce = moe_decode_coalesce_int4_enabled(); + const bool coalesce = moe_decode_coalesce_int4_enabled() && + moe_decode_coalesce_int4_amortized(total_tokens, num_experts); if (act_dtype == BTLA_DTYPE::F16) { if (asym) { if (coalesce) { From d80a9a134e39854c78f66dac474d6a725f932a68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:24:56 +0000 Subject: [PATCH 028/112] fix: qualify TOKEN_BLOCK with moe_decode_detail namespace Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index d66b5b4c2b..7c141b0eab 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1053,7 +1053,7 @@ inline bool moe_decode_coalesce_int4_enabled() { // ---------------------------------------------------------------------------- inline bool moe_decode_coalesce_int4_amortized(int total_tokens, int num_experts) { if (num_experts <= 0) return true; - long long min_tokens = static_cast(num_experts) * TOKEN_BLOCK; + long long min_tokens = static_cast(num_experts) * moe_decode_detail::TOKEN_BLOCK; const char* env = std::getenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS"); if (env != nullptr) { char* end = nullptr; From 7db3d5f24fb2a156717ba77275e2f38b49da70c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:09:30 +0000 Subject: [PATCH 029/112] perf: hoist routing filter out of int4 coalesced decode hot loop Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 7c141b0eab..8a60fd6417 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -531,8 +531,24 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k : nullptr; + // Compact the tokens routed to `expert` into a dense member list + // once per pass. Hoisting the routing filter out of the hot k-loop + // removes a per-(kb, token) branch and lets the compiler keep the + // per-member activation base pointers in registers; the numerics + // are identical to the previous per-kb `experts[b] != expert` + // filter. + int members[TOKEN_BLOCK]; + const ScalarT* act_rows[TOKEN_BLOCK]; + int nmembers = 0; + for (int b = 0; b < block; ++b) { + if (experts[b] != expert) continue; + members[nmembers] = b; + act_rows[nmembers] = activations + static_cast(token_base + b) * K; + ++nmembers; + } + float acc[TOKEN_BLOCK]; - for (int b = 0; b < block; ++b) acc[b] = 0.0f; + for (int m = 0; m < nmembers; ++m) acc[m] = 0.0f; for (int g = 0; g < num_groups_k; ++g) { const float scale = static_cast(s_row[g]); @@ -549,10 +565,10 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin float acc_q0[TOKEN_BLOCK]; float acc_q1[TOKEN_BLOCK]; float acc_a[TOKEN_BLOCK]; - for (int b = 0; b < block; ++b) { - acc_q0[b] = 0.0f; - acc_q1[b] = 0.0f; - acc_a[b] = 0.0f; + for (int m = 0; m < nmembers; ++m) { + acc_q0[m] = 0.0f; + acc_q1[m] = 0.0f; + acc_a[m] = 0.0f; } const int kb_base = k_base / 2; const int kb_count = group_size / 2; @@ -562,31 +578,30 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin decode_int4_pair(packed, q0, q1); const float fq0 = static_cast(q0); const float fq1 = static_cast(q1); - for (int b = 0; b < block; ++b) { - if (experts[b] != expert) continue; - const ScalarT* act_row = activations + static_cast(token_base + b) * K; - const float fa0 = static_cast(act_row[k_base + 2 * kb]); - const float fa1 = static_cast(act_row[k_base + 2 * kb + 1]); - acc_q0[b] += fa0 * fq0; - acc_q1[b] += fa1 * fq1; + const int k0 = k_base + 2 * kb; + for (int m = 0; m < nmembers; ++m) { + const ScalarT* act_row = act_rows[m]; + const float fa0 = static_cast(act_row[k0]); + const float fa1 = static_cast(act_row[k0 + 1]); + acc_q0[m] += fa0 * fq0; + acc_q1[m] += fa1 * fq1; if constexpr (Asym) { - acc_a[b] += fa0 + fa1; + acc_a[m] += fa0 + fa1; } } } - for (int b = 0; b < block; ++b) { - if (experts[b] != expert) continue; - float group_dot = acc_q0[b] + acc_q1[b]; + for (int m = 0; m < nmembers; ++m) { + float group_dot = acc_q0[m] + acc_q1[m]; if constexpr (Asym) { - group_dot -= zero * acc_a[b]; + group_dot -= zero * acc_a[m]; } - acc[b] += scale * group_dot; + acc[m] += scale * group_dot; } } - for (int b = 0; b < block; ++b) { - if (experts[b] != expert) continue; - outputs[static_cast(token_base + b) * N + n_global] = static_cast(acc[b]); + for (int m = 0; m < nmembers; ++m) { + const int b = members[m]; + outputs[static_cast(token_base + b) * N + n_global] = static_cast(acc[m]); } } }); From c8ef1b8e59c2a0375b6fa8e8d243065091b6191b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:45:10 +0000 Subject: [PATCH 030/112] feat: add dedicated w4a16_policy_m_8 decode DPAS dispatch for S4 sym Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 20 +++-- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 81 +++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 8a60fd6417..b1d637f9ea 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1012,12 +1012,16 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh // ---------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_DECODE_DPAS_S4` (default ON). When ON, int4-sym -// (S4_CLIP, !asym) decode is routed to the shared per-group S4 DPAS grouped -// GEMM (`moe_dpas_s4::moe_prefill_s4_dpas_per_group_dispatch`) instead of the -// scalar FMA GEMV (`launch_int4`). The DPAS path already handles the tiny -// total-token counts typical of decode (its `A_avg_M <= 4` bucket selects the -// 8-row `dpas_w4a16_policy_m_8` tile) and reads the same `[E, N, K/2]` packed -// weights + `[E, N, K/group]` scales, so no repack is needed. +// (S4_CLIP, !asym) decode is routed to the dedicated decode-phase S4 DPAS +// grouped GEMM (`moe_dpas_s4::moe_decode_s4_dpas_per_group_dispatch`) instead +// of the scalar FMA GEMV (`launch_int4`). Ported from vLLM-xpu-kernels' +// dedicated `w4a16` decode dispatch, this path hard-pins the 8-row +// `dpas_w4a16_policy_m_8` tile (the only tile the prefill `A_avg_M` ladder +// would ever pick for decode-sized batches) and reuses the shared per-group +// mainloop's 2D VNNI block load + register-resident per-N scale. It reads the +// same `[E, N, K/2]` packed weights + `[E, N, K/group]` scales, so no repack is +// needed. (`ARK_MOE_DECODE_S4_DPAS_M8=0` defers to the full prefill bucket +// ladder for A/B comparison; the two are numerically identical.) // // Setting the var to "0" / "false" / "off" / "no" (case-insensitive) forces // the legacy scalar GEMV, for A/B comparison and regression escape. Asym @@ -1136,13 +1140,13 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo if (!asym && moe_decode_dpas_s4_enabled() && moe_dpas_s4::moe_prefill_dpas_s4_pergroup_shape_ok(N, K, group_size)) { if (act_dtype == BTLA_DTYPE::F16) { - moe_dpas_s4::moe_prefill_s4_dpas_per_group_dispatch( + moe_dpas_s4::moe_decode_s4_dpas_per_group_dispatch( q, static_cast(activations), static_cast(weights), static_cast(scales), static_cast(outputs), num_tokens_per_expert, num_experts, N, K, group_size, total_tokens); } else { using BF = sycl::ext::oneapi::bfloat16; - moe_dpas_s4::moe_prefill_s4_dpas_per_group_dispatch( + moe_dpas_s4::moe_decode_s4_dpas_per_group_dispatch( q, static_cast(activations), static_cast(weights), static_cast(scales), static_cast(outputs), num_tokens_per_expert, num_experts, N, K, group_size, total_tokens); diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index 693a8675e5..f59a863cc7 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -678,6 +678,87 @@ void moe_prefill_s4_dpas_per_group_dispatch( sycl::free(atomic_buffer, *q); } +// --------------------------------------------------------------------------- +// Decode-specialized S4 (sym) per-K-group DPAS dispatch. +// +// Ported from vllm-xpu-kernels' dedicated `w4a16` *decode* dispatch: for the +// generation (decode) phase every expert sees at most a handful of tokens, so +// the 8-row `dpas_w4a16_policy_m_8` tile is always the right choice. Rather +// than piggy-backing on `moe_prefill_s4_dpas_per_group_dispatch` and re-running +// its four-tier `A_avg_M` bucket ladder (which only selects `_m_8` at the tiny +// end anyway), this entry point hard-pins the 8-row tile. +// +// The underlying grouped-GEMM mainloop (`xe_gemm_*` per-group loop) is reused +// verbatim: it already performs the 2D VNNI block load via +// `get_block_2d_copy_A/B` + `make_block_2d_prefetch`, and caches the per-N +// scale in the `sg_scale[]` register array, folding it once per K-group. So no +// new mainloop math is written here -- only the tile selection differs from the +// prefill dispatch. +// +// `ARK_MOE_DECODE_S4_DPAS_M8` (default ON) can be set to "0"/"false"/"off"/"no" +// to fall back to the full prefill `A_avg_M` bucket ladder for A/B comparison +// (the two paths are numerically identical; only the tile shape differs). +// --------------------------------------------------------------------------- +inline bool moe_decode_s4_dpas_m8_enabled() { + const char* env = std::getenv("ARK_MOE_DECODE_S4_DPAS_M8"); + if (env == nullptr) return true; // default ON + std::string s(env); + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + if (s == "0" || s == "false" || s == "off" || s == "no") return false; + return true; +} + +template +void moe_decode_s4_dpas_per_group_dispatch( + sycl::queue* q, const ScalarT* activations, const uint8_t* weights_NKp, + const ScalarT* scales, ScalarT* outputs, + const int* num_tokens_per_expert, int E, int N, int K, int group_size, + int total_tokens) { + // A/B escape: defer to the prefill bucket ladder when the m_8 pin is + // disabled. Identical math; only the DPAS tile shape differs. + if (!moe_decode_s4_dpas_m8_enabled()) { + moe_prefill_s4_dpas_per_group_dispatch( + q, activations, weights_NKp, scales, outputs, num_tokens_per_expert, E, + N, K, group_size, total_tokens); + return; + } + + if (E == 0 || N == 0 || K == 0 || total_tokens == 0) return; + if (K % group_size != 0) { + throw std::invalid_argument( + "moe_decode_s4_dpas(per-group): K must be a multiple of group_size"); + } + if ((K & 1) != 0) { + throw std::invalid_argument( + "moe_decode_s4_dpas(per-group): K must be even (packed nibbles)"); + } + + compat::set_default_queue(*q); + + using ElementA = cute_scalar_t; + const auto* activations_ca = + reinterpret_cast(activations); + const auto* scales_ca = reinterpret_cast(scales); + auto* outputs_ca = reinterpret_cast(outputs); + const auto* weights_i4 = + reinterpret_cast(weights_NKp); + + int32_t* atomic_buffer = sycl::malloc_device(1, *q); + if (atomic_buffer == nullptr) { + throw std::runtime_error( + "moe_decode_s4_dpas(per-group): failed to allocate atomic buffer"); + } + + // Hard-pin the 8-row tile -- the only tile the prefill ladder would ever + // select for decode-sized batches. + MoEGEMMLauncher_s4<'R', 'C', dpas_w4a16_policy_m_8>( + *q, activations_ca, weights_i4, scales_ca, + static_cast(nullptr), outputs_ca, N, K, + num_tokens_per_expert, E, group_size, atomic_buffer); + + sycl::free(atomic_buffer, *q); +} + // --------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_PREFILL_DPAS_S4` (default ON, semantics // identical to `moe_prefill_dpas_int_enabled` / `moe_prefill_dpas_fp8 From a3c82ced15b5b796e6834b4d7a2599c545e188a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:46:48 +0000 Subject: [PATCH 031/112] docs+test: document decode S4 DPAS path and add A/B parity assertion Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_PREFILL_PERF.md | 15 +++++++++++++++ .../ark/test/README_MOE_PREFILL_PERF_CN.md | 14 ++++++++++++++ auto_round_extension/ark/test/test_moe.py | 10 ++++++++++ 3 files changed, 39 insertions(+) diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index c1ed20ef5b..bea7f499b1 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -311,6 +311,21 @@ The mid-size `32×64` tile now covers `A_avg_M` up to 128 (previously it jumped to the wide tile at 33), which avoids padding waste on the common chunked-prefill batch sizes. +**S4 DPAS decode path** — the *decode* phase (`sycl_tla_moe_decode.hpp`, +int4-sym / `S4_CLIP`, `!asym`, `ARK_MOE_DECODE_DPAS_S4` default ON) now +has its own dedicated dispatch, `moe_decode_s4_dpas_per_group_dispatch`, +ported from vLLM-xpu-kernels' dedicated `w4a16` *decode* dispatch. +Because every expert sees at most a handful of tokens during generation, +it hard-pins the 8-row `dpas_w4a16_policy_m_8` tile (the only tile the +prefill `A_avg_M` ladder would ever pick for decode-sized batches) rather +than re-running that ladder. It reuses the shared per-group mainloop's +2D VNNI block load (`get_block_2d_copy_A/B` + `make_block_2d_prefetch`) +and register-resident per-N scale (`sg_scale[]`, folded once per K-group), +reading the same `[E, N, K/2]` packed weights + `[E, N, K/group]` scales +with no repack. `ARK_MOE_DECODE_S4_DPAS_M8=0` defers to the full prefill +bucket ladder for A/B comparison (numerically identical; only the tile +shape differs). **Status: NEEDS-HARDWARE-VALIDATION** (untested port). + Accuracy parity is covered by `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group`, which forces `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 713474651d..96d7a53396 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -240,6 +240,20 @@ nibble 的 B 流字节量是 INT8 路径的一半,大 M tile 加宽到 `128×256 中等大小的 `32×64` tile 现在覆盖 `A_avg_M` 至 128(此前在 33 就跳到大 tile),避免了常见 chunked-prefill batch 大小下的 padding 浪费。 +**S4 DPAS decode 路径** — decode(生成)阶段(`sycl_tla_moe_decode.hpp`, +int4-sym / `S4_CLIP`,`!asym`,`ARK_MOE_DECODE_DPAS_S4` 默认开启)现在拥有 +独立的 dispatch `moe_decode_s4_dpas_per_group_dispatch`,移植自 +vLLM-xpu-kernels 专用的 `w4a16` *decode* dispatch。由于生成阶段每个专家 +最多只见到少量 token,它直接钉死 8 行的 `dpas_w4a16_policy_m_8` tile +(prefill 的 `A_avg_M` 阶梯对 decode 规模的 batch 也只会选这个 tile), +而不再运行整个阶梯。它复用共享的 per-group mainloop 的 2D VNNI 块加载 +(`get_block_2d_copy_A/B` + `make_block_2d_prefetch`)与寄存器驻留的 +per-N scale(`sg_scale[]`,每个 K-group 折叠一次),读取相同的 +`[E, N, K/2]` 打包权重 + `[E, N, K/group]` scale,无需重新打包。 +`ARK_MOE_DECODE_S4_DPAS_M8=0` 会回退到完整的 prefill 阶梯以便 A/B 对比 +(数值完全相同,仅 tile 形状不同)。**状态:NEEDS-HARDWARE-VALIDATION** +(未经测试的移植)。 + 精度对齐由 `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group` 覆盖,该用例强制 `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index bcecc1446e..a57f87b089 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -516,8 +516,15 @@ def _run(): ) monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + monkeypatch.setenv("ARK_MOE_DECODE_S4_DPAS_M8", "1") out_dpas = _run() + # A/B escape: deferring to the prefill A_avg_M bucket ladder must be + # numerically identical (only the DPAS tile shape differs). + monkeypatch.setenv("ARK_MOE_DECODE_S4_DPAS_M8", "0") + out_dpas_ladder = _run() + monkeypatch.setenv("ARK_MOE_DECODE_S4_DPAS_M8", "1") + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") out_scalar = _run() @@ -528,6 +535,9 @@ def _run(): torch.testing.assert_close(out_scalar, ref, rtol=5e-2, atol=5e-2) # And they must agree with each other within the same tolerance. torch.testing.assert_close(out_dpas, out_scalar, rtol=5e-2, atol=5e-2) + # The m_8-pinned decode dispatch and the prefill bucket ladder are the + # same DPAS math on decode-sized batches. + torch.testing.assert_close(out_dpas, out_dpas_ladder, rtol=5e-2, atol=5e-2) @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) def test_decode_int4_asym(self, dtype): From 339608282dd6df73d0901a194bcfb7bf93194b17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:02:45 +0000 Subject: [PATCH 032/112] perf: reuse persistent atomic buffer in S4 DPAS dispatch Replace per-call sycl::malloc_device/free of the 1-int32 work-group counter in the prefill and decode S4 DPAS grouped-GEMM dispatchers with a persistent per-queue buffer. Each malloc/free forces a queue sync, which is pure overhead on the decode hot path. The kernel self-zeroes the counter at launch and every launch is synchronous, so a single reused buffer per queue is safe. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index f59a863cc7..7b015ff314 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -91,9 +91,11 @@ #pragma once #include +#include #include #include #include +#include #ifdef ARK_XPU #include @@ -143,6 +145,38 @@ using ::ark::moe_dpas_fp8::cute_scalar; using ::ark::moe_dpas_fp8::cute_scalar_t; using ::ark::moe_dpas_fp8::make_moe_tensor; +// --------------------------------------------------------------------------- +// Persistent per-queue atomic work-group counter. +// +// The grouped-GEMM launcher below needs a single `int32_t` device slot as a +// global work-group counter (`atomicAdd`). The kernel self-initialises it to 0 +// at launch (group 0 / lane 0 does `atm.store(0)`), so the host never has to +// reset it between calls. Previously each dispatch call allocated this slot +// with `sycl::malloc_device` and released it with `sycl::free`; both operations +// force a queue synchronization, which is pure overhead on the decode hot path +// where the GEMM itself is only tens of microseconds. +// +// Instead, hand out one persistent buffer per queue and reuse it across calls. +// This is safe because every launcher call is synchronous (`event.wait()` in +// `MoEGEMMLauncher_s4`), so two launches can never share the buffer +// concurrently. Buffers live until process exit (one `int32_t` per queue), +// matching the singleton lifetime already used by `EventManager`. +// --------------------------------------------------------------------------- +inline int32_t* get_persistent_atomic_buffer(sycl::queue* q) { + static std::mutex mtx; + static std::unordered_map cache; + std::lock_guard lock(mtx); + auto it = cache.find(q); + if (it != cache.end()) return it->second; + int32_t* buf = sycl::malloc_device(1, *q); + if (buf == nullptr) { + throw std::runtime_error( + "moe_dpas_s4: failed to allocate persistent atomic buffer"); + } + cache.emplace(q, buf); + return buf; +} + // --------------------------------------------------------------------------- // Variant B -- per-K-group S4 (sym) mainloop. // @@ -646,11 +680,9 @@ void moe_prefill_s4_dpas_per_group_dispatch( int A_avg_M = total_tokens / E; - int32_t* atomic_buffer = sycl::malloc_device(1, *q); - if (atomic_buffer == nullptr) { - throw std::runtime_error( - "moe_prefill_s4_dpas(per-group): failed to allocate atomic buffer"); - } + // Reusable per-queue work-group counter (self-zeroed by the kernel); avoids + // a malloc_device/free (each a queue sync) on every dispatch call. + int32_t* atomic_buffer = get_persistent_atomic_buffer(q); #define ARK_DPAS_S4_PG_LAUNCH_SYM(policy) \ MoEGEMMLauncher_s4<'R', 'C', policy>( \ @@ -674,8 +706,6 @@ void moe_prefill_s4_dpas_per_group_dispatch( ARK_DPAS_S4_PG_LAUNCH_SYM(dpas_w4a16_policy); } #undef ARK_DPAS_S4_PG_LAUNCH_SYM - - sycl::free(atomic_buffer, *q); } // --------------------------------------------------------------------------- @@ -743,11 +773,7 @@ void moe_decode_s4_dpas_per_group_dispatch( const auto* weights_i4 = reinterpret_cast(weights_NKp); - int32_t* atomic_buffer = sycl::malloc_device(1, *q); - if (atomic_buffer == nullptr) { - throw std::runtime_error( - "moe_decode_s4_dpas(per-group): failed to allocate atomic buffer"); - } + int32_t* atomic_buffer = get_persistent_atomic_buffer(q); // Hard-pin the 8-row tile -- the only tile the prefill ladder would ever // select for decode-sized batches. @@ -755,8 +781,6 @@ void moe_decode_s4_dpas_per_group_dispatch( *q, activations_ca, weights_i4, scales_ca, static_cast(nullptr), outputs_ca, N, K, num_tokens_per_expert, E, group_size, atomic_buffer); - - sycl::free(atomic_buffer, *q); } // --------------------------------------------------------------------------- From ba2c18d78fb7b2a650d298a38b8cc35daff13fe7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:31:27 +0000 Subject: [PATCH 033/112] perf: trim device-timeline overhead on S4 DPAS decode hot path Two waste removals on the int4-sym decode path, motivated by the device-event perf measurement (host-side malloc/free reuse showed no change because it never lands on the device timeline): - Skip the fill_expert_id_per_token kernel when the S4-sym DPAS fast path is taken; that path uses num_tokens_per_expert directly and never reads expert_id_per_token_buf, so the extra device kernel launch was pure overhead. The predicate is shared with the S4 block so the two decisions cannot diverge. - Cache the per-launch query_device_multiprocessor_count host driver query in MoEGEMMLauncher_s4 (device SM count is fixed), removing a Level Zero round-trip from every decode launch. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 20 ++++++++++++++----- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 6 ++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index b1d637f9ea..d8cb44b32c 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1108,8 +1108,17 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo void* outputs, int* expert_id_per_token_buf, BTLA_DTYPE act_dtype, BTLA_DTYPE weight_dtype, int N, int K, int group_size, int* num_tokens_per_expert, int num_experts, int total_tokens, bool asym) { - moe_decode_detail::fill_expert_id_per_token(q, expert_id_per_token_buf, num_tokens_per_expert, num_experts, - total_tokens); + // The S4-sym DPAS fast path consumes `num_tokens_per_expert` directly and + // never reads `expert_id_per_token_buf`. Skipping the fill on that path + // removes an extra device-timeline kernel launch from the decode hot path; + // every other path (fp, int8, int2, fp8, and the scalar int4 fallback) still + // needs the per-token expert mapping. + const bool s4_dpas_fastpath = weight_dtype == BTLA_DTYPE::S4_CLIP && !asym && moe_decode_dpas_s4_enabled() && + moe_dpas_s4::moe_prefill_dpas_s4_pergroup_shape_ok(N, K, group_size); + if (!s4_dpas_fastpath) { + moe_decode_detail::fill_expert_id_per_token(q, expert_id_per_token_buf, num_tokens_per_expert, num_experts, + total_tokens); + } if (weight_dtype == BTLA_DTYPE::F16 || weight_dtype == BTLA_DTYPE::BF16) { if (weight_dtype != act_dtype) { @@ -1136,9 +1145,10 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo // Fast path: sym int4 through the shared per-group S4 DPAS grouped GEMM. // Falls back to the scalar GEMV for asym weights (DPAS S4 is sym-only), // when the env flag is off, or when the shape gate rejects the tile - // geometry (e.g. N%64!=0, K%32!=0, unsupported group_size). - if (!asym && moe_decode_dpas_s4_enabled() && - moe_dpas_s4::moe_prefill_dpas_s4_pergroup_shape_ok(N, K, group_size)) { + // geometry (e.g. N%64!=0, K%32!=0, unsupported group_size). Reuses the + // `s4_dpas_fastpath` predicate computed above (which also gated the + // `fill_expert_id_per_token` skip) so the two decisions cannot diverge. + if (s4_dpas_fastpath) { if (act_dtype == BTLA_DTYPE::F16) { moe_dpas_s4::moe_decode_s4_dpas_per_group_dispatch( q, static_cast(activations), static_cast(weights), diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index 7b015ff314..7c43e08e45 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -591,8 +591,10 @@ void MoEGEMMLauncher_s4(sycl::queue& stream, const ElementA* activations, SGLayout>::TiledMMA; auto mma = MMA{}; - int sm_count = - cutlass::KernelHardwareInfo::query_device_multiprocessor_count(0); + // Device SM count is a host-side driver query (Level Zero round-trip). The + // value is fixed for a given device, so cache it once instead of paying the + // round-trip on every launch -- the decode hot path calls this per step. + static const int sm_count = cutlass::KernelHardwareInfo::query_device_multiprocessor_count(0); auto MaxThreadsPerWorkgroup = size(mma); static constexpr int MaxThreadsPerSM = 512; From 5b88c2ec6d9cca59883647134547c2fed8ccfb43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:50:11 +0000 Subject: [PATCH 034/112] perf(ark): use A_avg_M tile ladder for int4 moe sym decode (match vllm-xpu-kernels) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 16 +++---- .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 46 +++++++++++-------- .../ark/test/README_MOE_PREFILL_PERF.md | 31 +++++++------ .../ark/test/README_MOE_PREFILL_PERF_CN.md | 24 ++++++---- 4 files changed, 67 insertions(+), 50 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index d8cb44b32c..2aa6300ab6 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1014,14 +1014,14 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh // Env-flag helper -- `ARK_MOE_DECODE_DPAS_S4` (default ON). When ON, int4-sym // (S4_CLIP, !asym) decode is routed to the dedicated decode-phase S4 DPAS // grouped GEMM (`moe_dpas_s4::moe_decode_s4_dpas_per_group_dispatch`) instead -// of the scalar FMA GEMV (`launch_int4`). Ported from vLLM-xpu-kernels' -// dedicated `w4a16` decode dispatch, this path hard-pins the 8-row -// `dpas_w4a16_policy_m_8` tile (the only tile the prefill `A_avg_M` ladder -// would ever pick for decode-sized batches) and reuses the shared per-group -// mainloop's 2D VNNI block load + register-resident per-N scale. It reads the -// same `[E, N, K/2]` packed weights + `[E, N, K/group]` scales, so no repack is -// needed. (`ARK_MOE_DECODE_S4_DPAS_M8=0` defers to the full prefill bucket -// ladder for A/B comparison; the two are numerically identical.) +// of the scalar FMA GEMV (`launch_int4`). Mirroring vLLM-xpu-kernels' +// `w4a16` decode dispatch, this path selects the DPAS tile from the average +// tokens-per-expert (`A_avg_M`) ladder (`_m_8` -> `_m_16` -> `_m_32` -> wide), +// reusing the shared per-group mainloop's 2D VNNI block load + register-resident +// per-N scale. It reads the same `[E, N, K/2]` packed weights + `[E, N, K/group]` +// scales, so no repack is needed. (`ARK_MOE_DECODE_S4_DPAS_M8=1` forces the +// legacy hard-pinned 8-row tile for A/B comparison; the two are numerically +// identical.) // // Setting the var to "0" / "false" / "off" / "no" (case-insensitive) forces // the legacy scalar GEMV, for A/B comparison and regression escape. Asym diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index 7c43e08e45..ba2bb9ca6e 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -713,31 +713,38 @@ void moe_prefill_s4_dpas_per_group_dispatch( // --------------------------------------------------------------------------- // Decode-specialized S4 (sym) per-K-group DPAS dispatch. // -// Ported from vllm-xpu-kernels' dedicated `w4a16` *decode* dispatch: for the -// generation (decode) phase every expert sees at most a handful of tokens, so -// the 8-row `dpas_w4a16_policy_m_8` tile is always the right choice. Rather -// than piggy-backing on `moe_prefill_s4_dpas_per_group_dispatch` and re-running -// its four-tier `A_avg_M` bucket ladder (which only selects `_m_8` at the tiny -// end anyway), this entry point hard-pins the 8-row tile. +// Mirrors vllm-xpu-kernels' `w4a16` dispatch (`grouped_gemm_xe2_interface.hpp`): +// the DPAS tile is selected from the average tokens-per-expert (`A_avg_M`) +// ladder -- `_m_8` for `A_avg_M <= 4`, `_m_16` for `<= 8`, `_m_32` for `<= 128`, +// then the wide tile. An earlier revision hard-pinned the 8-row +// `dpas_w4a16_policy_m_8` tile on the assumption that decode only ever sees a +// handful of tokens per expert, but that assumption breaks for larger decode +// batches (many sequences, high top-k, or few experts): whenever more than 4 +// tokens route to an expert on average the 8-row tile under-fills the M +// dimension and the (bandwidth-bound) packed weights are re-streamed 2-4x more +// than necessary, roughly halving throughput versus the reference. Selecting +// the same tile as the reference collapses to `_m_8` for tiny batches (so the +// small-decode case is unchanged) and grows the M tile exactly when reuse pays +// off. // // The underlying grouped-GEMM mainloop (`xe_gemm_*` per-group loop) is reused // verbatim: it already performs the 2D VNNI block load via // `get_block_2d_copy_A/B` + `make_block_2d_prefetch`, and caches the per-N // scale in the `sg_scale[]` register array, folding it once per K-group. So no // new mainloop math is written here -- only the tile selection differs from the -// prefill dispatch. +// prefill dispatch (which uses the identical ladder). // -// `ARK_MOE_DECODE_S4_DPAS_M8` (default ON) can be set to "0"/"false"/"off"/"no" -// to fall back to the full prefill `A_avg_M` bucket ladder for A/B comparison -// (the two paths are numerically identical; only the tile shape differs). +// `ARK_MOE_DECODE_S4_DPAS_M8` (default OFF) can be set to "1"/"true"/"on"/"yes" +// to force the legacy hard-pinned 8-row tile for A/B comparison and regression +// escape (the two paths are numerically identical; only the tile shape differs). // --------------------------------------------------------------------------- -inline bool moe_decode_s4_dpas_m8_enabled() { +inline bool moe_decode_s4_dpas_force_m8() { const char* env = std::getenv("ARK_MOE_DECODE_S4_DPAS_M8"); - if (env == nullptr) return true; // default ON + if (env == nullptr) return false; // default OFF -- use the A_avg_M ladder std::string s(env); for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); - if (s == "0" || s == "false" || s == "off" || s == "no") return false; - return true; + if (s == "1" || s == "true" || s == "on" || s == "yes") return true; + return false; } template @@ -746,9 +753,10 @@ void moe_decode_s4_dpas_per_group_dispatch( const ScalarT* scales, ScalarT* outputs, const int* num_tokens_per_expert, int E, int N, int K, int group_size, int total_tokens) { - // A/B escape: defer to the prefill bucket ladder when the m_8 pin is - // disabled. Identical math; only the DPAS tile shape differs. - if (!moe_decode_s4_dpas_m8_enabled()) { + // Default: select the DPAS tile from the shared `A_avg_M` ladder, matching + // the reference `w4a16` decode dispatch. Identical math; only the DPAS tile + // shape differs. `ARK_MOE_DECODE_S4_DPAS_M8=1` forces the legacy m_8 pin. + if (!moe_decode_s4_dpas_force_m8()) { moe_prefill_s4_dpas_per_group_dispatch( q, activations, weights_NKp, scales, outputs, num_tokens_per_expert, E, N, K, group_size, total_tokens); @@ -777,8 +785,8 @@ void moe_decode_s4_dpas_per_group_dispatch( int32_t* atomic_buffer = get_persistent_atomic_buffer(q); - // Hard-pin the 8-row tile -- the only tile the prefill ladder would ever - // select for decode-sized batches. + // Legacy opt-in path (`ARK_MOE_DECODE_S4_DPAS_M8=1`): hard-pin the 8-row + // tile. Kept for A/B comparison against the default `A_avg_M` ladder above. MoEGEMMLauncher_s4<'R', 'C', dpas_w4a16_policy_m_8>( *q, activations_ca, weights_i4, scales_ca, static_cast(nullptr), outputs_ca, N, K, diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index bea7f499b1..c7bb58ea76 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -312,19 +312,24 @@ jumped to the wide tile at 33), which avoids padding waste on the common chunked-prefill batch sizes. **S4 DPAS decode path** — the *decode* phase (`sycl_tla_moe_decode.hpp`, -int4-sym / `S4_CLIP`, `!asym`, `ARK_MOE_DECODE_DPAS_S4` default ON) now -has its own dedicated dispatch, `moe_decode_s4_dpas_per_group_dispatch`, -ported from vLLM-xpu-kernels' dedicated `w4a16` *decode* dispatch. -Because every expert sees at most a handful of tokens during generation, -it hard-pins the 8-row `dpas_w4a16_policy_m_8` tile (the only tile the -prefill `A_avg_M` ladder would ever pick for decode-sized batches) rather -than re-running that ladder. It reuses the shared per-group mainloop's -2D VNNI block load (`get_block_2d_copy_A/B` + `make_block_2d_prefetch`) -and register-resident per-N scale (`sg_scale[]`, folded once per K-group), -reading the same `[E, N, K/2]` packed weights + `[E, N, K/group]` scales -with no repack. `ARK_MOE_DECODE_S4_DPAS_M8=0` defers to the full prefill -bucket ladder for A/B comparison (numerically identical; only the tile -shape differs). **Status: NEEDS-HARDWARE-VALIDATION** (untested port). +int4-sym / `S4_CLIP`, `!asym`, `ARK_MOE_DECODE_DPAS_S4` default ON) has +its own dedicated dispatch, `moe_decode_s4_dpas_per_group_dispatch`, +mirroring vLLM-xpu-kernels' `w4a16` decode dispatch. It selects the DPAS +tile from the same `A_avg_M` ladder as prefill (`_m_8` → `_m_16` → `_m_32` +→ wide): the 8-row tile is used only for the tiny-batch tail (`A_avg_M ≤ +4`), and the M tile grows once more than four tokens route to an expert +on average. An earlier revision hard-pinned the 8-row tile on the +assumption that decode only ever sees a handful of tokens per expert, but +that under-fills the M dimension and re-streams the (bandwidth-bound) +packed weights 2–4× on larger decode batches (many sequences, high top-k, +or few experts), roughly halving throughput versus the reference. It +reuses the shared per-group mainloop's 2D VNNI block load +(`get_block_2d_copy_A/B` + `make_block_2d_prefetch`) and register-resident +per-N scale (`sg_scale[]`, folded once per K-group), reading the same +`[E, N, K/2]` packed weights + `[E, N, K/group]` scales with no repack. +`ARK_MOE_DECODE_S4_DPAS_M8=1` forces the legacy hard-pinned 8-row tile for +A/B comparison (numerically identical; only the tile shape differs). +**Status: NEEDS-HARDWARE-VALIDATION** (untested port). Accuracy parity is covered by `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group`, diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 96d7a53396..1c7d00d8a2 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -241,17 +241,21 @@ nibble 的 B 流字节量是 INT8 路径的一半,大 M tile 加宽到 `128×256 tile),避免了常见 chunked-prefill batch 大小下的 padding 浪费。 **S4 DPAS decode 路径** — decode(生成)阶段(`sycl_tla_moe_decode.hpp`, -int4-sym / `S4_CLIP`,`!asym`,`ARK_MOE_DECODE_DPAS_S4` 默认开启)现在拥有 -独立的 dispatch `moe_decode_s4_dpas_per_group_dispatch`,移植自 -vLLM-xpu-kernels 专用的 `w4a16` *decode* dispatch。由于生成阶段每个专家 -最多只见到少量 token,它直接钉死 8 行的 `dpas_w4a16_policy_m_8` tile -(prefill 的 `A_avg_M` 阶梯对 decode 规模的 batch 也只会选这个 tile), -而不再运行整个阶梯。它复用共享的 per-group mainloop 的 2D VNNI 块加载 -(`get_block_2d_copy_A/B` + `make_block_2d_prefetch`)与寄存器驻留的 -per-N scale(`sg_scale[]`,每个 K-group 折叠一次),读取相同的 +int4-sym / `S4_CLIP`,`!asym`,`ARK_MOE_DECODE_DPAS_S4` 默认开启)拥有 +独立的 dispatch `moe_decode_s4_dpas_per_group_dispatch`,对齐 +vLLM-xpu-kernels 的 `w4a16` decode dispatch。它与 prefill 使用相同的 +`A_avg_M` 阶梯选择 DPAS tile(`_m_8` → `_m_16` → `_m_32` → 大 tile): +仅在极小 batch 尾部(`A_avg_M ≤ 4`)使用 8 行 tile,一旦平均每个专家 +路由超过 4 个 token,M tile 就随之增大。早先的版本直接钉死 8 行的 +`dpas_w4a16_policy_m_8` tile,假设 decode 阶段每个专家只见到少量 token, +但在较大的 decode batch(序列多、top-k 高或专家少)下,这会导致 M +维度欠填充,并把(受带宽约束的)打包权重重复流式加载 2–4 次,使吞吐 +大约只有参考实现的一半。它复用共享的 per-group mainloop 的 2D VNNI 块 +加载(`get_block_2d_copy_A/B` + `make_block_2d_prefetch`)与寄存器驻留 +的 per-N scale(`sg_scale[]`,每个 K-group 折叠一次),读取相同的 `[E, N, K/2]` 打包权重 + `[E, N, K/group]` scale,无需重新打包。 -`ARK_MOE_DECODE_S4_DPAS_M8=0` 会回退到完整的 prefill 阶梯以便 A/B 对比 -(数值完全相同,仅 tile 形状不同)。**状态:NEEDS-HARDWARE-VALIDATION** +`ARK_MOE_DECODE_S4_DPAS_M8=1` 会强制使用旧的钉死 8 行 tile 以便 A/B +对比(数值完全相同,仅 tile 形状不同)。**状态:NEEDS-HARDWARE-VALIDATION** (未经测试的移植)。 精度对齐由 From 1f7b6313f05469a58c2a1ec04a54730225ea024e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:27:55 +0000 Subject: [PATCH 035/112] perf(ark): route decode-sized int4-sym moe through the asym scalar GEMV Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 59 ++++++++++++++++--- .../ark/test/README_MOE_PREFILL_PERF.md | 15 +++++ .../ark/test/README_MOE_PREFILL_PERF_CN.md | 13 ++++ auto_round_extension/ark/test/test_moe.py | 6 +- .../ark/test/test_moe_decode_perf.py | 26 +++++--- 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 2aa6300ab6..c0a9865d28 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1025,8 +1025,10 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh // // Setting the var to "0" / "false" / "off" / "no" (case-insensitive) forces // the legacy scalar GEMV, for A/B comparison and regression escape. Asym -// weights and shapes that fail the DPAS shape gate always fall back to the -// scalar path regardless of this flag. Re-read on every call so tests / +// weights, shapes that fail the DPAS shape gate, and batches that fail the +// tokens-per-expert occupancy gate (`moe_decode_dpas_s4_occupancy_ok`, see +// below -- this is what keeps real decode batches on the fast scalar GEMV) +// always fall back to the scalar path regardless of this flag. Re-read on every call so tests / // benchmarks can toggle the path in-process. // ---------------------------------------------------------------------------- inline bool moe_decode_dpas_s4_enabled() { @@ -1038,10 +1040,43 @@ inline bool moe_decode_dpas_s4_enabled() { return true; } +// ---------------------------------------------------------------------------- +// Occupancy gate for the int4-sym S4 DPAS decode path. +// +// The DPAS grouped GEMM pays off only when its M tile is actually filled: the +// smallest tile (`dpas_w4a16_policy_m_8`) processes 8 token rows per expert, so +// with fewer than 8 tokens routed to an expert on average the tile is mostly +// padding and the (bandwidth-bound) packed weights are streamed for rows that +// contribute nothing. Real decode batches are exactly that regime -- e.g. +// MiniMax-M2 decode is 8 tokens (bs1) or 256 tokens (bs32) spread over 192 +// experts, i.e. 0.04-1.3 tokens per expert -- and there the shared scalar GEMV +// (`launch_int4`, the very kernel the *asym* path uses, where sym is just +// `Asym=false`) is up to ~3x faster because it reads each weight byte exactly +// once per active token with no tile padding. +// +// So route int4-sym decode through the same scalar GEMV as int4-asym unless the +// batch has at least one full 8-row tile of tokens per expert on average. +// `ARK_MOE_DECODE_DPAS_S4_MIN_TPE` overrides the tokens-per-expert threshold; +// "0" disables the gate (always take DPAS when the shape gate allows), which is +// what the accuracy tests use to exercise the DPAS kernel on tiny shapes. +// ---------------------------------------------------------------------------- +inline bool moe_decode_dpas_s4_occupancy_ok(int total_tokens, int num_experts) { + if (num_experts <= 0) return true; + long long min_tokens_per_expert = 8; // rows in `dpas_w4a16_policy_m_8` + const char* env = std::getenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE"); + if (env != nullptr) { + char* end = nullptr; + long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 0) min_tokens_per_expert = v; + } + if (min_tokens_per_expert == 0) return true; + return static_cast(total_tokens) >= min_tokens_per_expert * static_cast(num_experts); +} + // ---------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_DECODE_COALESCE_INT4` (default ON). When ON, the // int4 scalar-GEMV fallback (asym, or sym with the DPAS path disabled / shape -// gate miss) uses `launch_int4_coalesced`, which repacks the weights on-device +// or occupancy gate miss) uses `launch_int4_coalesced`, which repacks the weights on-device // into an N-tiled layout so sub-group weight loads are coalesced. Setting the // var to "0" / "false" / "off" / "no" (case-insensitive) forces the legacy // per-lane-strided `launch_int4`, for A/B comparison and regression escape. @@ -1094,10 +1129,12 @@ inline bool moe_decode_coalesce_int4_amortized(int total_tokens, int num_experts // scales [E, N, K/group_size] in act dtype, // zeros optional (asym==true requires it). // Sym weights are routed to the shared -// per-group S4 DPAS grouped GEMM by default -// (`ARK_MOE_DECODE_DPAS_S4`, default ON); -// asym, a disabled flag, or a shape-gate -// miss falls back to the scalar GEMV. +// per-group S4 DPAS grouped GEMM only when +// the batch fills its M tile (>= 8 tokens per +// expert on average, `ARK_MOE_DECODE_DPAS_S4` +// default ON); asym, a disabled flag, a +// shape-gate miss, or a decode-sized batch +// uses the shared scalar GEMV. // BTLA_DTYPE::S2_CLIP : packed int2 weights [E, N, K/4] (uint8), // 4 values per byte, sym/asym like int4 // BTLA_DTYPE::F8_E4M3 / F8_E5M2 : FP8 weights [E, N, K] (uint8 buffer), @@ -1114,6 +1151,7 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo // every other path (fp, int8, int2, fp8, and the scalar int4 fallback) still // needs the per-token expert mapping. const bool s4_dpas_fastpath = weight_dtype == BTLA_DTYPE::S4_CLIP && !asym && moe_decode_dpas_s4_enabled() && + moe_decode_dpas_s4_occupancy_ok(total_tokens, num_experts) && moe_dpas_s4::moe_prefill_dpas_s4_pergroup_shape_ok(N, K, group_size); if (!s4_dpas_fastpath) { moe_decode_detail::fill_expert_id_per_token(q, expert_id_per_token_buf, num_tokens_per_expert, num_experts, @@ -1144,8 +1182,11 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo } // Fast path: sym int4 through the shared per-group S4 DPAS grouped GEMM. // Falls back to the scalar GEMV for asym weights (DPAS S4 is sym-only), - // when the env flag is off, or when the shape gate rejects the tile - // geometry (e.g. N%64!=0, K%32!=0, unsupported group_size). Reuses the + // when the env flag is off, when the batch is too small to fill the DPAS M + // tile (the usual decode case -- sym then runs the exact same + // `launch_int4*` kernel as asym, with `Asym=false`), or when the shape gate + // rejects the tile geometry (e.g. N%64!=0, K%32!=0, unsupported + // group_size). Reuses the // `s4_dpas_fastpath` predicate computed above (which also gated the // `fill_expert_id_per_token` skip) so the two decisions cannot diverge. if (s4_dpas_fastpath) { diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index c7bb58ea76..592b3f0907 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -331,6 +331,21 @@ per-N scale (`sg_scale[]`, folded once per K-group), reading the same A/B comparison (numerically identical; only the tile shape differs). **Status: NEEDS-HARDWARE-VALIDATION** (untested port). +**Occupancy gate — decode-sized batches use the int4-asym kernel.** Even +the smallest DPAS tile processes 8 token rows per expert, so a batch with +fewer than 8 tokens per expert on average pays full weight-streaming cost +for mostly-padding rows. That is precisely the decode regime: on +MiniMax-M2 (192 experts) bs1 is 8 tokens and bs32 is 256 tokens, i.e. +0.04–1.3 tokens per expert, and measurements showed int4-sym (DPAS) at +0.31–0.34 ms/1.55 ms against int4-asym (scalar GEMV) at 0.12 ms/1.45 ms +for the same shapes. int4-sym decode is therefore routed to the *same* +scalar GEMV kernel that int4-asym uses (`launch_int4` / its coalesced +variant, with `Asym=false` — sym is cheaper there because it skips the +zero-point fold) unless the batch supplies at least 8 tokens per expert. +`ARK_MOE_DECODE_DPAS_S4_MIN_TPE` overrides the tokens-per-expert +threshold; `0` disables the gate (always DPAS when the shape gate allows), +which is what the accuracy and DPAS-vs-scalar perf tests set. + Accuracy parity is covered by `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group`, which forces `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 1c7d00d8a2..8070ff2230 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -258,6 +258,19 @@ vLLM-xpu-kernels 的 `w4a16` decode dispatch。它与 prefill 使用相同的 对比(数值完全相同,仅 tile 形状不同)。**状态:NEEDS-HARDWARE-VALIDATION** (未经测试的移植)。 +**占用率门控 — decode 规模的 batch 直接复用 int4-asym 的实现。** 即使是 +最小的 DPAS tile 也要处理每个专家 8 行 token,因此平均每个专家不足 8 个 +token 的 batch 会为几乎全是 padding 的行付出完整的权重流式加载代价。 +decode 正好处于这一区间:MiniMax-M2(192 个专家)bs1 只有 8 个 token, +bs32 只有 256 个 token,即平均每个专家 0.04–1.3 个 token;实测同样形状下 +int4-sym(DPAS)为 0.31–0.34 ms / 1.55 ms,而 int4-asym(标量 GEMV)为 +0.12 ms / 1.45 ms。因此除非 batch 平均每个专家至少有 8 个 token,int4-sym +的 decode 会被路由到与 int4-asym *完全相同* 的标量 GEMV kernel +(`launch_int4` 及其 coalesced 变体,`Asym=false` — sym 少了 zero-point +折叠,反而更快)。`ARK_MOE_DECODE_DPAS_S4_MIN_TPE` 可覆盖该 +"每专家 token 数" 阈值;设为 `0` 则关闭门控(只要形状门控通过就走 DPAS), +精度测试与 DPAS/标量 对比性能测试即使用该设置。 + 精度对齐由 `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group` 覆盖,该用例强制 `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index a57f87b089..77b76c0575 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -489,7 +489,10 @@ def test_decode_int4_sym_dpas_matches_scalar(self, monkeypatch, dtype, group_siz the dequant->bmm reference within quantization tolerance. Shapes satisfy the DPAS per-group shape gate (N%64==0, K%32==0, - group_size in {32,64,128,256}) so the fast path is actually taken. + group_size in {32,64,128,256}) and ``ARK_MOE_DECODE_DPAS_S4_MIN_TPE=0`` + disables the tokens-per-expert occupancy gate (these tiny token counts + would otherwise be routed to the scalar GEMV, which is faster there) so + the DPAS fast path is actually taken. """ num_experts = 4 total_tokens = sum(tokens_per_expert) @@ -516,6 +519,7 @@ def _run(): ) monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", "0") monkeypatch.setenv("ARK_MOE_DECODE_S4_DPAS_M8", "1") out_dpas = _run() diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index 584287528d..26641cc95c 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -403,12 +403,17 @@ def test_perf_int4_sym_dpas_vs_scalar(self, monkeypatch, dtype): ``speedup`` here is ``scalar / dpas`` (the DPAS path is the "ark" column), isolating the DPAS routing win from the dequant reference. Only shapes that clear the DPAS shape gate are timed on both paths. - - Observed crossover: at bs1 (8 tokens, ~1 token/expert) the DPAS tile is - nearly empty and the scalar GEMV wins (~0.5x), but by bs32 (256 tokens) - the shared S4 DPAS grouped-GEMM already wins ~2x. The auto-dispatch - default threshold (`_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS`) is therefore kept - small so 256-token batches are routed to prefill/DPAS, not decode. + ``ARK_MOE_DECODE_DPAS_S4_MIN_TPE=0`` disables the tokens-per-expert + occupancy gate so the DPAS column really runs DPAS (by default these + decode-sized batches are routed to the scalar GEMV). + + Observed on MiniMax-M2 decode shapes (192 experts): the DPAS tile is + starved at every decode batch measured -- 8 tokens (bs1, 0.04 + tokens/expert) and 256 tokens (bs32, 1.3 tokens/expert) are both far + below the 8 rows of `dpas_w4a16_policy_m_8` -- and the scalar GEMV wins, + which is why the default occupancy gate keeps decode on the scalar path + (the same kernel int4-asym uses) until a batch supplies >= 8 tokens per + expert. """ group_size = 128 _print_header( @@ -439,7 +444,9 @@ def _run(): monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") scalar_ms = _xpu_time_ms(_run) monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", "0") dpas_ms = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", raising=False) _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @@ -454,7 +461,10 @@ def test_perf_int4_sym_dpas_vs_scalar_threshold(self, monkeypatch, dtype): crosses 1.0x at the total-token count where the shared S4 DPAS grouped-GEMM starts beating the scalar GEMV, which is the value to feed into ``ARK_MOE_AUTO_DECODE_MAX_TOKENS`` / the ``moe(...)`` - ``decode_threshold`` auto-dispatch cutoff. + ``decode_threshold`` auto-dispatch cutoff, and is also the sweep behind + the default ``ARK_MOE_DECODE_DPAS_S4_MIN_TPE`` occupancy gate (set to + ``0`` here so the DPAS column is not itself re-routed to the scalar + GEMV). """ group_size = 32 E = 192 @@ -488,7 +498,9 @@ def _run(): monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") scalar_ms = _xpu_time_ms(_run) monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", "0") dpas_ms = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", raising=False) _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) From 211e95f8238b1116ef2f02f8b8432b423a74493f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:55:11 +0000 Subject: [PATCH 036/112] perf(ark): decode int4-sym nibbles through the asym unsigned path (^0x88 sign-flip) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 105 +++++++++++------- .../ark/test/README_MOE_PREFILL_PERF.md | 21 +++- .../ark/test/README_MOE_PREFILL_PERF_CN.md | 17 ++- 3 files changed, 97 insertions(+), 46 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index c0a9865d28..72121cebbd 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -240,18 +240,36 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight // without regressing group_size == 16 (which drops straight to the 16-wide // stage). // -// The per-group scale (and asym zero-point) is NOT applied here: this -// accumulates the raw integer-weighted dot product ``Σ a·q`` into ``acc_q`` -// and, for the asym case, the plain activation sum ``Σ a`` into ``acc_a``. -// The caller folds the group's scale/zero in once (Σ a·((q−z)·s) == -// s·(Σ a·q − z·Σ a); sym collapses to s·Σ a·q). Hoisting the scale removes one -// float multiply per K element on the decode hot path, and because the scale -// fold is exact-once per group the result stays well within the kernel's -// existing quantization tolerance. +// Both sym and asym decode the nibbles through the *same* unsigned path. +// Measurements on MiniMax-M2 decode shapes showed int4-sym ~1.9x slower than +// int4-asym in this very kernel even though sym does strictly fewer floating +// point operations. The only difference was the per-nibble sign extension +// (`(int8_t)(byte << 4) >> 4`) -- a serial shift/narrow/shift chain per nibble +// that defeats the byte-wise vectorization the asym mask+shift form gets. The +// sym decode is therefore expressed with the standard sign-flip identity +// +// signed_nibble == (unsigned_nibble ^ 8) - 8 +// +// so XOR-ing the packed byte with `0x88` (flipping the sign bit of *both* +// nibbles at once, on the whole loaded vector register) turns sym into exactly +// the asym computation with a constant zero-point of 8. The decoded integers +// are bit-identical to the sign-extending decode for all 256 byte values, so +// the only change is that sym now accumulates the biased sum and subtracts +// `8 * sum a` at the end -- exactly the fp32 accumulation pattern asym has +// always used, and well inside the kernel's existing quantization tolerance. +// +// The per-group scale and zero-point are NOT applied here: this accumulates the +// raw integer-weighted dot product into `acc_q0`/`acc_q1` and the plain +// activation sum into `acc_a` (now needed by both modes, since sym carries the +// constant zero-point of 8). The caller folds the group's scale/zero in once +// (sum a*((q-z)*s) == s*(sum a*q - z*sum a)). Hoisting the scale removes one +// float multiply per K element on the decode hot path, and because the fold is +// exact-once per group the result stays well within the kernel's existing +// quantization tolerance. // // Two independent partial accumulators (``acc_q0``/``acc_q1``) break the // single fp32 dependency chain so the FMA pipeline is not latency-bound; the -// caller reduces the pair. ``acc_a`` (asym only) reuses the same split. +// caller reduces the pair. ``acc_a`` reuses the same split. template static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc_q0, float& acc_q1, float& acc_a) { @@ -267,20 +285,26 @@ static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_pt #pragma unroll for (int s = 0; s < CHUNK / SUB; ++s) { const ActVec av = *reinterpret_cast(act_ptr + s * SUB); - const PackVec pv = *reinterpret_cast(w_ptr + s * (SUB / 2)); + PackVec pv = *reinterpret_cast(w_ptr + s * (SUB / 2)); + if constexpr (!Asym) { + // Sign-flip the whole packed vector in one vector XOR so the sym nibbles + // can be decoded by the (vectorizable) unsigned path below; the constant + // zero-point of 8 is folded by the caller. + pv = pv ^ PackVec(static_cast(0x88)); + } #pragma unroll for (int b = 0; b < SUB / 2; ++b) { int q0, q1; - decode_int4_pair(pv[b], q0, q1); + // Always the unsigned decode: asym nibbles are unsigned by definition and + // sym nibbles were biased by the XOR above. + decode_int4_pair(pv[b], q0, q1); const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); const float fa0 = static_cast(a0); const float fa1 = static_cast(a1); acc_q0 += fa0 * static_cast(q0); acc_q1 += fa1 * static_cast(q1); - if constexpr (Asym) { - acc_a += fa0 + fa1; - } + acc_a += fa0 + fa1; } } } @@ -329,7 +353,10 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig float acc = 0.0f; for (int g = 0; g < num_groups_k; ++g) { const float scale = static_cast(s_row[g]); - float zero = 0.0f; + // Sym uses the constant zero-point of 8 that the `^0x88` sign-flip + // in the decode introduces, so both modes run the identical fold. + // `if constexpr` keeps the null `z_row` out of the sym instantiation. + float zero = 8.0f; if constexpr (Asym) { zero = static_cast(z_row[g]); } @@ -342,13 +369,14 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // (32/64/128/256), while the 16-wide stage keeps group_size == 16 // on the fast path. // - // The scale (and asym zero) is constant across the group, so the - // wide stages accumulate the raw integer-weighted dot product + // The scale and zero are constant across the group, so the wide + // stages accumulate the raw integer-weighted dot product // ``Σ a·q`` (split across two partial accumulators to break the - // fp32 dependency chain) plus ``Σ a`` for asym, and the fold below - // applies the scale/zero exactly once per group: - // sym : acc += scale * (acc_q0 + acc_q1) - // asym: acc += scale * ((acc_q0 + acc_q1) - zero * acc_a) + // fp32 dependency chain) plus ``Σ a``, and the fold below applies + // the scale/zero exactly once per group, identically for both modes: + // acc += scale * ((acc_q0 + acc_q1) - zero * acc_a) + // with `zero` == the per-group zero-point (asym) or the constant 8 + // that the `^0x88` sign-flip decode introduces (sym). float acc_q0 = 0.0f; float acc_q1 = 0.0f; float acc_a = 0.0f; @@ -369,22 +397,17 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // raw-accumulation convention as the wide stages so the single // scale/zero fold below stays valid. for (; kk < group_size; kk += 2) { - const uint8_t packed = w_row[(k_base + kk) / 2]; + uint8_t packed = w_row[(k_base + kk) / 2]; + if constexpr (!Asym) packed ^= static_cast(0x88); int q0, q1; - decode_int4_pair(packed, q0, q1); + decode_int4_pair(packed, q0, q1); const float fa0 = static_cast(act_row[k_base + kk]); const float fa1 = static_cast(act_row[k_base + kk + 1]); acc_q0 += fa0 * static_cast(q0); acc_q1 += fa1 * static_cast(q1); - if constexpr (Asym) { - acc_a += fa0 + fa1; - } + acc_a += fa0 + fa1; } - float group_dot = acc_q0 + acc_q1; - if constexpr (Asym) { - group_dot -= zero * acc_a; - } - acc += scale * group_dot; + acc += scale * ((acc_q0 + acc_q1) - zero * acc_a); } outputs[static_cast(token) * N + n_global] = static_cast(acc); @@ -552,7 +575,10 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin for (int g = 0; g < num_groups_k; ++g) { const float scale = static_cast(s_row[g]); - float zero = 0.0f; + // Constant zero-point of 8 for sym (see `int4_decode_chunk`): the + // `^0x88` sign-flip lets sym reuse the asym unsigned decode and + // fold, so both modes emit the identical instruction stream. + float zero = 8.0f; if constexpr (Asym) { zero = static_cast(z_row[g]); } @@ -573,9 +599,10 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin const int kb_base = k_base / 2; const int kb_count = group_size / 2; for (int kb = 0; kb < kb_count; ++kb) { - const uint8_t packed = w_tile[(kb_base + kb) * N_TILE + lane]; + uint8_t packed = w_tile[(kb_base + kb) * N_TILE + lane]; + if constexpr (!Asym) packed ^= static_cast(0x88); int q0, q1; - decode_int4_pair(packed, q0, q1); + decode_int4_pair(packed, q0, q1); const float fq0 = static_cast(q0); const float fq1 = static_cast(q1); const int k0 = k_base + 2 * kb; @@ -585,17 +612,11 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin const float fa1 = static_cast(act_row[k0 + 1]); acc_q0[m] += fa0 * fq0; acc_q1[m] += fa1 * fq1; - if constexpr (Asym) { - acc_a[m] += fa0 + fa1; - } + acc_a[m] += fa0 + fa1; } } for (int m = 0; m < nmembers; ++m) { - float group_dot = acc_q0[m] + acc_q1[m]; - if constexpr (Asym) { - group_dot -= zero * acc_a[m]; - } - acc[m] += scale * group_dot; + acc[m] += scale * ((acc_q0[m] + acc_q1[m]) - zero * acc_a[m]); } } diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index 592b3f0907..435ae28392 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -340,12 +340,29 @@ MiniMax-M2 (192 experts) bs1 is 8 tokens and bs32 is 256 tokens, i.e. 0.31–0.34 ms/1.55 ms against int4-asym (scalar GEMV) at 0.12 ms/1.45 ms for the same shapes. int4-sym decode is therefore routed to the *same* scalar GEMV kernel that int4-asym uses (`launch_int4` / its coalesced -variant, with `Asym=false` — sym is cheaper there because it skips the -zero-point fold) unless the batch supplies at least 8 tokens per expert. +variant, with `Asym=false`) unless the batch supplies at least 8 tokens per +expert. `ARK_MOE_DECODE_DPAS_S4_MIN_TPE` overrides the tokens-per-expert threshold; `0` disables the gate (always DPAS when the shape gate allows), which is what the accuracy and DPAS-vs-scalar perf tests set. +**Sym decodes through the asym nibble path.** Once both modes shared the +scalar GEMV, int4-sym was still ~1.9x slower than int4-asym in the *same* +kernel (2.83 ms vs 1.49 ms at bs32) despite doing strictly fewer floating +point operations. The only asymmetry was the nibble decode: sym +sign-extended each nibble with `(int8_t)(byte << 4) >> 4`, a serial +shift/narrow/shift chain per nibble, while asym used a plain mask+shift +that vectorizes over the whole loaded byte vector. Sym now uses the +sign-flip identity `signed == (unsigned ^ 8) - 8`: one vector XOR of the +packed bytes with `0x88` flips both nibbles' sign bits, after which sym is +*literally* the asym computation with a constant zero-point of 8 (same +unsigned decode, same `sum a` accumulator, same single per-group +scale/zero fold). The decoded integers are bit-identical to the previous +sign-extending decode for all 256 byte values; the only change is that +sym now accumulates the biased sum and subtracts `8 * sum a` at the end, +which is exactly the fp32 accumulation pattern asym has always used. It +applies to both `launch_int4` and `launch_int4_coalesced`. + Accuracy parity is covered by `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group`, which forces `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 8070ff2230..8e43d7aae4 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -266,11 +266,24 @@ bs32 只有 256 个 token,即平均每个专家 0.04–1.3 个 token;实测同 int4-sym(DPAS)为 0.31–0.34 ms / 1.55 ms,而 int4-asym(标量 GEMV)为 0.12 ms / 1.45 ms。因此除非 batch 平均每个专家至少有 8 个 token,int4-sym 的 decode 会被路由到与 int4-asym *完全相同* 的标量 GEMV kernel -(`launch_int4` 及其 coalesced 变体,`Asym=false` — sym 少了 zero-point -折叠,反而更快)。`ARK_MOE_DECODE_DPAS_S4_MIN_TPE` 可覆盖该 +(`launch_int4` 及其 coalesced 变体,`Asym=false`)。`ARK_MOE_DECODE_DPAS_S4_MIN_TPE` 可覆盖该 "每专家 token 数" 阈值;设为 `0` 则关闭门控(只要形状门控通过就走 DPAS), 精度测试与 DPAS/标量 对比性能测试即使用该设置。 +**sym 直接复用 asym 的 nibble 解码路径。** 在两者都走标量 GEMV 之后, +int4-sym 在 *同一个* kernel 里仍比 int4-asym 慢约 1.9 倍(bs32:2.83 ms +vs 1.49 ms),尽管 sym 的浮点运算严格更少。唯一的差异在于 nibble 解码: +sym 使用 `(int8_t)(byte << 4) >> 4` 逐 nibble 做符号扩展,这是一条 +移位/截断/移位 的串行依赖链;而 asym 的 掩码+移位 形式可以在整个已加载 +的字节向量上向量化。现在 sym 改用符号翻转恒等式 +`signed == (unsigned ^ 8) - 8`:对打包字节做一次向量 `^ 0x88`,同时翻转 +两个 nibble 的符号位,之后 sym *就是* zero-point 恒为 8 的 asym 计算 +(相同的无符号解码、相同的 `sum a` 累加器、相同的每组一次 scale/zero +折叠)。对全部 256 种字节取值,解码出的整数与原来的符号扩展逐位相同, +唯一的变化是 sym 现在累加的是有偏置的和,最后再减去 `8 * sum a`, +这正是 asym 一直在用的 fp32 累加方式;`launch_int4` 与 `launch_int4_coalesced` +两个 kernel 均已应用。 + 精度对齐由 `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group` 覆盖,该用例强制 `ARK_MOE_PREFILL_DPAS_S4=1` + From e4d25c5d60679c983e4a10d9910c65d1ed30c8ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:28:51 +0000 Subject: [PATCH 037/112] perf(ark): vectorize int4 decode GEMV loads, hoist activation sums, pool the repack buffer Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 16 + .../ark/auto_round_kernel/ark.cpp | 1 + .../wrapper/include/sycl_tla_moe_decode.hpp | 624 +++++++++++++----- 3 files changed, 464 insertions(+), 177 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index 69147abc2f..0a485bd7c8 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -1670,6 +1670,22 @@ def moe_gemm_decode( return outputs +def moe_decode_release_scratch() -> None: + """Release the device scratch buffers held by the int4 decode fallbacks. + + :func:`moe_gemm_decode` serves its int4 weight-repack and activation-sum + buffers from grow-on-demand per-queue slabs that are kept for the lifetime + of the process so the decode hot path never allocates. Call this to hand + that memory back, or to drop a repack cached via + ``ARK_MOE_DECODE_INT4_REPACK_CACHE=1`` before the underlying weight tensor + is freed. A no-op when the XPU extension is not loaded. + """ + lib = xpu_lib + if lib is None or not hasattr(lib, "moe_decode_release_scratch"): + return + lib.moe_decode_release_scratch() + + def _validate_moe_quant_args( activations: torch.Tensor, weights: torch.Tensor, diff --git a/auto_round_extension/ark/auto_round_kernel/ark.cpp b/auto_round_extension/ark/auto_round_kernel/ark.cpp index d9c9c7c03f..dc701e0dad 100755 --- a/auto_round_extension/ark/auto_round_kernel/ark.cpp +++ b/auto_round_extension/ark/auto_round_kernel/ark.cpp @@ -800,6 +800,7 @@ PYBIND11_MODULE(PY_NAME, m) { m.def("sage_dynamic_quant_v_layout", &ark::sage_dynamic_quant_v_layout); m.def("moe_gemm", &ark::moe_gemm_wrapper); m.def("moe_gemm_decode", &ark::moe_gemm_decode_wrapper); + m.def("moe_decode_release_scratch", &ark::moe_decode_release_scratch); m.def("moe_gemm_prefill", &ark::moe_gemm_prefill_wrapper); m.def("moe_gemm_prefill_fp8_dpas", &ark::moe_gemm_prefill_fp8_dpas_wrapper); m.def("moe_gemm_prefill_int_dpas", &ark::moe_gemm_prefill_int_dpas_wrapper); diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 72121cebbd..bc128bda1d 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -13,9 +13,11 @@ // - weights (int4 packed): [num_experts, N, K/2] row-major, two // 4-bit values per byte (low nibble at lower K). // The scalar-GEMV fallback repacks this on-device -// into an N-tiled [E, N/16, K/2, 16] layout so that -// sub-group weight loads are coalesced; the external -// [E, N, K/2] contract is unchanged. +// into an N-tiled [E, N/16, ceil(K/8), 16, 4] +// layout so that sub-group weight loads are +// coalesced *and* each lane loads 4 packed bytes at +// a time; the external [E, N, K/2] contract is +// unchanged. // - weights (int2 packed): [num_experts, N, K/4] row-major, four // 2-bit values per byte (field j at K index // 4*i+j is bits [2j+1:2j]) @@ -40,6 +42,8 @@ #include #include #include +#include +#include #include #include @@ -107,6 +111,9 @@ class MoEDecodeKernelInt4Coalesced; template class MoEDecodeRepackInt4; +template +class MoEDecodeActGroupSum; + template class MoEDecodeKernelInt8; @@ -221,6 +228,168 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight }); } +// ---------------------------------------------------------------------------- +// Persistent per-queue device scratch pool. +// +// The int4 decode fallbacks need two device-side scratch buffers: the N-tiled +// weight repack and the per-(token, K-group) activation-sum table. Allocating +// them with `sycl::malloc_device` on every call is not viable on the decode hot +// path -- decode issues one call per generated token, and a USM allocation +// (plus the `queue::wait()` that has to precede the matching `sycl::free`) +// costs on the order of the GEMV itself. Instead each buffer is served from a +// slab that is allocated once per queue and grown on demand, so steady-state +// decode performs no allocation and needs no host-side synchronization: the +// in-order queue already serializes the producer kernel before the consumer, +// which is the same ordering guarantee `fill_expert_id_per_token` relies on. +// +// A slab additionally carries an optional *tag* -- the address of the source +// buffer it was derived from plus a caller-supplied key that must fold in +// everything else the derived contents depend on (shape, layout parameters). +// `acquire` reports whether the slab already holds the result for that exact +// tag, which lets the caller skip regenerating it. This is only consulted when +// the caller opts in (see `moe_decode_int4_repack_cache_enabled`), because the +// address half of a tag is a pointer identity and a freed-then-reallocated +// buffer can land on the same address. +// +// Slabs are intentionally never freed from a static destructor: the SYCL +// context may already be torn down at that point. `release_all` provides +// explicit teardown for callers that need it (exposed to Python as +// `moe_decode_release_scratch`). +// ---------------------------------------------------------------------------- +class DeviceScratchPool { + public: + uint8_t* acquire(sycl::queue* q, size_t bytes, const void* tag_ptr, size_t tag_key, bool use_tag, + bool* tag_hit) { + std::lock_guard lock(mu_); + Slab& slab = slabs_[q]; + if (slab.ptr == nullptr || slab.bytes < bytes) { + if (slab.ptr != nullptr) { + // The old slab may still be referenced by in-flight kernels. + q->wait(); + sycl::free(slab.ptr, *q); + slab = Slab{}; + } + uint8_t* p = sycl::malloc_device(bytes, *q); + if (p == nullptr) { + throw std::runtime_error("moe_gemm_decode: failed to allocate device scratch buffer"); + } + slab.ptr = p; + slab.bytes = bytes; + } + const bool hit = use_tag && slab.tagged && slab.tag_ptr == tag_ptr && slab.tag_key == tag_key; + if (tag_hit != nullptr) *tag_hit = hit; + if (!hit) { + slab.tagged = use_tag; + slab.tag_ptr = tag_ptr; + slab.tag_key = tag_key; + } + return slab.ptr; + } + + uint8_t* acquire(sycl::queue* q, size_t bytes) { + return acquire(q, bytes, nullptr, 0, false, nullptr); + } + + void release_all() { + std::lock_guard lock(mu_); + for (auto& kv : slabs_) { + if (kv.second.ptr != nullptr) { + kv.first->wait(); + sycl::free(kv.second.ptr, *kv.first); + } + } + slabs_.clear(); + } + + private: + struct Slab { + uint8_t* ptr = nullptr; + size_t bytes = 0; + bool tagged = false; + const void* tag_ptr = nullptr; + size_t tag_key = 0; + }; + std::mutex mu_; + std::map slabs_; +}; + +inline DeviceScratchPool& int4_repack_pool() { + static DeviceScratchPool pool; + return pool; +} + +inline DeviceScratchPool& act_group_sum_pool() { + static DeviceScratchPool pool; + return pool; +} + +// ---------------------------------------------------------------------------- +// Per-(token, K-group) activation sums. +// +// Both int4 GEMV kernels fold their per-group scale/zero as +// `scale * (Σ a·q - zero · Σ a)`, where `Σ a` runs over the group's K range. +// `Σ a` depends only on the activation row and the group, *not* on the output +// column, yet the GEMVs used to recompute it inside the inner loop -- once per +// sub-group lane (16x redundant) and again for every N-tile work-group (N/16x +// redundant). That cost one extra float add per K element on the hot path, +// which for sym is pure overhead introduced by the constant zero-point of 8 +// that the `^0x88` decode relies on. +// +// This pass computes the `[total_tokens, K/group_size]` table once, so the +// GEMVs only accumulate `Σ a·q` and read one float per group. The table is +// tiny (tokens x groups floats) and comes from the scratch pool, so no +// allocation happens in steady state. +// +// The summation order differs from the previous in-loop accumulation, so +// results move by a few float ULPs -- far inside the kernel's quantization +// tolerance. +// ---------------------------------------------------------------------------- +template +void launch_act_group_sums(sycl::queue* q, const ScalarT* activations, float* a_sums, int total_tokens, int K, + int group_size) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + const int num_groups_k = K / group_size; + q->parallel_for>( + sycl::range<2>(static_cast(total_tokens), static_cast(num_groups_k)), + [=](sycl::id<2> id) { + const int token = static_cast(id[0]); + const int g = static_cast(id[1]); + const ScalarT* row = activations + static_cast(token) * K + static_cast(g) * group_size; + // Split accumulators + a 16-wide vector load, mirroring the GEMV's own + // activation access pattern. + float s0 = 0.0f; + float s1 = 0.0f; + constexpr int SUB = 16; + using ActVec = sycl::vec; + int k = 0; + const int end = (group_size / SUB) * SUB; + for (; k < end; k += SUB) { + const ActVec av = *reinterpret_cast(row + k); +#pragma unroll + for (int u = 0; u < SUB; u += 2) { + s0 += static_cast(sycl::bit_cast(static_cast(av[u]))); + s1 += static_cast(sycl::bit_cast(static_cast(av[u + 1]))); + } + } + for (; k < group_size; ++k) { + s0 += static_cast(row[k]); + } + a_sums[static_cast(token) * num_groups_k + g] = s0 + s1; + }); +} + +// Convenience wrapper: fetch the activation-sum table from the scratch pool and +// (re)compute it for this call's activations. +template +float* compute_act_group_sums(sycl::queue* q, const ScalarT* activations, int total_tokens, int K, + int group_size) { + const int num_groups_k = K / group_size; + const size_t bytes = static_cast(total_tokens) * static_cast(num_groups_k) * sizeof(float); + float* a_sums = reinterpret_cast(act_group_sum_pool().acquire(q, bytes)); + launch_act_group_sums(q, activations, a_sums, total_tokens, K, group_size); + return a_sums; +} + // ---------------------------------------------------------------------------- // INT4 (S4_CLIP) GEMV with group-wise dequantization. // @@ -259,20 +428,22 @@ void launch_fp(sycl::queue* q, const ScalarT* activations, const ScalarT* weight // always used, and well inside the kernel's existing quantization tolerance. // // The per-group scale and zero-point are NOT applied here: this accumulates the -// raw integer-weighted dot product into `acc_q0`/`acc_q1` and the plain -// activation sum into `acc_a` (now needed by both modes, since sym carries the -// constant zero-point of 8). The caller folds the group's scale/zero in once +// raw integer-weighted dot product into `acc_q0`/`acc_q1`. The plain activation +// sum `Σ a` that the fold also needs (for both modes -- sym carries the +// constant zero-point of 8) is *not* accumulated here either: it is independent +// of the output column and is precomputed once per (token, K-group) by +// `launch_act_group_sums`. The caller folds the group's scale/zero in once // (sum a*((q-z)*s) == s*(sum a*q - z*sum a)). Hoisting the scale removes one -// float multiply per K element on the decode hot path, and because the fold is -// exact-once per group the result stays well within the kernel's existing -// quantization tolerance. +// float multiply per K element on the decode hot path, hoisting `Σ a` removes +// one float add per K element, and because the fold is exact-once per group the +// result stays well within the kernel's existing quantization tolerance. // // Two independent partial accumulators (``acc_q0``/``acc_q1``) break the // single fp32 dependency chain so the FMA pipeline is not latency-bound; the -// caller reduces the pair. ``acc_a`` reuses the same split. +// caller reduces the pair. template -static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc_q0, float& acc_q1, - float& acc_a) { +static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc_q0, + float& acc_q1) { static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); // sycl::vec only supports widths of 1, 2, 3, 4, 8 or 16, so a single @@ -304,7 +475,6 @@ static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_pt const float fa1 = static_cast(a1); acc_q0 += fa0 * static_cast(q0); acc_q1 += fa1 * static_cast(q1); - acc_a += fa0 + fa1; } } } @@ -328,6 +498,10 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig const int num_groups_k = K / group_size; const int k_packed = K / 2; // bytes of packed weight per (expert, n) + // Per-(token, K-group) activation sums, shared by every lane and every + // N-tile instead of being recomputed inside the inner loop. + const float* a_sums = compute_act_group_sums(q, activations, total_tokens, K, group_size); + sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; sycl::range<2> local{1, static_cast(SG_SIZE)}; @@ -341,6 +515,7 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig const int expert = expert_id_per_token[token]; const ScalarT* act_row = activations + static_cast(token) * K; + const float* a_sum_row = a_sums + static_cast(token) * num_groups_k; const uint8_t* w_row = weights + (static_cast(expert) * N + static_cast(n_global)) * k_packed; @@ -370,28 +545,28 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // on the fast path. // // The scale and zero are constant across the group, so the wide - // stages accumulate the raw integer-weighted dot product + // stages accumulate only the raw integer-weighted dot product // ``Σ a·q`` (split across two partial accumulators to break the - // fp32 dependency chain) plus ``Σ a``, and the fold below applies - // the scale/zero exactly once per group, identically for both modes: - // acc += scale * ((acc_q0 + acc_q1) - zero * acc_a) + // fp32 dependency chain); ``Σ a`` comes from the precomputed + // per-(token, group) table. The fold below applies the scale/zero + // exactly once per group, identically for both modes: + // acc += scale * ((acc_q0 + acc_q1) - zero * a_sum) // with `zero` == the per-group zero-point (asym) or the constant 8 // that the `^0x88` sign-flip decode introduces (sym). float acc_q0 = 0.0f; float acc_q1 = 0.0f; - float acc_a = 0.0f; int kk = 0; constexpr int CHUNK32 = 32; const int end32 = (group_size / CHUNK32) * CHUNK32; for (; kk < end32; kk += CHUNK32) { int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, acc_q0, - acc_q1, acc_a); + acc_q1); } constexpr int CHUNK16 = 16; const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; for (; kk < end16; kk += CHUNK16) { int4_decode_chunk(act_row + k_base + kk, w_row + (k_base + kk) / 2, acc_q0, - acc_q1, acc_a); + acc_q1); } // Scalar tail for group_size not divisible by 16. Uses the same // raw-accumulation convention as the wide stages so the single @@ -405,15 +580,39 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig const float fa1 = static_cast(act_row[k_base + kk + 1]); acc_q0 += fa0 * static_cast(q0); acc_q1 += fa1 * static_cast(q1); - acc_a += fa0 + fa1; } - acc += scale * ((acc_q0 + acc_q1) - zero * acc_a); + acc += scale * ((acc_q0 + acc_q1) - zero * a_sum_row[g]); } outputs[static_cast(token) * N + n_global] = static_cast(acc); }); } +// ---------------------------------------------------------------------------- +// Opt-in reuse of the int4 weight repack across calls. +// +// The repack output depends only on the weight buffer, which does not change +// between decode steps of a real inference loop, so in principle it can be +// built once and reused. The pool tag is a *pointer identity*, though, and a +// freed-then-reallocated weight tensor can land on the address of the previous +// one (torch's caching allocator makes this common in test loops that build a +// fresh packed tensor of the same shape per iteration). Reusing a stale repack +// would then silently produce wrong results, so this is off by default and must +// be enabled explicitly by a caller that owns the weight lifetime: +// +// ARK_MOE_DECODE_INT4_REPACK_CACHE=1 +// +// `ark::moe_decode_release_scratch()` (exposed to Python as +// `moe_decode_release_scratch`) drops the cached buffers. +// ---------------------------------------------------------------------------- +inline bool moe_decode_int4_repack_cache_enabled() { + const char* env = std::getenv("ARK_MOE_DECODE_INT4_REPACK_CACHE"); + if (env == nullptr) return false; // default OFF -- see comment above + std::string s(env); + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + return !(s == "0" || s == "false" || s == "off" || s == "no"); +} + // ---------------------------------------------------------------------------- // INT4 (S4_CLIP) coalesced-load GEMV. // @@ -426,19 +625,32 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // step issues 16 scattered transactions instead of one contiguous cache line. // // This path fixes that by first repacking the weights on-device into an -// N-tiled layout `[E, N/16, K/2, 16]`: the trailing dim of 16 holds one packed -// byte for each of the 16 columns owned by a sub-group tile, so at a fixed -// packed-byte index the 16 lanes read 16 contiguous bytes -> a single coalesced -// load. The dequant math is byte-for-byte identical to `launch_int4` (same -// `decode_int4_pair`, same per-group scale/zero fold), only the weight memory -// access pattern changes. The repack buffer is a transient USM device -// allocation freed after the queue drains; the caller's `[E, N, K/2]` weight +// N-tiled, 4-byte-blocked layout `[E, N/16, ceil(K/8), 16, 4]`: a *chunk* holds +// four consecutive packed bytes for each of the 16 columns owned by a sub-group +// tile, lane-major. Lane `l` therefore reads its four bytes at chunk offset +// `l*4`, and the 16 lanes of a sub-group together cover 64 contiguous bytes -> +// still a single coalesced transaction, but now each lane issues one +// `vec` load instead of four separate byte loads. For sym the +// `^0x88` sign flip that lets the unsigned (vectorizable) nibble decode stand in +// for the sign-extending one also becomes a single vector XOR per chunk instead +// of one scalar XOR per byte -- the last remaining per-byte instruction sym paid +// over asym in this kernel. The dequant math is otherwise identical to +// `launch_int4` (same `decode_int4_pair`, same per-group scale/zero fold); only +// the weight memory layout changes, and the caller's `[E, N, K/2]` weight // contract is unchanged. // -// The trailing lane stride means each lane's own K-bytes are 16 apart, so the -// vectorized `int4_decode_chunk` (contiguous per-lane load) does not apply -// here; the inner loop reads one packed byte per lane per step, which the -// hardware coalesces across the sub-group into one wide transaction. +// Group sizes that are a multiple of 8 (16/32/64/128/256 -- the shipped quant +// configs) start every K-group on a chunk boundary, so the vectorized stage +// covers the whole group. Other even group sizes are handled by a scalar +// prologue/epilogue around the vector stage, which reads the same layout one +// byte at a time. +// +// The repack buffer comes from the persistent per-queue scratch pool +// (`DeviceScratchPool`), so decode steady state performs no USM allocation and +// -- unlike the previous transient allocation, which had to be freed behind a +// blocking `queue::wait()` on every call -- introduces no host-side +// synchronization. The repack kernel itself still runs per call unless the +// caller opts into `ARK_MOE_DECODE_INT4_REPACK_CACHE`. // // On top of coalescing, this path blocks tokens: each work-item owns one // output column but processes up to `TOKEN_BLOCK` consecutive tokens. For each @@ -448,8 +660,7 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // hitting the same expert -- this amortizes the dominant weight traffic across // the block (GEMV -> small GEMM). Fully-scattered routing degrades gracefully // to one pass per token with the same per-pass weight reads as before, so the -// result is bit-identical to the one-token-per-work-item kernel regardless of -// routing. +// result is independent of routing. // ---------------------------------------------------------------------------- template void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, @@ -470,165 +681,211 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin const int n_tiles = N / N_TILE; const int num_groups_k = K / group_size; const int k_packed = K / 2; // bytes of packed weight per (expert, n) - - const size_t repacked_bytes = - static_cast(num_experts) * static_cast(n_tiles) * - static_cast(k_packed) * static_cast(N_TILE); - uint8_t* repacked = sycl::malloc_device(repacked_bytes, *q); - if (repacked == nullptr) { - throw std::runtime_error("moe_gemm_decode(int4): failed to allocate repack buffer"); - } - - // Repack kernel: one work-item per (expert, column, packed byte). The write - // index places the 16 columns of a tile contiguously in the trailing dim. - { + // Packed bytes are blocked by 4 along K so each lane can issue one 4-byte + // load. The last chunk is zero-padded when k_packed is not a multiple of 4. + constexpr int PACK_VEC = 4; + const int k_chunks = (k_packed + PACK_VEC - 1) / PACK_VEC; + const int chunk_stride = N_TILE * PACK_VEC; // bytes per (chunk) across the tile + + const size_t repacked_bytes = static_cast(num_experts) * static_cast(n_tiles) * + static_cast(k_chunks) * static_cast(chunk_stride); + // Reuse the repack across calls only when the caller opted in; the tag key + // folds in the full shape so a tensor of different dimensions cannot alias a + // cached repack that happens to sit at the same address. + const size_t repack_key = (static_cast(num_experts) * 1000003u + static_cast(N)) * 1000003u + + static_cast(k_packed); + bool repack_cached = false; + uint8_t* repacked = int4_repack_pool().acquire(q, repacked_bytes, weights, repack_key, + moe_decode_int4_repack_cache_enabled(), &repack_cached); + + // Repack kernel: one work-item per (expert, column, packed-byte slot). The + // write index places the 16 columns of a tile contiguously in chunks of 4 + // bytes, lane-major. Slots past `k_packed` are zero-filled so the padded tail + // of the last chunk is always initialized. + if (!repack_cached) { sycl::range<3> rp_global{static_cast(num_experts), static_cast(N), - static_cast(k_packed)}; + static_cast(k_chunks * PACK_VEC)}; q->parallel_for>(rp_global, [=](sycl::id<3> id) { const int e = static_cast(id[0]); const int n = static_cast(id[1]); const int kb = static_cast(id[2]); const int t = n / N_TILE; const int l = n % N_TILE; - const size_t src = (static_cast(e) * N + static_cast(n)) * k_packed + kb; - const size_t dst = - ((static_cast(e) * n_tiles + t) * k_packed + kb) * N_TILE + l; - repacked[dst] = weights[src]; + const int c = kb / PACK_VEC; + const int r = kb % PACK_VEC; + const size_t dst = ((static_cast(e) * n_tiles + t) * k_chunks + c) * chunk_stride + + static_cast(l) * PACK_VEC + r; + if (kb < k_packed) { + repacked[dst] = weights[(static_cast(e) * N + static_cast(n)) * k_packed + kb]; + } else { + repacked[dst] = 0; + } }); } + // Per-(token, K-group) activation sums, hoisted out of the inner loop. + const float* a_sums = compute_act_group_sums(q, activations, total_tokens, K, group_size); + sycl::range<2> global{static_cast((total_tokens + TOKEN_BLOCK - 1) / TOKEN_BLOCK), static_cast(n_tiles * SG_SIZE)}; sycl::range<2> local{1, static_cast(SG_SIZE)}; q->parallel_for>( - sycl::nd_range<2>(global, local), - [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token_base = static_cast(it.get_global_id(0)) * TOKEN_BLOCK; - const int n_tile = static_cast(it.get_group(1)); - const int lane = static_cast(it.get_local_id(1)); - const int n_global = n_tile * N_TILE + lane; - - // Number of tokens this work-item owns (last block may be short). - int block = TOKEN_BLOCK; - if (token_base + block > total_tokens) { - block = total_tokens - token_base; - } - - // Experts routed by each token in the block. The tile weight byte is - // loaded once per k-step and reused only for tokens whose expert - // matches the byte's owning expert, so blocking tokens that share an - // expert amortizes the dominant weight traffic; tokens with a - // different expert contribute nothing from this pass and are handled - // by the pass whose leader expert matches theirs. - int experts[TOKEN_BLOCK]; - for (int b = 0; b < block; ++b) { - experts[b] = expert_id_per_token[token_base + b]; - } - - // Which distinct experts appear in this block. For each we make one - // weight-streaming pass, reusing every loaded byte across all tokens - // in the block routed to that expert. Bursty routing collapses to a - // single pass; fully-scattered routing degrades to one pass per token - // (i.e. the previous behaviour) with no extra weight reads per pass. - for (int lead = 0; lead < block; ++lead) { - const int expert = experts[lead]; - // Skip experts already streamed by an earlier token in this block. - bool seen = false; - for (int p = 0; p < lead; ++p) { - if (experts[p] == expert) { - seen = true; - break; - } - } - if (seen) continue; - - // Base of this (expert, n_tile) weight tile in the repacked buffer. - // Layout [E, N/16, K/2, 16]; this lane reads byte kb at - // w_tile[kb*16 + lane], so adjacent lanes read adjacent bytes. - const uint8_t* w_tile = - repacked + ((static_cast(expert) * n_tiles + n_tile) * k_packed) * N_TILE; - const ScalarT* s_row = - scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; - const ScalarT* z_row = Asym - ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k - : nullptr; - - // Compact the tokens routed to `expert` into a dense member list - // once per pass. Hoisting the routing filter out of the hot k-loop - // removes a per-(kb, token) branch and lets the compiler keep the - // per-member activation base pointers in registers; the numerics - // are identical to the previous per-kb `experts[b] != expert` - // filter. - int members[TOKEN_BLOCK]; - const ScalarT* act_rows[TOKEN_BLOCK]; - int nmembers = 0; - for (int b = 0; b < block; ++b) { - if (experts[b] != expert) continue; - members[nmembers] = b; - act_rows[nmembers] = activations + static_cast(token_base + b) * K; - ++nmembers; - } - - float acc[TOKEN_BLOCK]; - for (int m = 0; m < nmembers; ++m) acc[m] = 0.0f; - - for (int g = 0; g < num_groups_k; ++g) { - const float scale = static_cast(s_row[g]); - // Constant zero-point of 8 for sym (see `int4_decode_chunk`): the - // `^0x88` sign-flip lets sym reuse the asym unsigned decode and - // fold, so both modes emit the identical instruction stream. - float zero = 8.0f; - if constexpr (Asym) { - zero = static_cast(z_row[g]); - } - const int k_base = g * group_size; - // Per-token split accumulators; the per-group scale/zero is folded - // once after the K-loop, exactly as in the scalar path. Each - // iteration processes two K-elements (one packed byte); the byte - // load is coalesced across the sub-group and reused across every - // token in the block routed to `expert`. - float acc_q0[TOKEN_BLOCK]; - float acc_q1[TOKEN_BLOCK]; - float acc_a[TOKEN_BLOCK]; - for (int m = 0; m < nmembers; ++m) { - acc_q0[m] = 0.0f; - acc_q1[m] = 0.0f; - acc_a[m] = 0.0f; - } - const int kb_base = k_base / 2; - const int kb_count = group_size / 2; - for (int kb = 0; kb < kb_count; ++kb) { - uint8_t packed = w_tile[(kb_base + kb) * N_TILE + lane]; - if constexpr (!Asym) packed ^= static_cast(0x88); - int q0, q1; - decode_int4_pair(packed, q0, q1); - const float fq0 = static_cast(q0); - const float fq1 = static_cast(q1); - const int k0 = k_base + 2 * kb; - for (int m = 0; m < nmembers; ++m) { - const ScalarT* act_row = act_rows[m]; - const float fa0 = static_cast(act_row[k0]); - const float fa1 = static_cast(act_row[k0 + 1]); - acc_q0[m] += fa0 * fq0; - acc_q1[m] += fa1 * fq1; - acc_a[m] += fa0 + fa1; - } - } - for (int m = 0; m < nmembers; ++m) { - acc[m] += scale * ((acc_q0[m] + acc_q1[m]) - zero * acc_a[m]); - } - } + sycl::nd_range<2>(global, local), + [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token_base = static_cast(it.get_global_id(0)) * TOKEN_BLOCK; + const int n_tile = static_cast(it.get_group(1)); + const int lane = static_cast(it.get_local_id(1)); + const int n_global = n_tile * N_TILE + lane; + + // Number of tokens this work-item owns (last block may be short). + int block = TOKEN_BLOCK; + if (token_base + block > total_tokens) { + block = total_tokens - token_base; + } - for (int m = 0; m < nmembers; ++m) { - const int b = members[m]; - outputs[static_cast(token_base + b) * N + n_global] = static_cast(acc[m]); - } - } - }); + // Experts routed by each token in the block. The tile weight byte is + // loaded once per k-step and reused only for tokens whose expert + // matches the byte's owning expert, so blocking tokens that share an + // expert amortizes the dominant weight traffic; tokens with a + // different expert contribute nothing from this pass and are handled + // by the pass whose leader expert matches theirs. + int experts[TOKEN_BLOCK]; + for (int b = 0; b < block; ++b) { + experts[b] = expert_id_per_token[token_base + b]; + } - q->wait(); - sycl::free(repacked, *q); + // Which distinct experts appear in this block. For each we make one + // weight-streaming pass, reusing every loaded byte across all tokens + // in the block routed to that expert. Bursty routing collapses to a + // single pass; fully-scattered routing degrades to one pass per token + // (i.e. the previous behaviour) with no extra weight reads per pass. + for (int lead = 0; lead < block; ++lead) { + const int expert = experts[lead]; + // Skip experts already streamed by an earlier token in this block. + bool seen = false; + for (int p = 0; p < lead; ++p) { + if (experts[p] == expert) { + seen = true; + break; + } + } + if (seen) continue; + + // Base of this (expert, n_tile) weight tile in the repacked buffer. + // Layout [E, N/16, ceil(K/8), 16, 4]; this lane reads packed byte + // `b_abs` at w_tile[(b_abs/4)*64 + lane*4 + b_abs%4], so the 16 lanes + // of the sub-group span 64 contiguous bytes per chunk. + const uint8_t* w_tile = + repacked + (static_cast(expert) * n_tiles + n_tile) * k_chunks * chunk_stride; + const ScalarT* s_row = + scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; + const ScalarT* z_row = Asym + ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k + : nullptr; + + // Compact the tokens routed to `expert` into a dense member list + // once per pass. Hoisting the routing filter out of the hot k-loop + // removes a per-(kb, token) branch and lets the compiler keep the + // per-member activation base pointers in registers; the numerics + // are identical to the previous per-kb `experts[b] != expert` + // filter. + int members[TOKEN_BLOCK]; + const ScalarT* act_rows[TOKEN_BLOCK]; + const float* a_sum_rows[TOKEN_BLOCK]; + int nmembers = 0; + for (int b = 0; b < block; ++b) { + if (experts[b] != expert) continue; + members[nmembers] = b; + act_rows[nmembers] = activations + static_cast(token_base + b) * K; + a_sum_rows[nmembers] = a_sums + static_cast(token_base + b) * num_groups_k; + ++nmembers; + } + + float acc[TOKEN_BLOCK]; + for (int m = 0; m < nmembers; ++m) acc[m] = 0.0f; + + for (int g = 0; g < num_groups_k; ++g) { + const float scale = static_cast(s_row[g]); + // Constant zero-point of 8 for sym (see `int4_decode_chunk`): the + // `^0x88` sign-flip lets sym reuse the asym unsigned decode and + // fold, so both modes emit the identical instruction stream. + float zero = 8.0f; + if constexpr (Asym) { + zero = static_cast(z_row[g]); + } + const int k_base = g * group_size; + // Per-token split accumulators for the raw integer-weighted dot + // product; the per-group scale/zero (and the precomputed activation + // sum) are folded once after the K-loop, exactly as in the scalar + // path. + float acc_q0[TOKEN_BLOCK]; + float acc_q1[TOKEN_BLOCK]; + for (int m = 0; m < nmembers; ++m) { + acc_q0[m] = 0.0f; + acc_q1[m] = 0.0f; + } + + // Decode one already-sign-biased packed byte (two K elements) and + // accumulate it into every token of this pass. + auto accumulate_byte = [&](uint8_t biased, int k0) { + int q0, q1; + decode_int4_pair(biased, q0, q1); + const float fq0 = static_cast(q0); + const float fq1 = static_cast(q1); + for (int m = 0; m < nmembers; ++m) { + const ScalarT* act_row = act_rows[m]; + acc_q0[m] += static_cast(act_row[k0]) * fq0; + acc_q1[m] += static_cast(act_row[k0 + 1]) * fq1; + } + }; + // Load a single packed byte through the chunked layout. + auto load_byte = [&](int b_abs) { + uint8_t packed = w_tile[static_cast(b_abs / PACK_VEC) * chunk_stride + + static_cast(lane) * PACK_VEC + (b_abs % PACK_VEC)]; + if constexpr (!Asym) packed ^= static_cast(0x88); + return packed; + }; + + const int kb_base = k_base / 2; + const int kb_count = group_size / 2; + int kb = 0; + // Prologue to the next 4-byte chunk boundary. Empty whenever + // group_size % 8 == 0, i.e. for every shipped quant config. + for (; kb < kb_count && ((kb_base + kb) % PACK_VEC) != 0; ++kb) { + accumulate_byte(load_byte(kb_base + kb), k_base + 2 * kb); + } + using PackVec = sycl::vec; + for (; kb + PACK_VEC <= kb_count; kb += PACK_VEC) { + const int b_abs = kb_base + kb; // 4-byte aligned here + PackVec pv = *reinterpret_cast( + w_tile + static_cast(b_abs / PACK_VEC) * chunk_stride + + static_cast(lane) * PACK_VEC); + if constexpr (!Asym) { + // One vector XOR flips the sign bit of all 8 nibbles at once. + pv = pv ^ PackVec(static_cast(0x88)); + } +#pragma unroll + for (int u = 0; u < PACK_VEC; ++u) { + accumulate_byte(pv[u], k_base + 2 * (kb + u)); + } + } + // Scalar tail for group sizes that are not a multiple of 8. + for (; kb < kb_count; ++kb) { + accumulate_byte(load_byte(kb_base + kb), k_base + 2 * kb); + } + + for (int m = 0; m < nmembers; ++m) { + acc[m] += scale * ((acc_q0[m] + acc_q1[m]) - zero * a_sum_rows[m][g]); + } + } + + for (int m = 0; m < nmembers; ++m) { + const int b = members[m]; + outputs[static_cast(token_base + b) * N + n_global] = static_cast(acc[m]); + } + } + }); } // ---------------------------------------------------------------------------- @@ -1031,6 +1288,19 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh } // namespace moe_decode_detail +// ---------------------------------------------------------------------------- +// Release every device scratch buffer the int4 decode fallbacks hold (the +// N-tiled weight repack and the activation-sum table). Both are served from +// grow-on-demand per-queue slabs that are normally kept for the lifetime of the +// process; call this to hand the memory back, or to drop a repack cached under +// `ARK_MOE_DECODE_INT4_REPACK_CACHE` before the underlying weight buffer is +// freed. Safe to call at any time -- the next decode simply reallocates. +// ---------------------------------------------------------------------------- +inline void moe_decode_release_scratch() { + moe_decode_detail::int4_repack_pool().release_all(); + moe_decode_detail::act_group_sum_pool().release_all(); +} + // ---------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_DECODE_DPAS_S4` (default ON). When ON, int4-sym // (S4_CLIP, !asym) decode is routed to the dedicated decode-phase S4 DPAS From 4ec9d7fe3b6f775dd300147431a45af333b03877 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:32:40 +0000 Subject: [PATCH 038/112] test,docs: cover the new int4 decode paths and document the tuning knobs Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_PREFILL_PERF.md | 79 ++++++++++ .../ark/test/README_MOE_PREFILL_PERF_CN.md | 62 ++++++++ auto_round_extension/ark/test/test_moe.py | 135 +++++++++++++++++- .../ark/test/test_moe_decode_perf.py | 80 ++++++++++- 4 files changed, 350 insertions(+), 6 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index 435ae28392..2d624c2c0c 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -363,6 +363,85 @@ sym now accumulates the biased sum and subtracts `8 * sum a` at the end, which is exactly the fp32 accumulation pattern asym has always used. It applies to both `launch_int4` and `launch_int4_coalesced`. +**4-byte-blocked coalesced repack.** The coalesced fallback +(`launch_int4_coalesced`, `ARK_MOE_DECODE_COALESCE_INT4` default ON) +repacks the `[E, N, K/2]` weights on-device so sub-group loads are +contiguous. The original repack layout `[E, N/16, K/2, 16]` put one byte +per lane per step, so although the 16 lanes together covered one cache +line, each lane still issued a *byte* load — and for sym, one scalar +`^ 0x88` per byte. The layout is now `[E, N/16, ceil(K/8), 16, 4]`: a +chunk holds four consecutive packed bytes for each of the 16 columns of a +tile, lane-major, so lane `l` reads its four bytes at chunk offset `l*4` +and the sub-group still spans 64 contiguous bytes. Each lane therefore +issues one `vec` load instead of four byte loads (4× fewer +weight-load instructions), and the sym sign flip becomes one vector XOR +per chunk instead of four scalar XORs — removing the last per-byte +instruction sym paid over asym in this kernel. Group sizes that are a +multiple of 8 (16/32/64/128/256 — every shipped quant config) start each +K-group on a chunk boundary so the vector stage covers the whole group; +other even group sizes fall back to a scalar prologue/epilogue over the +same layout. The external `[E, N, K/2]` weight contract is unchanged. + +**Hoisted activation sums.** Both int4 GEMVs fold the per-group +scale/zero as `scale * (Σ a·q − zero · Σ a)`. `Σ a` depends only on the +activation row and the K-group, not on the output column, yet it used to +be recomputed inside the inner loop — once per sub-group lane (16× +redundant) and again for every N-tile work-group — costing one extra float +add per K element. It is now precomputed once into a +`[total_tokens, K/group_size]` fp32 table (`launch_act_group_sums`), so +the GEMV inner loop only accumulates `Σ a·q` and reads one float per +group. Sym benefits most: for sym `Σ a` exists purely to carry the +constant zero-point of 8 that the `^ 0x88` decode introduces. The +summation order changes by a few fp32 ULPs, far inside the kernel's +quantization tolerance. + +**Pooled scratch instead of per-call `malloc_device`.** The repack buffer +used to be a transient USM allocation that had to be freed behind a +blocking `queue::wait()` on *every* decode call — and decode issues one +call per generated token, so that allocation plus sync was on the order of +the GEMV itself. Both the repack buffer and the activation-sum table now +come from a persistent per-queue, grow-on-demand slab +(`DeviceScratchPool`), so steady-state decode performs no allocation and +introduces no host-side synchronization; ordering between the producer +kernels and the GEMV is already guaranteed by the in-order queue. +`ark.moe_decode_release_scratch()` (pybind `moe_decode_release_scratch`) +hands the memory back. + +The repack *kernel* still runs on every call by default. Setting +`ARK_MOE_DECODE_INT4_REPACK_CACHE=1` reuses the previous repack when the +weight buffer address and shape are unchanged, which is valid for a real +inference loop where the weights are fixed. It is **off by default** +because the tag is a pointer identity: a freed-then-reallocated weight +tensor can land on the same address (torch's caching allocator makes this +common in test loops), and a stale repack would silently produce wrong +results. Callers that enable it must call +`ark.moe_decode_release_scratch()` before dropping the weight tensor. + +| Env var | Default | Effect | +| ------- | ------- | ------ | +| `ARK_MOE_DECODE_COALESCE_INT4` | ON | Use the coalesced, 4-byte-blocked repack GEMV for the int4 scalar fallback; `0` forces the legacy per-lane-strided `launch_int4`. | +| `ARK_MOE_DECODE_COALESCE_MIN_TOKENS` | `num_experts * TOKEN_BLOCK` | Minimum total tokens before the coalesced kernel is worth its repack pass; `0` disables the gate (what the parity/A-B tests set). | +| `ARK_MOE_DECODE_INT4_REPACK_CACHE` | OFF | Reuse the repack across calls on the same weight buffer. Only safe when the caller owns the weight lifetime. | + +Perf A/B for the coalesced path is +`test_moe_decode_perf.py::test_perf_int4_coalesced_vs_strided` (toggles +`ARK_MOE_DECODE_COALESCE_INT4` 0/1 on the same shapes). Correctness is +covered by `test_moe.py::test_decode_int4_coalesced_matches_scalar`, +`::test_decode_int4_coalesced_token_blocking`, +`::test_decode_int4_coalesced_unaligned_group_size` (group sizes that are +not a multiple of 8, exercising the scalar prologue/epilogue) and +`::test_decode_int4_repack_cache`. + +**Occupancy-gate threshold sweep.** The default +`ARK_MOE_DECODE_DPAS_S4_MIN_TPE` of 8 was derived from the row count of +`dpas_w4a16_policy_m_8` rather than measured. The sweep that locates the +real crossing point is +`test_moe_decode_perf.py::test_perf_int4_sym_dpas_vs_scalar_threshold`; +its default token counts (16–128) all sit far below the gate (8 × 192 +experts == 1536 tokens), so pass `--all-shapes` to extend the sweep to +256/512/1024/1536/3072 tokens and bracket the gate from both sides. The +default stays at 8 until hardware numbers say otherwise. + Accuracy parity is covered by `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group`, which forces `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 8e43d7aae4..fabb2948eb 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -284,6 +284,68 @@ sym 使用 `(int8_t)(byte << 4) >> 4` 逐 nibble 做符号扩展,这是一条 这正是 asym 一直在用的 fp32 累加方式;`launch_int4` 与 `launch_int4_coalesced` 两个 kernel 均已应用。 +**按 4 字节分块的 coalesced repack。** coalesced 回退路径 +(`launch_int4_coalesced`,`ARK_MOE_DECODE_COALESCE_INT4` 默认开启)会在设备端把 +`[E, N, K/2]` 权重重排,使 sub-group 的加载连续。原先的重排布局 +`[E, N/16, K/2, 16]` 每个 lane 每步只放一个字节,因此虽然 16 个 lane 合起来覆盖 +一条 cache line,每个 lane 仍然发出的是*字节*加载 —— 对 sym 而言还额外附带每字节 +一次标量 `^ 0x88`。现在布局改为 `[E, N/16, ceil(K/8), 16, 4]`:一个 chunk 为 +tile 内 16 列中的每一列存放 4 个连续的打包字节,按 lane 主序排列,因此 lane `l` +在 chunk 偏移 `l*4` 处读取自己的 4 个字节,sub-group 整体仍然覆盖 64 个连续字节。 +于是每个 lane 只需一次 `vec` 加载而不是四次字节加载(权重加载指令数 +降为 1/4),sym 的符号翻转也变成每个 chunk 一次向量 XOR 而不是四次标量 XOR —— +这消除了该 kernel 中 sym 相对 asym 仅存的逐字节额外指令。group_size 为 8 的倍数时 +(16/32/64/128/256,即全部已发布的量化配置)每个 K 组都从 chunk 边界开始,向量 +阶段覆盖整个组;其他偶数 group_size 则通过标量前导/收尾循环在同一布局上处理。 +对外的 `[E, N, K/2]` 权重约定保持不变。 + +**提取激活求和。** 两个 int4 GEMV 都按 +`scale * (Σ a·q − zero · Σ a)` 折叠每组的 scale/zero。`Σ a` 只依赖激活行与 K 组, +与输出列无关,但此前它是在内层循环里重复计算的 —— 每个 sub-group lane 算一遍 +(16 倍冗余),每个 N-tile work-group 再算一遍 —— 每个 K 元素多付出一次浮点加法。 +现在它被预先计算成一张 `[total_tokens, K/group_size]` 的 fp32 表 +(`launch_act_group_sums`),GEMV 内层循环只累加 `Σ a·q`,每组读取一个 float。 +sym 获益最大:对 sym 来说 `Σ a` 的存在纯粹是为了承载 `^ 0x88` 解码引入的常数 +zero-point 8。求和顺序的变化仅带来几个 fp32 ULP 的差异,远在 kernel 现有的 +量化容差之内。 + +**用 scratch 池替代每次调用的 `malloc_device`。** repack 缓冲区原本是临时的 USM +分配,每次 decode 调用都必须在一次阻塞的 `queue::wait()` 之后释放 —— 而 decode +每生成一个 token 就调用一次,因此这次分配加同步的开销已经与 GEMV 本身同量级。 +现在 repack 缓冲区与激活求和表都取自按 queue 持有、按需增长的常驻 slab +(`DeviceScratchPool`),稳态 decode 不再有任何分配,也不引入主机侧同步;生产者 +kernel 与 GEMV 之间的顺序由 in-order queue 保证。 +`ark.moe_decode_release_scratch()`(pybind `moe_decode_release_scratch`)可将内存 +归还。 + +repack *kernel* 默认仍每次调用都执行。设置 +`ARK_MOE_DECODE_INT4_REPACK_CACHE=1` 可在权重缓冲区地址与形状不变时复用上一次的 +repack 结果 —— 这对权重固定的真实推理循环是成立的。它**默认关闭**,因为其 tag +是指针身份:被释放后重新分配的权重张量可能落在同一地址(torch 的缓存分配器在 +测试循环中很容易出现这种情况),此时陈旧的 repack 会静默产生错误结果。启用它的 +调用方必须在丢弃权重张量之前调用 `ark.moe_decode_release_scratch()`。 + +| 环境变量 | 默认值 | 作用 | +| -------- | ------ | ---- | +| `ARK_MOE_DECODE_COALESCE_INT4` | 开启 | int4 标量回退使用按 4 字节分块的 coalesced repack GEMV;设为 `0` 则强制使用按 lane 跨步的旧版 `launch_int4`。 | +| `ARK_MOE_DECODE_COALESCE_MIN_TOKENS` | `num_experts * TOKEN_BLOCK` | coalesced kernel 值回其 repack 开销所需的最小总 token 数;设为 `0` 关闭该门控(一致性/A-B 测试即如此设置)。 | +| `ARK_MOE_DECODE_INT4_REPACK_CACHE` | 关闭 | 在同一权重缓冲区上跨调用复用 repack 结果。仅当调用方掌握权重生命周期时才安全。 | + +coalesced 路径的性能 A/B 见 +`test_moe_decode_perf.py::test_perf_int4_coalesced_vs_strided`(在相同形状上切换 +`ARK_MOE_DECODE_COALESCE_INT4` 0/1)。正确性由 +`test_moe.py::test_decode_int4_coalesced_matches_scalar`、 +`::test_decode_int4_coalesced_token_blocking`、 +`::test_decode_int4_coalesced_unaligned_group_size`(非 8 的倍数的 group_size, +覆盖标量前导/收尾路径)以及 `::test_decode_int4_repack_cache` 覆盖。 + +**占用率门控阈值扫描。** `ARK_MOE_DECODE_DPAS_S4_MIN_TPE` 的默认值 8 来自 +`dpas_w4a16_policy_m_8` 的 tile 行数,而非实测结果。定位真实交叉点的扫描用例是 +`test_moe_decode_perf.py::test_perf_int4_sym_dpas_vs_scalar_threshold`;它默认的 +token 数(16–128)都远低于该门控(8 × 192 个专家 == 1536 个 token),因此需要传入 +`--all-shapes` 把扫描扩展到 256/512/1024/1536/3072 个 token,从两侧夹住门控。 +在拿到硬件数据之前,默认值仍保持为 8。 + 精度对齐由 `test_moe_prefill_accuracy.py::test_accuracy_int4_dpas_per_group` 覆盖,该用例强制 `ARK_MOE_PREFILL_DPAS_S4=1` + diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index 77b76c0575..e1eb623531 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -584,8 +584,10 @@ def test_decode_int4_coalesced_matches_scalar(self, monkeypatch, dtype, asym, gr dequant->bmm reference within quantization tolerance. The S4 DPAS fast path is disabled so both runs exercise the scalar - fallback (this is the only path the coalesce flag affects). Shapes use - N%16==0 so the N-tiled repack is exact. + fallback (this is the only path the coalesce flag affects), and the + coalesce amortization gate is disabled so the coalesced kernel really + runs at this (tiny) token count instead of silently falling back to the + per-lane-strided one. Shapes use N%16==0 so the N-tiled repack is exact. """ num_experts = 4 tokens_per_expert = [1, 0, 2, 1] @@ -619,8 +621,11 @@ def _run(): asym=asym, ) - # Force the scalar-GEMV fallback so the coalesce flag actually applies. + # Force the scalar-GEMV fallback so the coalesce flag actually applies, + # and disable the tokens-per-expert amortization gate so the coalesced + # kernel is reached at this token count. monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS", "0") monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "1") out_coalesced = _run() @@ -684,8 +689,11 @@ def _run(): asym=asym, ) - # Force the scalar-GEMV fallback so the coalesce/token-blocking path runs. + # Force the scalar-GEMV fallback so the coalesce/token-blocking path + # runs, and disable the amortization gate so the coalesced kernel is + # reached at this token count. monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS", "0") monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "1") out_blocked = _run() @@ -699,6 +707,125 @@ def _run(): # blocked and legacy kernels must match tightly for every routing shape. torch.testing.assert_close(out_blocked, out_scalar, rtol=1e-3, atol=1e-3) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("asym", [False, True]) + @pytest.mark.parametrize("group_size", [4, 12]) + def test_decode_int4_coalesced_unaligned_group_size(self, monkeypatch, dtype, asym, group_size): + """The coalesced int4 kernel loads four packed bytes per lane out of a + ``[E, N/16, ceil(K/8), 16, 4]`` layout. Group sizes that are a multiple + of 8 start every K-group on a 4-byte chunk boundary, so the vectorized + stage covers the whole group; other even group sizes need the scalar + prologue/epilogue around it. + + ``group_size=4`` leaves every odd group misaligned with no room for a + vector step at all, and ``group_size=12`` mixes a misaligned prologue + with a vector step, so between them both non-vector paths are covered. + """ + num_experts = 3 + tokens_per_expert = [2, 0, 3] + total_tokens = sum(tokens_per_expert) + N, K = 32, 48 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + if asym: + zeros = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_asym(w_float, scales, zeros, group_size) + dequant = _dequant_int4_asym(packed, scales, zeros, group_size).to(dtype) + else: + zeros = None + packed = _pack_int4_sym(w_float, scales, group_size) + dequant = _dequant_int4_sym(packed, scales, group_size).to(dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + zeros=zeros, + weight_bits=4, + group_size=group_size, + asym=asym, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS", "0") + + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "1") + out_coalesced = _run() + + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "0") + out_scalar = _run() + + assert out_coalesced.shape == (total_tokens, N) + torch.testing.assert_close(out_coalesced, ref, rtol=5e-2, atol=5e-2) + torch.testing.assert_close(out_coalesced, out_scalar, rtol=1e-3, atol=1e-3) + + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + def test_decode_int4_repack_cache(self, monkeypatch, dtype): + """``ARK_MOE_DECODE_INT4_REPACK_CACHE=1`` lets the coalesced int4 kernel + reuse the N-tiled weight repack across calls instead of rebuilding it. + + Reuse is keyed on the weight buffer address plus its shape, so it is + only valid while the caller keeps that buffer alive. Verify that (a) + repeated calls on a live weight tensor keep matching the reference, and + (b) ``moe_decode_release_scratch()`` drops the cache so a *different* + weight tensor -- which torch's caching allocator may well hand back at + the same address -- is repacked again rather than answered from the + stale entry. + """ + num_experts = 3 + tokens_per_expert = [4, 0, 5] + total_tokens = sum(tokens_per_expert) + N, K = 64, 128 + group_size = 32 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + def _build(): + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_sym(w_float, scales, group_size) + dequant = _dequant_int4_sym(packed, scales, group_size).to(dtype) + return packed, scales, _moe_decode_reference(activations, dequant, num_tokens_per_expert) + + def _run(packed, scales): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "1") + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS", "0") + monkeypatch.setenv("ARK_MOE_DECODE_INT4_REPACK_CACHE", "1") + + try: + packed_a, scales_a, ref_a = _build() + # First call builds the repack, second must hit the cache. + torch.testing.assert_close(_run(packed_a, scales_a), ref_a, rtol=5e-2, atol=5e-2) + torch.testing.assert_close(_run(packed_a, scales_a), ref_a, rtol=5e-2, atol=5e-2) + + # Drop the cached repack before the buffer it was derived from goes + # away, then verify a freshly built weight tensor is honoured. + del packed_a, scales_a + ark.moe_decode_release_scratch() + packed_b, scales_b, ref_b = _build() + torch.testing.assert_close(_run(packed_b, scales_b), ref_b, rtol=5e-2, atol=5e-2) + finally: + ark.moe_decode_release_scratch() + def test_decode_validation_errors(self): """Sanity-check that Python-side validation catches misuse.""" num_experts = 2 diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index 26641cc95c..60861b407e 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -234,6 +234,15 @@ def _spread_tokens(total_tokens: int, num_experts: int = 192) -> list: # to locate the DPAS-vs-scalar crossover for the auto-dispatch threshold. _INT4_THRESHOLD_TOKEN_COUNTS = [16, 32, 64, 128] +# Extra (much larger) token counts appended when --all-shapes is passed. The +# default ARK_MOE_DECODE_DPAS_S4_MIN_TPE gate is 8 tokens per expert, i.e. +# 8 * 192 == 1536 total tokens for the sweep's expert count, so the counts above +# alone can never show where DPAS actually overtakes the scalar GEMV -- they all +# sit far below the gate. These bracket the gate from both sides so the measured +# crossing point can replace the tile-row-count heuristic the default was +# derived from. +_INT4_THRESHOLD_TOKEN_COUNTS_EXTENDED = [256, 512, 1024, 1536, 3072] + # MiniMax-M2 up/down-proj (N, K) pairs reused by the threshold sweep. _INT4_THRESHOLD_NK = [ (1536, 3072), # gate/up-proj @@ -395,6 +404,65 @@ def test_perf_int4(self, dtype, asym): ) _print_row(label, N, K, total_tokens, base_ms, ark_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("asym", [False, True]) + def test_perf_int4_coalesced_vs_strided(self, monkeypatch, dtype, asym): + """int4 scalar-GEMV fallback A/B: coalesced N-tiled weight loads + (``ARK_MOE_DECODE_COALESCE_INT4=1``) vs the legacy per-lane-strided + kernel (``=0``). + + ``speedup`` is ``strided / coalesced`` (the coalesced kernel is the + "ark" column). The S4 DPAS fast path is disabled so both columns run the + scalar fallback -- the only path the coalesce flag affects -- and the + amortization gate is disabled so the coalesced kernel is actually + reached at decode-sized token counts (it repacks the whole weight + tensor, so at very low tokens-per-expert the repack dominates, which is + exactly what the default gate exists to avoid; this row shows how much). + """ + group_size = 128 + kind = "asym" if asym else "sym" + _print_header( + f"INT4 {kind} coalesced vs strided (group_size={group_size}, " + f"act={str(dtype).split('.')[-1]}) -- strided GEMV (baseline) vs coalesced GEMV (ark)" + ) + for label, E, tpe, N, K in DECODE_SHAPES: + if K % group_size != 0: + continue + total_tokens = sum(tpe) + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + if asym: + zeros = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_asym(w_float, scales, zeros, group_size) + else: + zeros = None + packed = _pack_int4_sym(w_float, scales, group_size) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + zeros=zeros, + weight_bits=4, + group_size=group_size, + asym=asym, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS", "0") + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "0") + strided_ms = _xpu_time_ms(_run) + monkeypatch.setenv("ARK_MOE_DECODE_COALESCE_INT4", "1") + coalesced_ms = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_MOE_DECODE_COALESCE_MIN_TOKENS", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_COALESCE_INT4", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_S4", raising=False) + _print_row(label, N, K, total_tokens, strided_ms, coalesced_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) def test_perf_int4_sym_dpas_vs_scalar(self, monkeypatch, dtype): """int4-sym decode: compare the S4 DPAS path (ARK_MOE_DECODE_DPAS_S4=1) @@ -450,7 +518,7 @@ def _run(): _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) - def test_perf_int4_sym_dpas_vs_scalar_threshold(self, monkeypatch, dtype): + def test_perf_int4_sym_dpas_vs_scalar_threshold(self, request, monkeypatch, dtype): """int4-sym decode threshold sweep: DPAS vs scalar GEMV across a range of total-token counts (16/32/64/128) at ``group_size=32``. @@ -465,9 +533,17 @@ def test_perf_int4_sym_dpas_vs_scalar_threshold(self, monkeypatch, dtype): the default ``ARK_MOE_DECODE_DPAS_S4_MIN_TPE`` occupancy gate (set to ``0`` here so the DPAS column is not itself re-routed to the scalar GEMV). + + By default only the small token counts are swept so a CI pass stays + short. Those all sit below the default occupancy gate (8 tokens per + expert == 1536 tokens for E=192), so pass ``--all-shapes`` to extend the + sweep across the gate and measure where the crossing actually is. """ group_size = 32 E = 192 + token_counts = list(_INT4_THRESHOLD_TOKEN_COUNTS) + if request.config.getoption("--all-shapes", default=False): + token_counts += _INT4_THRESHOLD_TOKEN_COUNTS_EXTENDED _print_header( f"INT4 sym DPAS vs scalar threshold sweep (group_size={group_size}, " f"act={str(dtype).split('.')[-1]}) -- scalar GEMV (baseline) vs S4 DPAS (ark)" @@ -475,7 +551,7 @@ def test_perf_int4_sym_dpas_vs_scalar_threshold(self, monkeypatch, dtype): for N, K in _INT4_THRESHOLD_NK: if K % group_size != 0 or N % 64 != 0 or K % 32 != 0: continue - for total_tokens in _INT4_THRESHOLD_TOKEN_COUNTS: + for total_tokens in token_counts: tpe = _spread_tokens(total_tokens, E) label = f"int4 {N}x{K} t{total_tokens}" activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") From 610a005d669e6e0ef469df782ba6bafcbbc782e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:25:46 +0000 Subject: [PATCH 039/112] perf: speed up int4-sym MoE decode with word-native nibble decode Decode packed int4 nibbles from 32-bit words instead of 8-bit-typed sycl::vec ops, and restore true signed-nibble decode for sym so the zero-point/activation-sum fold (and its extra per-call device kernel launch) is skipped entirely on the sym path. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 303 ++++++++++-------- .../wrapper/include/sycl_tla_moe_dequant.hpp | 28 +- 2 files changed, 186 insertions(+), 145 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index bc128bda1d..9ae797dfb6 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -39,6 +39,7 @@ #pragma once +#include #include #include #include @@ -86,6 +87,31 @@ namespace moe_decode_detail { constexpr int SG_SIZE = 16; constexpr int N_TILE = SG_SIZE; // one output element per sub-group lane +// ---------------------------------------------------------------------------- +// Allocation-free boolean env-var lookup. +// +// The int4 decode dispatch consults up to three of these on *every* call -- they +// are deliberately re-read rather than cached so tests and benchmarks can toggle +// a path in-process -- and decode issues one call per generated token. Building +// a `std::string` per lookup put a heap allocation on that hot path for nothing, +// so the comparison is done in place instead. The accepted spellings are +// unchanged: "0" / "false" / "off" / "no" (case-insensitive) mean off, any other +// value means on, and an unset variable falls back to `default_value`. +// ---------------------------------------------------------------------------- +inline bool env_flag_enabled(const char* name, bool default_value) { + const char* env = std::getenv(name); + if (env == nullptr) return default_value; + auto iequals = [](const char* value, const char* lowercase_literal) { + const char* a = value; + const char* b = lowercase_literal; + for (; *a != '\0' && *b != '\0'; ++a, ++b) { + if (static_cast(std::tolower(static_cast(*a))) != *b) return false; + } + return *a == '\0' && *b == '\0'; + }; + return !(iequals(env, "0") || iequals(env, "false") || iequals(env, "off") || iequals(env, "no")); +} + // Token-blocking factor for the coalesced int4 decode GEMV. A work-item that // owns one (n_tile, lane) output column processes up to TOKEN_BLOCK consecutive // tokens, loading each packed weight byte from the (expert, n_tile) tile once @@ -136,6 +162,7 @@ using moe_dequant::decode_fp8_e4m3_lut; using moe_dequant::decode_fp8_e5m2_bits; using moe_dequant::decode_fp8_e5m2_lut; using moe_dequant::decode_int2_quad; +using moe_dequant::decode_int4_octet; using moe_dequant::decode_int4_pair; using moe_dequant::decode_int8; using moe_dequant::fp8_decode_use_lut; @@ -324,22 +351,25 @@ inline DeviceScratchPool& act_group_sum_pool() { } // ---------------------------------------------------------------------------- -// Per-(token, K-group) activation sums. +// Per-(token, K-group) activation sums (asym int4 only). // -// Both int4 GEMV kernels fold their per-group scale/zero as +// The asym int4 GEMVs fold their per-group scale/zero as // `scale * (Σ a·q - zero · Σ a)`, where `Σ a` runs over the group's K range. // `Σ a` depends only on the activation row and the group, *not* on the output // column, yet the GEMVs used to recompute it inside the inner loop -- once per // sub-group lane (16x redundant) and again for every N-tile work-group (N/16x -// redundant). That cost one extra float add per K element on the hot path, -// which for sym is pure overhead introduced by the constant zero-point of 8 -// that the `^0x88` decode relies on. +// redundant). That cost one extra float add per K element on the hot path. // // This pass computes the `[total_tokens, K/group_size]` table once, so the // GEMVs only accumulate `Σ a·q` and read one float per group. The table is // tiny (tokens x groups floats) and comes from the scratch pool, so no // allocation happens in steady state. // +// Sym does *not* use this at all: it decodes true signed nibbles, so its fold +// carries no zero-point term. That keeps this extra kernel launch -- a +// first-order cost when the GEMV itself is only tens of microseconds -- off the +// sym decode timeline entirely. +// // The summation order differs from the previous in-loop accumulation, so // results move by a few float ULPs -- far inside the kernel's quantization // tolerance. @@ -409,34 +439,36 @@ float* compute_act_group_sums(sycl::queue* q, const ScalarT* activations, int to // without regressing group_size == 16 (which drops straight to the 16-wide // stage). // -// Both sym and asym decode the nibbles through the *same* unsigned path. -// Measurements on MiniMax-M2 decode shapes showed int4-sym ~1.9x slower than -// int4-asym in this very kernel even though sym does strictly fewer floating -// point operations. The only difference was the per-nibble sign extension -// (`(int8_t)(byte << 4) >> 4`) -- a serial shift/narrow/shift chain per nibble -// that defeats the byte-wise vectorization the asym mask+shift form gets. The -// sym decode is therefore expressed with the standard sign-flip identity +// The packed weights are consumed as 32-bit *words* (four packed bytes, eight +// K elements) through the shared `decode_int4_octet` primitive rather than as +// a `sycl::vec` byte vector. On Xe the ALU is 32-bit-lane based +// and byte-typed vector operations lower to restricted byte regioning that IGC +// often has to expand, so every per-byte step in the hot loop -- the element +// extraction *and*, for sym, the sign handling -- paid that expansion. In +// word form both modes issue exactly two native DWORD operations per nibble: // -// signed_nibble == (unsigned_nibble ^ 8) - 8 +// asym: (word >> 4j) & 0xF +// sym : (int)(word << (28 - 4j)) >> 28 // -// so XOR-ing the packed byte with `0x88` (flipping the sign bit of *both* -// nibbles at once, on the whole loaded vector register) turns sym into exactly -// the asym computation with a constant zero-point of 8. The decoded integers -// are bit-identical to the sign-extending decode for all 256 byte values, so -// the only change is that sym now accumulates the biased sum and subtracts -// `8 * sum a` at the end -- exactly the fp32 accumulation pattern asym has -// always used, and well inside the kernel's existing quantization tolerance. +// so sym's sign extension is no longer a serial byte-typed shift/narrow/shift +// chain and costs the same as asym's mask+shift. That removes the reason the +// previous revision biased sym with a `^0x88` vector XOR and folded a constant +// zero-point of 8: sym now accumulates *true signed* nibbles, which means it +// no longer needs the `Σ a` term at all (see `launch_int4`) -- one fewer fp32 +// add per K element, one fewer table read per K-group, and one fewer device +// kernel launch per decode call than asym. The decoded integers are +// bit-identical to `decode_int4_pair` for every input word, so decode/prefill +// parity is unchanged. // -// The per-group scale and zero-point are NOT applied here: this accumulates the -// raw integer-weighted dot product into `acc_q0`/`acc_q1`. The plain activation -// sum `Σ a` that the fold also needs (for both modes -- sym carries the -// constant zero-point of 8) is *not* accumulated here either: it is independent -// of the output column and is precomputed once per (token, K-group) by -// `launch_act_group_sums`. The caller folds the group's scale/zero in once -// (sum a*((q-z)*s) == s*(sum a*q - z*sum a)). Hoisting the scale removes one -// float multiply per K element on the decode hot path, hoisting `Σ a` removes -// one float add per K element, and because the fold is exact-once per group the -// result stays well within the kernel's existing quantization tolerance. +// The per-group scale and zero-point are NOT applied here: this accumulates +// the raw integer-weighted dot product into `acc_q0`/`acc_q1`. The caller +// folds the group's scale (and, for asym, its zero-point against the +// precomputed `Σ a`) in exactly once per group: +// sym : acc += scale * (acc_q0 + acc_q1) +// asym: acc += scale * ((acc_q0 + acc_q1) - zero * Σ a) +// Hoisting the scale removes one float multiply per K element on the decode +// hot path, and because the fold is exact-once per group the result stays well +// within the kernel's existing quantization tolerance. // // Two independent partial accumulators (``acc_q0``/``acc_q1``) break the // single fp32 dependency chain so the FMA pipeline is not latency-bound; the @@ -449,32 +481,29 @@ static inline void int4_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_pt // sycl::vec only supports widths of 1, 2, 3, 4, 8 or 16, so a single // vec load is illegal. Process the chunk in 16-wide sub-blocks // (16 activations + 8 packed weight bytes each), which keeps CHUNK == 32 - // valid while reusing the same code path for CHUNK == 16. + // valid while reusing the same code path for CHUNK == 16. The 8 packed bytes + // are loaded as two 32-bit words in one 8-byte transaction -- the same + // access width (and the same 8-byte alignment requirement) as the byte + // vector it replaces. constexpr int SUB = 16; + constexpr int WORDS = SUB / 8; // one 32-bit word per 8 K elements using ActVec = sycl::vec; - using PackVec = sycl::vec; + using WordVec = sycl::vec; #pragma unroll for (int s = 0; s < CHUNK / SUB; ++s) { const ActVec av = *reinterpret_cast(act_ptr + s * SUB); - PackVec pv = *reinterpret_cast(w_ptr + s * (SUB / 2)); - if constexpr (!Asym) { - // Sign-flip the whole packed vector in one vector XOR so the sym nibbles - // can be decoded by the (vectorizable) unsigned path below; the constant - // zero-point of 8 is folded by the caller. - pv = pv ^ PackVec(static_cast(0x88)); - } + const WordVec wv = *reinterpret_cast(w_ptr + s * (SUB / 2)); #pragma unroll - for (int b = 0; b < SUB / 2; ++b) { - int q0, q1; - // Always the unsigned decode: asym nibbles are unsigned by definition and - // sym nibbles were biased by the XOR above. - decode_int4_pair(pv[b], q0, q1); - const ScalarT a0 = sycl::bit_cast(static_cast(av[2 * b])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[2 * b + 1])); - const float fa0 = static_cast(a0); - const float fa1 = static_cast(a1); - acc_q0 += fa0 * static_cast(q0); - acc_q1 += fa1 * static_cast(q1); + for (int w = 0; w < WORDS; ++w) { + int q[8]; + decode_int4_octet(wv[w], q); +#pragma unroll + for (int u = 0; u < 8; u += 2) { + const ScalarT a0 = sycl::bit_cast(static_cast(av[8 * w + u])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[8 * w + u + 1])); + acc_q0 += static_cast(a0) * static_cast(q[u]); + acc_q1 += static_cast(a1) * static_cast(q[u + 1]); + } } } } @@ -499,8 +528,16 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig const int k_packed = K / 2; // bytes of packed weight per (expert, n) // Per-(token, K-group) activation sums, shared by every lane and every - // N-tile instead of being recomputed inside the inner loop. - const float* a_sums = compute_act_group_sums(q, activations, total_tokens, K, group_size); + // N-tile instead of being recomputed inside the inner loop. Only the *asym* + // fold needs them (`Σ a·(q - z) == Σ a·q - z·Σ a`): sym decodes true signed + // nibbles, so its fold is a plain per-group scale multiply with no + // zero-point term. Skipping the pre-pass keeps a whole extra kernel launch + // off the sym decode timeline -- on decode-sized batches the GEMV itself is + // only tens of microseconds, so an extra dispatch is a first-order cost. + [[maybe_unused]] const float* a_sums = nullptr; + if constexpr (Asym) { + a_sums = compute_act_group_sums(q, activations, total_tokens, K, group_size); + } sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; sycl::range<2> local{1, static_cast(SG_SIZE)}; @@ -515,26 +552,21 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig const int expert = expert_id_per_token[token]; const ScalarT* act_row = activations + static_cast(token) * K; - const float* a_sum_row = a_sums + static_cast(token) * num_groups_k; const uint8_t* w_row = weights + (static_cast(expert) * N + static_cast(n_global)) * k_packed; const ScalarT* s_row = scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; - const ScalarT* z_row = Asym - ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k - : nullptr; + [[maybe_unused]] const ScalarT* z_row = nullptr; + [[maybe_unused]] const float* a_sum_row = nullptr; + if constexpr (Asym) { + z_row = zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; + a_sum_row = a_sums + static_cast(token) * num_groups_k; + } float acc = 0.0f; for (int g = 0; g < num_groups_k; ++g) { const float scale = static_cast(s_row[g]); - // Sym uses the constant zero-point of 8 that the `^0x88` sign-flip - // in the decode introduces, so both modes run the identical fold. - // `if constexpr` keeps the null `z_row` out of the sym instantiation. - float zero = 8.0f; - if constexpr (Asym) { - zero = static_cast(z_row[g]); - } const int k_base = g * group_size; // Vectorized ladder: process 32 K-elements at a time (16 packed // weight bytes + vec activation block), then a 16-wide @@ -547,12 +579,11 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // The scale and zero are constant across the group, so the wide // stages accumulate only the raw integer-weighted dot product // ``Σ a·q`` (split across two partial accumulators to break the - // fp32 dependency chain); ``Σ a`` comes from the precomputed - // per-(token, group) table. The fold below applies the scale/zero - // exactly once per group, identically for both modes: - // acc += scale * ((acc_q0 + acc_q1) - zero * a_sum) - // with `zero` == the per-group zero-point (asym) or the constant 8 - // that the `^0x88` sign-flip decode introduces (sym). + // fp32 dependency chain). The fold below applies the scale exactly + // once per group; asym additionally subtracts its per-group + // zero-point against the precomputed ``Σ a``: + // sym : acc += scale * (acc_q0 + acc_q1) + // asym: acc += scale * ((acc_q0 + acc_q1) - zero * a_sum) float acc_q0 = 0.0f; float acc_q1 = 0.0f; int kk = 0; @@ -572,16 +603,19 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // raw-accumulation convention as the wide stages so the single // scale/zero fold below stays valid. for (; kk < group_size; kk += 2) { - uint8_t packed = w_row[(k_base + kk) / 2]; - if constexpr (!Asym) packed ^= static_cast(0x88); + const uint8_t packed = w_row[(k_base + kk) / 2]; int q0, q1; - decode_int4_pair(packed, q0, q1); + decode_int4_pair(packed, q0, q1); const float fa0 = static_cast(act_row[k_base + kk]); const float fa1 = static_cast(act_row[k_base + kk + 1]); acc_q0 += fa0 * static_cast(q0); acc_q1 += fa1 * static_cast(q1); } - acc += scale * ((acc_q0 + acc_q1) - zero * a_sum_row[g]); + if constexpr (Asym) { + acc += scale * ((acc_q0 + acc_q1) - static_cast(z_row[g]) * a_sum_row[g]); + } else { + acc += scale * (acc_q0 + acc_q1); + } } outputs[static_cast(token) * N + n_global] = static_cast(acc); @@ -606,11 +640,7 @@ void launch_int4(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // `moe_decode_release_scratch`) drops the cached buffers. // ---------------------------------------------------------------------------- inline bool moe_decode_int4_repack_cache_enabled() { - const char* env = std::getenv("ARK_MOE_DECODE_INT4_REPACK_CACHE"); - if (env == nullptr) return false; // default OFF -- see comment above - std::string s(env); - for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); - return !(s == "0" || s == "false" || s == "off" || s == "no"); + return env_flag_enabled("ARK_MOE_DECODE_INT4_REPACK_CACHE", false); // default OFF -- see comment above } // ---------------------------------------------------------------------------- @@ -629,15 +659,14 @@ inline bool moe_decode_int4_repack_cache_enabled() { // four consecutive packed bytes for each of the 16 columns owned by a sub-group // tile, lane-major. Lane `l` therefore reads its four bytes at chunk offset // `l*4`, and the 16 lanes of a sub-group together cover 64 contiguous bytes -> -// still a single coalesced transaction, but now each lane issues one -// `vec` load instead of four separate byte loads. For sym the -// `^0x88` sign flip that lets the unsigned (vectorizable) nibble decode stand in -// for the sign-extending one also becomes a single vector XOR per chunk instead -// of one scalar XOR per byte -- the last remaining per-byte instruction sym paid -// over asym in this kernel. The dequant math is otherwise identical to -// `launch_int4` (same `decode_int4_pair`, same per-group scale/zero fold); only -// the weight memory layout changes, and the caller's `[E, N, K/2]` weight -// contract is unchanged. +// still a single coalesced transaction, but now each lane issues one 32-bit +// word load instead of four separate byte loads. That word is decoded with the +// shared `decode_int4_octet` primitive, so all eight nibbles are extracted with +// native DWORD shift/mask pairs and neither mode touches the 8-bit ALU (see +// `int4_decode_chunk`). The dequant math is otherwise identical to +// `launch_int4` (bit-identical nibbles, same per-group scale/zero fold, sym +// accumulating true signed nibbles with no `Σ a` term); only the weight memory +// layout changes, and the caller's `[E, N, K/2]` weight contract is unchanged. // // Group sizes that are a multiple of 8 (16/32/64/128/256 -- the shipped quant // configs) start every K-group on a chunk boundary, so the vectorized stage @@ -723,8 +752,13 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin }); } - // Per-(token, K-group) activation sums, hoisted out of the inner loop. - const float* a_sums = compute_act_group_sums(q, activations, total_tokens, K, group_size); + // Per-(token, K-group) activation sums, hoisted out of the inner loop. Only + // asym needs them -- sym decodes true signed nibbles and folds a plain scale + // -- so the sym path skips this kernel launch entirely (see `launch_int4`). + [[maybe_unused]] const float* a_sums = nullptr; + if constexpr (Asym) { + a_sums = compute_act_group_sums(q, activations, total_tokens, K, group_size); + } sycl::range<2> global{static_cast((total_tokens + TOKEN_BLOCK - 1) / TOKEN_BLOCK), static_cast(n_tiles * SG_SIZE)}; @@ -780,9 +814,10 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin repacked + (static_cast(expert) * n_tiles + n_tile) * k_chunks * chunk_stride; const ScalarT* s_row = scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; - const ScalarT* z_row = Asym - ? zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k - : nullptr; + [[maybe_unused]] const ScalarT* z_row = nullptr; + if constexpr (Asym) { + z_row = zeros + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; + } // Compact the tokens routed to `expert` into a dense member list // once per pass. Hoisting the routing filter out of the hot k-loop @@ -792,13 +827,15 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin // filter. int members[TOKEN_BLOCK]; const ScalarT* act_rows[TOKEN_BLOCK]; - const float* a_sum_rows[TOKEN_BLOCK]; + [[maybe_unused]] const float* a_sum_rows[TOKEN_BLOCK]; int nmembers = 0; for (int b = 0; b < block; ++b) { if (experts[b] != expert) continue; members[nmembers] = b; act_rows[nmembers] = activations + static_cast(token_base + b) * K; - a_sum_rows[nmembers] = a_sums + static_cast(token_base + b) * num_groups_k; + if constexpr (Asym) { + a_sum_rows[nmembers] = a_sums + static_cast(token_base + b) * num_groups_k; + } ++nmembers; } @@ -807,18 +844,11 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin for (int g = 0; g < num_groups_k; ++g) { const float scale = static_cast(s_row[g]); - // Constant zero-point of 8 for sym (see `int4_decode_chunk`): the - // `^0x88` sign-flip lets sym reuse the asym unsigned decode and - // fold, so both modes emit the identical instruction stream. - float zero = 8.0f; - if constexpr (Asym) { - zero = static_cast(z_row[g]); - } const int k_base = g * group_size; // Per-token split accumulators for the raw integer-weighted dot - // product; the per-group scale/zero (and the precomputed activation - // sum) are folded once after the K-loop, exactly as in the scalar - // path. + // product; the per-group scale (and, for asym, the zero-point + // against the precomputed activation sum) are folded once after + // the K-loop, exactly as in the scalar path. float acc_q0[TOKEN_BLOCK]; float acc_q1[TOKEN_BLOCK]; for (int m = 0; m < nmembers; ++m) { @@ -826,11 +856,9 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin acc_q1[m] = 0.0f; } - // Decode one already-sign-biased packed byte (two K elements) and - // accumulate it into every token of this pass. - auto accumulate_byte = [&](uint8_t biased, int k0) { - int q0, q1; - decode_int4_pair(biased, q0, q1); + // Accumulate one decoded nibble pair (two K elements) into every + // token of this pass. + auto accumulate_pair = [&](int q0, int q1, int k0) { const float fq0 = static_cast(q0); const float fq1 = static_cast(q1); for (int m = 0; m < nmembers; ++m) { @@ -839,12 +867,13 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin acc_q1[m] += static_cast(act_row[k0 + 1]) * fq1; } }; - // Load a single packed byte through the chunked layout. - auto load_byte = [&](int b_abs) { - uint8_t packed = w_tile[static_cast(b_abs / PACK_VEC) * chunk_stride + - static_cast(lane) * PACK_VEC + (b_abs % PACK_VEC)]; - if constexpr (!Asym) packed ^= static_cast(0x88); - return packed; + // Load and decode a single packed byte through the chunked layout. + auto accumulate_byte = [&](int b_abs, int k0) { + const uint8_t packed = w_tile[static_cast(b_abs / PACK_VEC) * chunk_stride + + static_cast(lane) * PACK_VEC + (b_abs % PACK_VEC)]; + int q0, q1; + decode_int4_pair(packed, q0, q1); + accumulate_pair(q0, q1, k0); }; const int kb_base = k_base / 2; @@ -853,30 +882,38 @@ void launch_int4_coalesced(sycl::queue* q, const ScalarT* activations, const uin // Prologue to the next 4-byte chunk boundary. Empty whenever // group_size % 8 == 0, i.e. for every shipped quant config. for (; kb < kb_count && ((kb_base + kb) % PACK_VEC) != 0; ++kb) { - accumulate_byte(load_byte(kb_base + kb), k_base + 2 * kb); + accumulate_byte(kb_base + kb, k_base + 2 * kb); } - using PackVec = sycl::vec; + // A lane's PACK_VEC == 4 bytes inside a chunk are contiguous, so + // they are exactly one little-endian 32-bit word: load it as such + // and decode all 8 nibbles with native DWORD ops (no 8-bit ALU, + // no sign-bias XOR for sym) via the shared octet primitive. for (; kb + PACK_VEC <= kb_count; kb += PACK_VEC) { const int b_abs = kb_base + kb; // 4-byte aligned here - PackVec pv = *reinterpret_cast( + const uint32_t word = *reinterpret_cast( w_tile + static_cast(b_abs / PACK_VEC) * chunk_stride + static_cast(lane) * PACK_VEC); - if constexpr (!Asym) { - // One vector XOR flips the sign bit of all 8 nibbles at once. - pv = pv ^ PackVec(static_cast(0x88)); - } + int qv[8]; + decode_int4_octet(word, qv); #pragma unroll for (int u = 0; u < PACK_VEC; ++u) { - accumulate_byte(pv[u], k_base + 2 * (kb + u)); + accumulate_pair(qv[2 * u], qv[2 * u + 1], k_base + 2 * (kb + u)); } } // Scalar tail for group sizes that are not a multiple of 8. for (; kb < kb_count; ++kb) { - accumulate_byte(load_byte(kb_base + kb), k_base + 2 * kb); + accumulate_byte(kb_base + kb, k_base + 2 * kb); } - for (int m = 0; m < nmembers; ++m) { - acc[m] += scale * ((acc_q0[m] + acc_q1[m]) - zero * a_sum_rows[m][g]); + if constexpr (Asym) { + const float zero = static_cast(z_row[g]); + for (int m = 0; m < nmembers; ++m) { + acc[m] += scale * ((acc_q0[m] + acc_q1[m]) - zero * a_sum_rows[m][g]); + } + } else { + for (int m = 0; m < nmembers; ++m) { + acc[m] += scale * (acc_q0[m] + acc_q1[m]); + } } } @@ -1323,12 +1360,7 @@ inline void moe_decode_release_scratch() { // benchmarks can toggle the path in-process. // ---------------------------------------------------------------------------- inline bool moe_decode_dpas_s4_enabled() { - const char* env = std::getenv("ARK_MOE_DECODE_DPAS_S4"); - if (env == nullptr) return true; // default ON - std::string s(env); - for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); - if (s == "0" || s == "false" || s == "off" || s == "no") return false; - return true; + return moe_decode_detail::env_flag_enabled("ARK_MOE_DECODE_DPAS_S4", true); // default ON } // ---------------------------------------------------------------------------- @@ -1374,12 +1406,7 @@ inline bool moe_decode_dpas_s4_occupancy_ok(int total_tokens, int num_experts) { // Re-read on every call so tests / benchmarks can toggle it in-process. // ---------------------------------------------------------------------------- inline bool moe_decode_coalesce_int4_enabled() { - const char* env = std::getenv("ARK_MOE_DECODE_COALESCE_INT4"); - if (env == nullptr) return true; // default ON - std::string s(env); - for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); - if (s == "0" || s == "false" || s == "off" || s == "no") return false; - return true; + return moe_decode_detail::env_flag_enabled("ARK_MOE_DECODE_COALESCE_INT4", true); // default ON } // ---------------------------------------------------------------------------- diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp index d142621fdc..ecc95f52c3 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp @@ -211,17 +211,31 @@ inline void decode_int2_quad(uint8_t packed, int q[4]) { // ... // q[7] = byte3 high nibble (k_base + 7) // -// The decoder is expressed as a `#pragma unroll` loop over `decode_int4_pair`, -// so it is bit-identical by construction to four scalar decodes of the same -// four bytes. This keeps the parity contract with the decode/GEMV path (which -// only ever calls `decode_int4_pair`) trivially satisfied. +// Collapsing that mapping, field `j` (K offset `j`) is simply bits +// `[4j+3 : 4j]` of the word, so every field can be extracted with a pair of +// *32-bit* ALU ops and the 8-bit datapath is never touched: +// asym: `(word >> 4j) & 0xF` +// sym : `(int)(word << (28 - 4j)) >> 28` -- park the nibble in the sign +// position, then arithmetic-shift it back down. This is exactly the +// 32-bit form of `int8_t(byte << 4) >> 4`, so the decoded integers are +// bit-identical to `decode_int4_pair` for all inputs and the +// decode/prefill parity contract is preserved. +// +// The 32-bit form matters on Xe: `sycl::vec` arithmetic and +// per-byte extraction lower to byte-typed regioning that IGC frequently has to +// expand, and that expansion is what made the sym sign-extension look +// inherently more expensive than the asym mask+shift. Both modes now issue the +// same two native DWORD operations per nibble. // ---------------------------------------------------------------------------- template inline void decode_int4_octet(uint32_t packed, int q[8]) { #pragma unroll - for (int i = 0; i < 4; ++i) { - const uint8_t byte = static_cast((packed >> (i * 8)) & 0xFFu); - decode_int4_pair(byte, q[2 * i], q[2 * i + 1]); + for (int j = 0; j < 8; ++j) { + if constexpr (Asym) { + q[j] = static_cast((packed >> (4 * j)) & 0xFu); + } else { + q[j] = static_cast(packed << (28 - 4 * j)) >> 28; + } } } From a2827941df6c5a9d8fa440d6d11848ed0411e7b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:29:01 +0000 Subject: [PATCH 040/112] docs: update int4 decode perf notes for word-native nibble decode Rewrite the sym-decode, coalesced-repack and activation-sum sections of README_MOE_PREFILL_PERF.md and its CN counterpart to describe the 32-bit word decode path and the asym-only activation-sum pre-pass. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_PREFILL_PERF.md | 92 +++++++++++-------- .../ark/test/README_MOE_PREFILL_PERF_CN.md | 58 +++++++----- 2 files changed, 90 insertions(+), 60 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index 2d624c2c0c..090bb3e1cf 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -346,54 +346,70 @@ expert. threshold; `0` disables the gate (always DPAS when the shape gate allows), which is what the accuracy and DPAS-vs-scalar perf tests set. -**Sym decodes through the asym nibble path.** Once both modes shared the -scalar GEMV, int4-sym was still ~1.9x slower than int4-asym in the *same* -kernel (2.83 ms vs 1.49 ms at bs32) despite doing strictly fewer floating -point operations. The only asymmetry was the nibble decode: sym -sign-extended each nibble with `(int8_t)(byte << 4) >> 4`, a serial -shift/narrow/shift chain per nibble, while asym used a plain mask+shift -that vectorizes over the whole loaded byte vector. Sym now uses the -sign-flip identity `signed == (unsigned ^ 8) - 8`: one vector XOR of the -packed bytes with `0x88` flips both nibbles' sign bits, after which sym is -*literally* the asym computation with a constant zero-point of 8 (same -unsigned decode, same `sum a` accumulator, same single per-group -scale/zero fold). The decoded integers are bit-identical to the previous -sign-extending decode for all 256 byte values; the only change is that -sym now accumulates the biased sum and subtracts `8 * sum a` at the end, -which is exactly the fp32 accumulation pattern asym has always used. It -applies to both `launch_int4` and `launch_int4_coalesced`. +**Word-native nibble decode; sym keeps its signed nibbles.** Once both +modes shared the scalar GEMV, int4-sym was still slower than int4-asym in +the *same* kernel despite doing strictly fewer floating point operations. +The asymmetry was the nibble decode, and the first attempt at fixing it +(the `^ 0x88` sign-flip identity `signed == (unsigned ^ 8) - 8`) did not +close the gap: it kept sym on 8-bit-typed operations — a `sycl::vec` +XOR plus per-byte mask/shift — which Xe expands into narrow-type ALU work +rather than executing on the native 32-bit datapath, and it forced sym to +carry a constant zero-point of 8 (see *activation sums* below). + +Both modes now decode through the shared `decode_int4_octet` primitive, +which takes the 8 nibbles of a packed *32-bit word* and extracts each one +with a single DWORD shift/mask pair (asym) or a DWORD shift-left + +arithmetic shift-right pair (sym). No 8-bit-typed vector, no XOR, no +narrowing casts, and one 32-bit load per 8 K elements instead of a byte +vector. The per-nibble results are bit-identical to `decode_int4_pair` for +every one of the 2^32 input words in both modes (verified exhaustively), so +this is a pure instruction-selection change. It applies to `launch_int4`, +`launch_int4_coalesced`, and — since the primitive is shared — the prefill +mixed-dtype path. + +Because sym once again recovers *true signed* nibbles, its per-group fold +collapses to `acc += scale * Σ a·q` with no zero-point term at all, whereas +asym keeps `acc += scale * (Σ a·q − zero · Σ a)`. **4-byte-blocked coalesced repack.** The coalesced fallback (`launch_int4_coalesced`, `ARK_MOE_DECODE_COALESCE_INT4` default ON) repacks the `[E, N, K/2]` weights on-device so sub-group loads are contiguous. The original repack layout `[E, N/16, K/2, 16]` put one byte per lane per step, so although the 16 lanes together covered one cache -line, each lane still issued a *byte* load — and for sym, one scalar -`^ 0x88` per byte. The layout is now `[E, N/16, ceil(K/8), 16, 4]`: a -chunk holds four consecutive packed bytes for each of the 16 columns of a -tile, lane-major, so lane `l` reads its four bytes at chunk offset `l*4` -and the sub-group still spans 64 contiguous bytes. Each lane therefore -issues one `vec` load instead of four byte loads (4× fewer -weight-load instructions), and the sym sign flip becomes one vector XOR -per chunk instead of four scalar XORs — removing the last per-byte -instruction sym paid over asym in this kernel. Group sizes that are a -multiple of 8 (16/32/64/128/256 — every shipped quant config) start each -K-group on a chunk boundary so the vector stage covers the whole group; -other even group sizes fall back to a scalar prologue/epilogue over the -same layout. The external `[E, N, K/2]` weight contract is unchanged. - -**Hoisted activation sums.** Both int4 GEMVs fold the per-group -scale/zero as `scale * (Σ a·q − zero · Σ a)`. `Σ a` depends only on the -activation row and the K-group, not on the output column, yet it used to -be recomputed inside the inner loop — once per sub-group lane (16× +line, each lane still issued a *byte* load. The layout is now +`[E, N/16, ceil(K/8), 16, 4]`: a chunk holds four consecutive packed bytes +for each of the 16 columns of a tile, lane-major, so lane `l` reads its +four bytes at chunk offset `l*4` and the sub-group still spans 64 +contiguous bytes. A lane's four bytes are contiguous, hence exactly one +little-endian 32-bit word: the lane issues a single DWORD load (4× fewer +weight-load instructions) and feeds it straight to `decode_int4_octet`, so +all eight nibbles come out with native 32-bit ops in both modes. Group +sizes that are a multiple of 8 (16/32/64/128/256 — every shipped quant +config) start each K-group on a chunk boundary so the vector stage covers +the whole group; other even group sizes fall back to a scalar +prologue/epilogue over the same layout. The external `[E, N, K/2]` weight +contract is unchanged. + +**Hoisted activation sums (asym only).** The asym int4 GEMVs fold the +per-group scale/zero as `scale * (Σ a·q − zero · Σ a)`. `Σ a` depends only +on the activation row and the K-group, not on the output column, yet it +used to be recomputed inside the inner loop — once per sub-group lane (16× redundant) and again for every N-tile work-group — costing one extra float add per K element. It is now precomputed once into a `[total_tokens, K/group_size]` fp32 table (`launch_act_group_sums`), so the GEMV inner loop only accumulates `Σ a·q` and reads one float per -group. Sym benefits most: for sym `Σ a` exists purely to carry the -constant zero-point of 8 that the `^ 0x88` decode introduces. The -summation order changes by a few fp32 ULPs, far inside the kernel's -quantization tolerance. +group. The summation order changes by a few fp32 ULPs, far inside the +kernel's quantization tolerance. + +**Sym skips the pre-pass entirely.** `launch_act_group_sums` is a separate +`parallel_for`, and on an in-order queue it fully serializes ahead of the +GEMV. That is a poor trade at decode sizes: it saves one float add per K +element in a loop that is already memory-bound, but adds a whole kernel +dispatch to a call whose GEMV is only tens of microseconds at bs1 — which +is why routing sym through the biased-unsigned decode made sym *slower*, +not faster. Now that sym decodes true signed nibbles it has no zero-point +term, so the table is computed (and the kernel launched) only when +`Asym` is true. **Pooled scratch instead of per-call `malloc_device`.** The repack buffer used to be a transient USM allocation that had to be freed behind a diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index fabb2948eb..201a2bb49b 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -270,44 +270,58 @@ int4-sym(DPAS)为 0.31–0.34 ms / 1.55 ms,而 int4-asym(标量 GEMV)为 "每专家 token 数" 阈值;设为 `0` 则关闭门控(只要形状门控通过就走 DPAS), 精度测试与 DPAS/标量 对比性能测试即使用该设置。 -**sym 直接复用 asym 的 nibble 解码路径。** 在两者都走标量 GEMV 之后, -int4-sym 在 *同一个* kernel 里仍比 int4-asym 慢约 1.9 倍(bs32:2.83 ms -vs 1.49 ms),尽管 sym 的浮点运算严格更少。唯一的差异在于 nibble 解码: -sym 使用 `(int8_t)(byte << 4) >> 4` 逐 nibble 做符号扩展,这是一条 -移位/截断/移位 的串行依赖链;而 asym 的 掩码+移位 形式可以在整个已加载 -的字节向量上向量化。现在 sym 改用符号翻转恒等式 -`signed == (unsigned ^ 8) - 8`:对打包字节做一次向量 `^ 0x88`,同时翻转 -两个 nibble 的符号位,之后 sym *就是* zero-point 恒为 8 的 asym 计算 -(相同的无符号解码、相同的 `sum a` 累加器、相同的每组一次 scale/zero -折叠)。对全部 256 种字节取值,解码出的整数与原来的符号扩展逐位相同, -唯一的变化是 sym 现在累加的是有偏置的和,最后再减去 `8 * sum a`, -这正是 asym 一直在用的 fp32 累加方式;`launch_int4` 与 `launch_int4_coalesced` -两个 kernel 均已应用。 +**基于 32 位字的 nibble 解码;sym 恢复真正的有符号 nibble。** 在两者都走 +标量 GEMV 之后,int4-sym 在 *同一个* kernel 里仍比 int4-asym 慢,尽管 sym +的浮点运算严格更少。差异在于 nibble 解码,而第一次尝试的修复(符号翻转 +恒等式 `signed == (unsigned ^ 8) - 8`,即 `^ 0x88`)并没有弥合差距:它让 +sym 仍然停留在 8 位类型的运算上 —— 一次 `sycl::vec` 的 XOR 加上 +逐字节的 掩码/移位 —— 而 Xe 会把这类窄类型运算展开,无法直接跑在原生 +32 位数据通路上;同时它还迫使 sym 携带一个恒为 8 的 zero-point(见下面的 +"激活求和")。 + +现在两种模式都通过共享的 `decode_int4_octet` 原语解码:它接收一个打包的 +*32 位字* 中的 8 个 nibble,每个 nibble 只用一对 DWORD 移位/掩码(asym) +或一对 DWORD 左移 + 算术右移(sym)即可取出。没有 8 位类型的向量,没有 +XOR,没有窄化转换,并且每 8 个 K 元素只需一次 32 位加载而不是一次字节 +向量加载。在两种模式下,对全部 2^32 种输入字,逐 nibble 的结果都与 +`decode_int4_pair` 逐位相同(已穷举验证),因此这纯粹是指令选择层面的改动。 +它应用于 `launch_int4`、`launch_int4_coalesced`,并且由于该原语是共享的, +prefill 的混合精度路径同样受益。 + +由于 sym 重新恢复了 *真正的有符号* nibble,它的每组折叠退化为 +`acc += scale * Σ a·q`,完全没有 zero-point 项;asym 则仍是 +`acc += scale * (Σ a·q − zero · Σ a)`。 **按 4 字节分块的 coalesced repack。** coalesced 回退路径 (`launch_int4_coalesced`,`ARK_MOE_DECODE_COALESCE_INT4` 默认开启)会在设备端把 `[E, N, K/2]` 权重重排,使 sub-group 的加载连续。原先的重排布局 `[E, N/16, K/2, 16]` 每个 lane 每步只放一个字节,因此虽然 16 个 lane 合起来覆盖 -一条 cache line,每个 lane 仍然发出的是*字节*加载 —— 对 sym 而言还额外附带每字节 -一次标量 `^ 0x88`。现在布局改为 `[E, N/16, ceil(K/8), 16, 4]`:一个 chunk 为 +一条 cache line,每个 lane 仍然发出的是*字节*加载。现在布局改为 +`[E, N/16, ceil(K/8), 16, 4]`:一个 chunk 为 tile 内 16 列中的每一列存放 4 个连续的打包字节,按 lane 主序排列,因此 lane `l` 在 chunk 偏移 `l*4` 处读取自己的 4 个字节,sub-group 整体仍然覆盖 64 个连续字节。 -于是每个 lane 只需一次 `vec` 加载而不是四次字节加载(权重加载指令数 -降为 1/4),sym 的符号翻转也变成每个 chunk 一次向量 XOR 而不是四次标量 XOR —— -这消除了该 kernel 中 sym 相对 asym 仅存的逐字节额外指令。group_size 为 8 的倍数时 +一个 lane 的这 4 个字节是连续的,因此恰好构成一个小端 32 位字:lane 只需发出 +一次 DWORD 加载(权重加载指令数降为 1/4),并直接交给 `decode_int4_octet`, +两种模式下 8 个 nibble 都用原生 32 位运算取出。group_size 为 8 的倍数时 (16/32/64/128/256,即全部已发布的量化配置)每个 K 组都从 chunk 边界开始,向量 阶段覆盖整个组;其他偶数 group_size 则通过标量前导/收尾循环在同一布局上处理。 对外的 `[E, N, K/2]` 权重约定保持不变。 -**提取激活求和。** 两个 int4 GEMV 都按 +**提取激活求和(仅 asym)。** asym 的 int4 GEMV 按 `scale * (Σ a·q − zero · Σ a)` 折叠每组的 scale/zero。`Σ a` 只依赖激活行与 K 组, 与输出列无关,但此前它是在内层循环里重复计算的 —— 每个 sub-group lane 算一遍 (16 倍冗余),每个 N-tile work-group 再算一遍 —— 每个 K 元素多付出一次浮点加法。 现在它被预先计算成一张 `[total_tokens, K/group_size]` 的 fp32 表 (`launch_act_group_sums`),GEMV 内层循环只累加 `Σ a·q`,每组读取一个 float。 -sym 获益最大:对 sym 来说 `Σ a` 的存在纯粹是为了承载 `^ 0x88` 解码引入的常数 -zero-point 8。求和顺序的变化仅带来几个 fp32 ULP 的差异,远在 kernel 现有的 -量化容差之内。 +求和顺序的变化仅带来几个 fp32 ULP 的差异,远在 kernel 现有的量化容差之内。 + +**sym 完全跳过这一前置 pass。** `launch_act_group_sums` 是一个独立的 +`parallel_for`,在 in-order queue 上会完全串行地排在 GEMV 之前。对 decode +规模而言这笔交易并不划算:它在一个本就受访存带宽限制的循环里省下每个 K +元素一次浮点加法,却给一次 GEMV 仅几十微秒(bs1)的调用额外增加了一整次 +kernel 派发 —— 这正是让 sym 走"有偏置无符号解码"反而变*慢*的原因。现在 +sym 解码出真正的有符号 nibble,不含 zero-point 项,因此只有在 `Asym` 为真 +时才会计算该表(并派发该 kernel)。 **用 scratch 池替代每次调用的 `malloc_device`。** repack 缓冲区原本是临时的 USM 分配,每次 decode 调用都必须在一次阻塞的 `queue::wait()` 之后释放 —— 而 decode From 6cd81921310670f30ec3439a5448692eb59f18cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:48:51 +0000 Subject: [PATCH 041/112] perf: word-native FP8 decode + decode-tuned FP8 DPAS dispatch Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 337 +++++++++++++----- .../wrapper/include/sycl_tla_moe_dequant.hpp | 133 +++++++ .../include/sycl_tla_moe_prefill_fp8_dpas.hpp | 111 ++++++ .../include/sycl_tla_moe_prefill_s4_dpas.hpp | 32 +- 4 files changed, 499 insertions(+), 114 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 9ae797dfb6..1c03de22ef 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -63,20 +63,25 @@ // ---------------------------------------------------------------------------- // FP8 decode implementation switch (runtime) // -// FP8 weight bytes can be dequantized either via inline bit manipulation or -// via the 128-entry magnitude LUT in `bestla/sycl/fp8_lut.h` (sign applied -// separately). Both paths are mathematically equivalent for finite values; -// pick whichever is faster on the target hardware. +// FP8 weight bytes can be dequantized three ways, all mathematically equivalent +// for the values a real checkpoint contains: +// - word : convert four bytes of a 32-bit weight word straight into four fp16 +// bit patterns with native DWORD field moves, folding E4M3's +// residual 2^-8 into the per-K-group scale. No memory traffic, no +// 8-bit ALU ops. +// - lut : the 128-entry magnitude LUT in `bestla/sycl/fp8_lut.h` (sign +// applied separately). +// - bits : self-contained inline bit manipulation. // -// Selection is done at runtime through the environment variable -// `ARK_FP8_DECODE_USE_LUT`: -// - unset / "1" / "true" / "on" / "yes" (case-insensitive) -> LUT path (default) -// - "0" / "false" / "off" / "no" (case-insensitive) -> inline bit-manip +// Selection is done at runtime through `ARK_FP8_DECODE_MODE` ("word" / "lut" / +// "bits", case-insensitive), defaulting to "word". The legacy +// `ARK_FP8_DECODE_USE_LUT` variable still works when set explicitly and keeps +// its old meaning (truthy -> lut, falsy -> bits). // -// The env var is read once on the host (cached) and passed as a template -// parameter into the SYCL kernel, so there is no per-element runtime branch. -// The actual primitives live in `sycl_tla_moe_dequant.hpp` (shared with the -// mixed-input prefill path); this file just re-exports them via `using`. +// The env var is read on the host and passed as a template parameter into the +// SYCL kernel, so there is no per-element runtime branch. The actual primitives +// live in `sycl_tla_moe_dequant.hpp` (shared with the mixed-input prefill +// path); this file just re-exports them via `using`. // ---------------------------------------------------------------------------- #if defined(ARK_XPU) && defined(ARK_SYCL_TLA) @@ -146,7 +151,7 @@ class MoEDecodeKernelInt8; template class MoEDecodeKernelInt2; -template +template class MoEDecodeKernelFP8; // ---------------------------------------------------------------------------- @@ -156,16 +161,21 @@ class MoEDecodeKernelFP8; // keep the in-kernel call sites (`decode_fp8<...>(byte)`) and the host-side // `fp8_decode_use_lut()` lookup inside `moe_decode_detail` working unchanged. // ---------------------------------------------------------------------------- +using moe_dequant::Fp8DecodeMode; using moe_dequant::decode_fp8; using moe_dequant::decode_fp8_e4m3_bits; using moe_dequant::decode_fp8_e4m3_lut; using moe_dequant::decode_fp8_e5m2_bits; using moe_dequant::decode_fp8_e5m2_lut; +using moe_dequant::decode_fp8_half_bits; +using moe_dequant::decode_fp8_quad_half_bits; using moe_dequant::decode_int2_quad; using moe_dequant::decode_int4_octet; using moe_dequant::decode_int4_pair; using moe_dequant::decode_int8; +using moe_dequant::fp8_decode_mode; using moe_dequant::fp8_decode_use_lut; +using moe_dequant::fp8_word_scale_bias; // ---------------------------------------------------------------------------- // Build a [total_tokens] -> expert_id mapping from num_tokens_per_expert. @@ -1213,10 +1223,24 @@ void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // ---------------------------------------------------------------------------- // FP8 (E4M3 / E5M2) GEMV with group-wise scale (no zero-point). // -// Weights are 1 FP8 byte per element [E, N, K]. The byte is decoded via the -// `decode_fp8` helper, which selects between the LUT and the -// inline bit-manipulation path at compile time. The choice is driven at -// launch time by the env var `ARK_FP8_DECODE_USE_LUT` (default: ON). +// Weights are 1 FP8 byte per element [E, N, K]. How a byte becomes a float is +// chosen at launch time by `fp8_decode_mode()` and passed in as the `Mode` +// template parameter, so the hot path stays branch-free: +// +// * `kWord` (default) -- the four bytes of a 32-bit weight word are turned +// into four fp16 bit patterns by `decode_fp8_quad_half_bits`, i.e. a couple +// of native DWORD ops and no memory traffic at all. This mirrors the +// word-native `decode_int4_octet` treatment that made int4 decode fast: Xe +// ALU lanes are 32-bit, so the previous `sycl::vec` weight +// vector plus per-byte decode paid narrow-type regioning on *every* weight +// element, and the LUT variant additionally issued one load per element in +// a loop that already does only ~1 MAC per byte. +// For E4M3 the field move leaves a constant 2^-8 factor, which is folded +// into the per-K-group scale below (`fp8_word_scale_bias`), so it costs +// nothing per element. +// +// * `kLut` / `kBits` -- the original per-byte `decode_fp8` +// decoders, kept for A/B measurement and regression escape. // ---------------------------------------------------------------------------- // Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK weight @@ -1225,33 +1249,79 @@ void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // remainder, mirroring the int4/int8 paths. sycl::vec only supports widths of // 1, 2, 3, 4, 8 or 16, so CHUNK is processed in 16-wide sub-blocks. // +// In `kWord` mode the 16 weight bytes of a sub-block are read as a +// `sycl::vec` -- the same 16-byte transaction (and the same +// 16-byte alignment requirement) as the byte vector it replaces, but 32-bit +// typed, so the decode never leaves the native datapath. +// // The per-group scale is constant across the whole group, so it is NOT applied // here: this accumulates the raw dot product (sum of act * decoded_fp8) and the // caller multiplies the group total by the scale once (Σ a·(w·s) == s·Σ a·w). // For the per-expert / per-tensor scale case (group_size == K, one scale per // output row) this collapses the whole K reduction to a single scale multiply, // removing one multiply per K element on the decode hot path. -template -static inline void fp8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc) { +// +// Two independent partial accumulators break the single fp32 dependency chain +// so the FMA pipeline is not latency-bound (same trick as `int4_decode_chunk`); +// the caller reduces the pair. +template +static inline void fp8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc0, + float& acc1) { static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); constexpr int SUB = 16; using ActVec = sycl::vec; - using ByteVec = sycl::vec; #pragma unroll for (int s = 0; s < CHUNK / SUB; ++s) { const ActVec av = *reinterpret_cast(act_ptr + s * SUB); - const ByteVec wv = *reinterpret_cast(w_ptr + s * SUB); + if constexpr (Mode == Fp8DecodeMode::kWord) { + constexpr int WORDS = SUB / 4; // one 32-bit word per 4 FP8 bytes + using WordVec = sycl::vec; + const WordVec wv = *reinterpret_cast(w_ptr + s * SUB); #pragma unroll - for (int u = 0; u < SUB; ++u) { - const float w = decode_fp8(wv[u]); - const ScalarT a = sycl::bit_cast(static_cast(av[u])); - acc += static_cast(a) * w; + for (int w = 0; w < WORDS; ++w) { + uint32_t lo2, hi2; + decode_fp8_quad_half_bits(wv[w], lo2, hi2); + const uint16_t hb[4] = {static_cast(lo2), static_cast(lo2 >> 16), + static_cast(hi2), static_cast(hi2 >> 16)}; +#pragma unroll + for (int u = 0; u < 4; u += 2) { + const ScalarT a0 = sycl::bit_cast(static_cast(av[4 * w + u])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[4 * w + u + 1])); + acc0 += static_cast(a0) * static_cast(sycl::bit_cast(hb[u])); + acc1 += static_cast(a1) * static_cast(sycl::bit_cast(hb[u + 1])); + } + } + } else { + constexpr bool kUseLut = (Mode == Fp8DecodeMode::kLut); + using ByteVec = sycl::vec; + const ByteVec wv = *reinterpret_cast(w_ptr + s * SUB); +#pragma unroll + for (int u = 0; u < SUB; u += 2) { + const float w0 = decode_fp8(wv[u]); + const float w1 = decode_fp8(wv[u + 1]); + const ScalarT a0 = sycl::bit_cast(static_cast(av[u])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[u + 1])); + acc0 += static_cast(a0) * w0; + acc1 += static_cast(a1) * w1; + } } } } -template +// Single-byte decode matching `fp8_decode_chunk`'s convention: in `kWord` mode +// the returned value carries the same folded 2^-8 bias as the vector stage, so +// the scalar tail can share the group accumulator. +template +static inline float fp8_decode_scalar(uint8_t raw) { + if constexpr (Mode == Fp8DecodeMode::kWord) { + return static_cast(sycl::bit_cast(decode_fp8_half_bits(raw))); + } else { + return decode_fp8(raw); + } +} + +template void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, int group_size) { if (N % N_TILE != 0) { @@ -1264,11 +1334,15 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh const int n_tiles = N / N_TILE; const int num_groups_k = K / group_size; + // Undoes the exponent re-bias the word-native decode leaves behind (1.0f for + // every other mode). Exact power of two, applied once per K-group. + constexpr float kScaleBias = + (Mode == Fp8DecodeMode::kWord) ? fp8_word_scale_bias() : 1.0f; sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; sycl::range<2> local{1, static_cast(SG_SIZE)}; - q->parallel_for>( + q->parallel_for>( sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { const int token = static_cast(it.get_global_id(0)); @@ -1286,7 +1360,7 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh float acc = 0.0f; for (int g = 0; g < num_groups_k; ++g) { - const float scale = static_cast(s_row[g]); + const float scale = static_cast(s_row[g]) * kScaleBias; const int k_base = g * group_size; // Vectorized ladder mirroring the int4/int8 paths: process 32 // K-elements (32 weight bytes + vec activations) at a @@ -1297,32 +1371,57 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh // load for the shipped group sizes (32/64/128/256); hoisting the // scale removes one multiply per K element, which is the dominant // cost for the per-expert / per-tensor scale case (group_size == K). - float group_acc = 0.0f; + // Two partial accumulators break the fp32 dependency chain. + float group_acc0 = 0.0f; + float group_acc1 = 0.0f; int kk = 0; constexpr int CHUNK32 = 32; const int end32 = (group_size / CHUNK32) * CHUNK32; for (; kk < end32; kk += CHUNK32) { - fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, - group_acc); + fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, + group_acc0, group_acc1); } constexpr int CHUNK16 = 16; const int end16 = kk + ((group_size - kk) / CHUNK16) * CHUNK16; for (; kk < end16; kk += CHUNK16) { - fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, - group_acc); + fp8_decode_chunk(act_row + k_base + kk, w_row + k_base + kk, + group_acc0, group_acc1); } for (; kk < group_size; ++kk) { - const uint8_t raw = w_row[k_base + kk]; - const float w = decode_fp8(raw); - group_acc += static_cast(act_row[k_base + kk]) * w; + const float w = fp8_decode_scalar(w_row[k_base + kk]); + group_acc0 += static_cast(act_row[k_base + kk]) * w; } - acc += group_acc * scale; + acc += (group_acc0 + group_acc1) * scale; } outputs[static_cast(token) * N + n_global] = static_cast(acc); }); } +// Runtime -> compile-time bridge for the decode-mode selector. Keeps the +// `moe_gemm_decode` dispatch to one branch per (act dtype, format) instead of +// re-nesting the mode selection at every call site. +template +void launch_fp8_by_mode(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, + const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int group_size) { + switch (fp8_decode_mode()) { + case Fp8DecodeMode::kLut: + launch_fp8(q, activations, weights, scales, outputs, + expert_id_per_token, total_tokens, N, K, group_size); + return; + case Fp8DecodeMode::kBits: + launch_fp8(q, activations, weights, scales, outputs, + expert_id_per_token, total_tokens, N, K, group_size); + return; + case Fp8DecodeMode::kWord: + default: + launch_fp8(q, activations, weights, scales, outputs, + expert_id_per_token, total_tokens, N, K, group_size); + return; + } +} + } // namespace moe_decode_detail // ---------------------------------------------------------------------------- @@ -1396,6 +1495,52 @@ inline bool moe_decode_dpas_s4_occupancy_ok(int total_tokens, int num_experts) { return static_cast(total_tokens) >= min_tokens_per_expert * static_cast(num_experts); } +// ---------------------------------------------------------------------------- +// Env-flag helper -- `ARK_MOE_DECODE_DPAS_FP8` (default ON). When ON, FP8 +// (E4M3 / E5M2, sym) decode is routed to the decode-phase FP8 DPAS grouped +// GEMM (`moe_dpas_fp8::moe_decode_fp8_dpas_per_group_dispatch`) instead of the +// scalar FMA GEMV (`launch_fp8`). This is the FP8 twin of +// `ARK_MOE_DECODE_DPAS_S4`: same `[E, N, K]` FP8 bytes and `[E, N, K/group]` +// scales, no repack, tile picked from the `A_avg_M` ladder. +// +// Setting the var to "0" / "false" / "off" / "no" (case-insensitive) forces the +// scalar GEMV, for A/B comparison and regression escape. Shapes that fail the +// DPAS shape gate and batches that fail the tokens-per-expert occupancy gate +// (`moe_decode_dpas_fp8_occupancy_ok`, below -- this is what keeps real decode +// batches on the fast scalar GEMV) always fall back to the scalar path +// regardless of this flag. Re-read on every call so tests / benchmarks can +// toggle the path in-process. +// ---------------------------------------------------------------------------- +inline bool moe_decode_dpas_fp8_enabled() { + return moe_decode_detail::env_flag_enabled("ARK_MOE_DECODE_DPAS_FP8", true); // default ON +} + +// ---------------------------------------------------------------------------- +// Occupancy gate for the FP8 DPAS decode path. Identical reasoning to +// `moe_decode_dpas_s4_occupancy_ok`: the smallest DPAS tile the decode +// dispatch can pick (`dpas_w4a16_policy_m_8`) processes 8 token rows per +// expert, so below 8 tokens per expert on average the tile is mostly padding +// and the bandwidth-bound FP8 weights get streamed for rows that contribute +// nothing -- exactly the regime real decode batches live in. Above that the +// DPAS pipeline wins, so the threshold is where the two cross. +// +// `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` overrides the tokens-per-expert threshold; +// "0" disables the gate (always take DPAS when the shape gate allows), which is +// what the accuracy tests use to exercise the DPAS kernel on tiny shapes. +// ---------------------------------------------------------------------------- +inline bool moe_decode_dpas_fp8_occupancy_ok(int total_tokens, int num_experts) { + if (num_experts <= 0) return true; + long long min_tokens_per_expert = 8; // rows in `dpas_w4a16_policy_m_8` + const char* env = std::getenv("ARK_MOE_DECODE_DPAS_FP8_MIN_TPE"); + if (env != nullptr) { + char* end = nullptr; + long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 0) min_tokens_per_expert = v; + } + if (min_tokens_per_expert == 0) return true; + return static_cast(total_tokens) >= min_tokens_per_expert * static_cast(num_experts); +} + // ---------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_DECODE_COALESCE_INT4` (default ON). When ON, the // int4 scalar-GEMV fallback (asym, or sym with the DPAS path disabled / shape @@ -1456,22 +1601,32 @@ inline bool moe_decode_coalesce_int4_amortized(int total_tokens, int num_experts // BTLA_DTYPE::S2_CLIP : packed int2 weights [E, N, K/4] (uint8), // 4 values per byte, sym/asym like int4 // BTLA_DTYPE::F8_E4M3 / F8_E5M2 : FP8 weights [E, N, K] (uint8 buffer), -// group-wise scales, no zero-points +// group-wise scales, no zero-points. Routed +// to the per-group FP8 DPAS grouped GEMM only +// when the batch fills its M tile (>= 8 +// tokens per expert on average, +// `ARK_MOE_DECODE_DPAS_FP8` default ON); a +// disabled flag, a shape-gate miss, or a +// decode-sized batch uses the scalar GEMV. // act_dtype: F16 or BF16 (must match scales/outputs dtype) // ---------------------------------------------------------------------------- inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, void* scales, void* zeros, void* outputs, int* expert_id_per_token_buf, BTLA_DTYPE act_dtype, BTLA_DTYPE weight_dtype, int N, int K, int group_size, int* num_tokens_per_expert, int num_experts, int total_tokens, bool asym) { - // The S4-sym DPAS fast path consumes `num_tokens_per_expert` directly and - // never reads `expert_id_per_token_buf`. Skipping the fill on that path + // The S4-sym and FP8 DPAS fast paths consume `num_tokens_per_expert` directly + // and never read `expert_id_per_token_buf`. Skipping the fill on those paths // removes an extra device-timeline kernel launch from the decode hot path; - // every other path (fp, int8, int2, fp8, and the scalar int4 fallback) still - // needs the per-token expert mapping. + // every other path (fp, int8, int2, and the scalar int4 / fp8 fallbacks) + // still needs the per-token expert mapping. const bool s4_dpas_fastpath = weight_dtype == BTLA_DTYPE::S4_CLIP && !asym && moe_decode_dpas_s4_enabled() && moe_decode_dpas_s4_occupancy_ok(total_tokens, num_experts) && moe_dpas_s4::moe_prefill_dpas_s4_pergroup_shape_ok(N, K, group_size); - if (!s4_dpas_fastpath) { + const bool fp8_dpas_fastpath = (weight_dtype == BTLA_DTYPE::F8_E4M3 || weight_dtype == BTLA_DTYPE::F8_E5M2) && + !asym && moe_decode_dpas_fp8_enabled() && + moe_decode_dpas_fp8_occupancy_ok(total_tokens, num_experts) && + moe_dpas_fp8::moe_prefill_dpas_fp8_pergroup_shape_ok(N, K, group_size); + if (!s4_dpas_fastpath && !fp8_dpas_fastpath) { moe_decode_detail::fill_expert_id_per_token(q, expert_id_per_token_buf, num_tokens_per_expert, num_experts, total_tokens); } @@ -1655,63 +1810,71 @@ inline void moe_gemm_decode(sycl::queue* q, void* activations, void* weights, vo if (asym) { throw std::invalid_argument("moe_gemm_decode(fp8): asym mode is not supported"); } + if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { + throw std::invalid_argument("moe_gemm_decode(fp8): act_dtype must be FP16 or BF16"); + } const bool is_e4m3 = (weight_dtype == BTLA_DTYPE::F8_E4M3); - const bool use_lut = moe_decode_detail::fp8_decode_use_lut(); - if (act_dtype == BTLA_DTYPE::F16) { - if (is_e4m3) { - if (use_lut) { - moe_decode_detail::launch_fp8( + // Fast path: FP8 through the decode-phase per-group DPAS grouped GEMM. + // Falls back to the scalar GEMV when the env flag is off, when the batch is + // too small to fill the DPAS M tile (the usual decode case), or when the + // shape gate rejects the tile geometry (e.g. N%64!=0, K%32!=0, unsupported + // group_size). Reuses the `fp8_dpas_fastpath` predicate computed above + // (which also gated the `fill_expert_id_per_token` skip) so the two + // decisions cannot diverge. + if (fp8_dpas_fastpath) { + if (act_dtype == BTLA_DTYPE::F16) { + if (is_e4m3) { + moe_dpas_fp8::moe_decode_fp8_dpas_per_group_dispatch( q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, - total_tokens, N, K, group_size); + static_cast(scales), static_cast(outputs), num_tokens_per_expert, + num_experts, N, K, group_size, total_tokens); } else { - moe_decode_detail::launch_fp8( + moe_dpas_fp8::moe_decode_fp8_dpas_per_group_dispatch( q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, - total_tokens, N, K, group_size); + static_cast(scales), static_cast(outputs), num_tokens_per_expert, + num_experts, N, K, group_size, total_tokens); } } else { - if (use_lut) { - moe_decode_detail::launch_fp8( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, - total_tokens, N, K, group_size); - } else { - moe_decode_detail::launch_fp8( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, - total_tokens, N, K, group_size); - } - } - } else if (act_dtype == BTLA_DTYPE::BF16) { - using BF = sycl::ext::oneapi::bfloat16; - if (is_e4m3) { - if (use_lut) { - moe_decode_detail::launch_fp8( + using BF = sycl::ext::oneapi::bfloat16; + if (is_e4m3) { + moe_dpas_fp8::moe_decode_fp8_dpas_per_group_dispatch( q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, - group_size); + static_cast(scales), static_cast(outputs), num_tokens_per_expert, num_experts, N, K, + group_size, total_tokens); } else { - moe_decode_detail::launch_fp8( + moe_dpas_fp8::moe_decode_fp8_dpas_per_group_dispatch( q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, - group_size); + static_cast(scales), static_cast(outputs), num_tokens_per_expert, num_experts, N, K, + group_size, total_tokens); } + } + return; + } + if (act_dtype == BTLA_DTYPE::F16) { + if (is_e4m3) { + moe_decode_detail::launch_fp8_by_mode( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(outputs), expert_id_per_token_buf, + total_tokens, N, K, group_size); } else { - if (use_lut) { - moe_decode_detail::launch_fp8( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, - group_size); - } else { - moe_decode_detail::launch_fp8( - q, static_cast(activations), static_cast(weights), - static_cast(scales), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, - group_size); - } + moe_decode_detail::launch_fp8_by_mode( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(outputs), expert_id_per_token_buf, + total_tokens, N, K, group_size); } } else { - throw std::invalid_argument("moe_gemm_decode(fp8): act_dtype must be FP16 or BF16"); + using BF = sycl::ext::oneapi::bfloat16; + if (is_e4m3) { + moe_decode_detail::launch_fp8_by_mode( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, + group_size); + } else { + moe_decode_detail::launch_fp8_by_mode( + q, static_cast(activations), static_cast(weights), + static_cast(scales), static_cast(outputs), expert_id_per_token_buf, total_tokens, N, K, + group_size); + } } return; } diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp index ecc95f52c3..736ab0e3c0 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp @@ -10,6 +10,11 @@ // Currently extracted: // - FP8 (E4M3 / E5M2) byte->float decoders + host-side // `ARK_FP8_DECODE_USE_LUT` env-var reader (PR-A1). +// - FP8 word-native decoders (`decode_fp8_half_bits`, +// `decode_fp8_quad_half_bits`, `fp8_word_scale_bias`) + the +// `Fp8DecodeMode` selector: convert FP8 bytes to fp16 bit patterns with +// pure 32-bit field moves (no LUT load, no 8-bit ALU), folding E4M3's +// residual 2^-8 into the per-K-group scale. Used by the decode GEMV. // - INT2 / INT4 / INT8 packed-byte decoders (PR-A2): return the raw // integer field(s) prior to `(q - zp) * scale`. Both the decode (GEMV) // and prefill (mixed-input Grouped GEMM) paths call these directly, @@ -134,6 +139,98 @@ inline float decode_fp8(uint8_t byte) { } } +// ---------------------------------------------------------------------------- +// Word-native FP8 -> half decode (the `Fp8DecodeMode::kWord` path). +// +// Both LUT and inline-bits decoders above cost real work per weight byte: the +// LUT issues a memory load (plus a sign select) and the bit-manip path runs a +// branchy `ldexp` chain. On the decode hot path -- a pure GEMV that streams one +// weight byte per multiply-add -- that dequant cost is the kernel. Neither is +// necessary, because an FP8 byte is already an IEEE-style float and fp16 is a +// *superset* of both FP8 formats: the whole conversion is a bit-field move. +// +// E5M2 -> fp16: identical sign position, identical 5-bit exponent with the +// same bias 15, mantissa just needs 8 more bits -> +// h = byte << 8 +// Exact for every one of the 256 encodings, specials included +// (subnormals stay subnormal, exp==31 stays Inf/NaN). +// +// E4M3 -> fp16: 4-bit exponent, bias 7. Shifting the 7 magnitude bits up by +// 7 lands the exponent in fp16's exponent field and the 3 +// mantissa bits in the top of fp16's mantissa, which yields the +// correct value scaled by 2^(7-15) == 2^-8; the sign bit has to +// move 8 places instead of 7. Both moves collapse into one +// add + one shift, because adding the sign bit to itself +// carries it exactly one position further: +// h = (byte + (byte & 0x80)) << 7 +// The residual 2^-8 is constant, so callers fold the reciprocal +// (`fp8_word_scale_bias()` == 256.0f) into the +// per-K-group scale, i.e. it costs nothing per element. +// +// Exactness (verified exhaustively over all 256 byte values / all four +// format-mode combinations): E5M2 is bit-exact including Inf/NaN; E4M3 is +// bit-exact for all 254 finite encodings, including subnormals and both zeros. +// The two E4M3 *NaN* encodings (0x7F / 0xFF -- `torch.float8_e4m3fn` has no +// Inf) decode to +-480 instead of NaN, since fp16 has no NaN pattern reachable +// by a pure field move. auto-round FP8 checkpoints are produced by scaling to +// `finfo(float8_e4m3fn).max == 448` and clamping, so those two encodings cannot +// occur; callers that need NaN propagation can select `Fp8DecodeMode::kLut` or +// `kBits` (see `fp8_decode_mode()`). +// ---------------------------------------------------------------------------- +template +inline uint16_t decode_fp8_half_bits(uint32_t byte) { + if constexpr (IsE4M3) { + return static_cast((byte + (byte & 0x80u)) << 7); + } else { + return static_cast(byte << 8); + } +} + +// Constant the caller must fold into the per-group scale to undo the exponent +// re-bias performed by `decode_fp8_half_bits`. Exact power of two, so the fold +// is a pure exponent bump on the fp32 scale (no rounding). +template +inline constexpr float fp8_word_scale_bias() { + return IsE4M3 ? 256.0f : 1.0f; +} + +// SWAR form: decode the four FP8 bytes of one little-endian 32-bit word into +// two 32-bit words, each packing two fp16 bit patterns (low 16-bit lane holds +// the lower K index). Bit-identical to calling `decode_fp8_half_bits` on each +// byte, but the whole quad costs a handful of native DWORD ops and -- crucially +// on Xe, whose ALU lanes are 32-bit -- never touches an 8-bit-typed vector, +// which IGC has to expand into narrow-type regioning. This mirrors what +// `decode_int4_octet` does for packed nibbles. +template +inline void decode_fp8_quad_half_bits(uint32_t word, uint32_t& lo2, uint32_t& hi2) { + // Spread bytes 0/1 and 2/3 into the two 16-bit lanes of `lo` / `hi`. + const uint32_t lo = (word & 0x000000FFu) | ((word & 0x0000FF00u) << 8); + const uint32_t hi = ((word >> 16) & 0x000000FFu) | ((word >> 8) & 0x00FF0000u); + if constexpr (IsE4M3) { + // Per-lane `(b + (b & 0x80)) << 7`. A lane's value is <= 0x17F before the + // shift and <= 0xBF80 after it, so neither the add nor the shift can carry + // into the neighbouring lane. + lo2 = (lo + (lo & 0x00800080u)) << 7; + hi2 = (hi + (hi & 0x00800080u)) << 7; + } else { + lo2 = lo << 8; + hi2 = hi << 8; + } +} + +// ---------------------------------------------------------------------------- +// FP8 decode implementation selector. +// +// kWord : word-native bit-field move + folded scale bias (default; fastest, +// no memory traffic, no 8-bit ALU ops -- see above). +// kLut : 128-entry magnitude table in `bestla/sycl/fp8_lut.h`. +// kBits : self-contained inline bit manipulation. +// +// `kLut` / `kBits` are kept reachable for A/B measurement, regression escape, +// and the (checkpoint-impossible) E4M3 NaN encodings. +// ---------------------------------------------------------------------------- +enum class Fp8DecodeMode { kWord, kLut, kBits }; + // ---------------------------------------------------------------------------- // INT4 (S4_CLIP) packed-byte decode. // @@ -287,6 +384,10 @@ inline int decode_int8(uint8_t raw) { // // Read once on first call and cached in a function-local static, so it is // safe (and free) to call this on every launch. +// +// NOTE: this only chooses between the two *per-byte* decoders. The decode GEMV +// selects between {word, lut, bits} through `fp8_decode_mode()` below, which +// still honours this variable when it is set explicitly. // ---------------------------------------------------------------------------- inline bool fp8_decode_use_lut() { static const bool value = []() { @@ -300,6 +401,38 @@ inline bool fp8_decode_use_lut() { return value; } +// ---------------------------------------------------------------------------- +// Host-side selector for the FP8 decode implementation. +// +// `ARK_FP8_DECODE_MODE` = "word" | "lut" | "bits" (case-insensitive) picks a +// mode explicitly and wins over everything else. +// +// Otherwise, if the legacy `ARK_FP8_DECODE_USE_LUT` is set, it keeps its old +// meaning (`kLut` when truthy, `kBits` when falsy) so existing A/B scripts +// behave exactly as before. +// +// With neither set, the default is `kWord` -- the word-native bit-field move. +// +// Re-read on every call (not cached) so tests and benchmarks can toggle the +// path in-process; the result is passed into the kernel as a template argument, +// so there is no per-element runtime branch. +// ---------------------------------------------------------------------------- +inline Fp8DecodeMode fp8_decode_mode() { + const char* mode = std::getenv("ARK_FP8_DECODE_MODE"); + if (mode != nullptr) { + std::string s(mode); + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + if (s == "word") return Fp8DecodeMode::kWord; + if (s == "lut") return Fp8DecodeMode::kLut; + if (s == "bits") return Fp8DecodeMode::kBits; + // Unrecognised value: fall through to the legacy variable / default. + } + if (std::getenv("ARK_FP8_DECODE_USE_LUT") != nullptr) { + return fp8_decode_use_lut() ? Fp8DecodeMode::kLut : Fp8DecodeMode::kBits; + } + return Fp8DecodeMode::kWord; +} + } // namespace moe_dequant } // namespace ark diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index b67694e80b..a7bf88d2e2 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -80,9 +80,12 @@ #pragma once #include +#include +#include #include #include #include +#include #ifdef ARK_XPU #include @@ -152,6 +155,39 @@ struct cute_scalar { template using cute_scalar_t = typename cute_scalar::type; +// --------------------------------------------------------------------------- +// Persistent per-queue atomic work-group counter. +// +// The grouped-GEMM launchers below need a single `int32_t` device slot as a +// global work-group counter (`atomicAdd`). The kernel self-initialises it to 0 +// at launch (group 0 / lane 0 does `atm.store(0)`), so the host never has to +// reset it between calls. Allocating it per dispatch with `sycl::malloc_device` +// and releasing it with `sycl::free` costs two queue synchronizations, which is +// pure overhead on the decode hot path where the GEMM itself is only tens of +// microseconds. +// +// Instead, hand out one persistent buffer per queue and reuse it across calls. +// This is safe because every launcher call is synchronous (`event.wait()` in +// `MoEGEMMLauncher`), so two launches can never share the buffer concurrently. +// Buffers live until process exit (one `int32_t` per queue), matching the +// singleton lifetime already used by `EventManager`. The S4 header re-exports +// this helper rather than defining its own, so both paths share one cache. +// --------------------------------------------------------------------------- +inline int32_t* get_persistent_atomic_buffer(sycl::queue* q) { + static std::mutex mtx; + static std::unordered_map cache; + std::lock_guard lock(mtx); + auto it = cache.find(q); + if (it != cache.end()) return it->second; + int32_t* buf = sycl::malloc_device(1, *q); + if (buf == nullptr) { + throw std::runtime_error( + "moe_dpas_fp8: failed to allocate persistent atomic buffer"); + } + cache.emplace(q, buf); + return buf; +} + // --------------------------------------------------------------------------- // Policy classes (ported verbatim from vllm-xpu-kernels // `gemm_xe2_policy.hpp`, renamed to `dpas_*` to avoid collision with any @@ -1041,6 +1077,81 @@ void moe_prefill_fp8_dpas_per_group_dispatch( sycl::free(atomic_buffer, *q); } +// --------------------------------------------------------------------------- +// Host-side driver: per-K-group FP8, *decode* phase. +// +// Same math, same mainloop and same weight/scale layout as +// `moe_prefill_fp8_dpas_per_group_dispatch` above -- only the tile selection +// and the atomic-buffer lifetime differ, for two reasons that are specific to +// the decode regime (a handful of tokens spread over many experts): +// +// 1. Finer small-M ladder. The reference `w8a16` dispatch in +// vllm-xpu-kernels bottoms out at the 16-row tile (`m_16`), while its +// `w4a16` dispatch has an extra 8-row bucket. Decode `A_avg_M` is far +// below 16, so the missing rung means half of every M tile is padding and +// the (bandwidth-bound) FP8 weights are streamed for rows that contribute +// nothing. `dpas_w4a16_policy_m_8` carries no 4-bit-specific types -- it +// is purely a `WGTile`/`SGLayout` shape (8x64x32) -- so the FP8 mainloop +// reuses it verbatim, closing that gap. This mirrors what the S4 decode +// dispatch already does. +// 2. Persistent atomic counter. The prefill dispatch allocates and frees the +// work-group counter per call; each of those forces a queue sync. At +// prefill sizes that is noise, at decode sizes it is a large fraction of +// the total. Use the per-queue persistent slot instead. +// +// The upper rungs are pulled in to match the S4 decode ladder +// (`m_8` -> `m_16` -> `m_32` -> wide) rather than the prefill one, whose +// `<= 512 -> m_32` rung is tuned for prefill-sized batches. +// +// Numerically identical to the prefill dispatch for every input; only the tile +// geometry changes, so `test_moe_prefill_accuracy.py::test_accuracy_fp8` +// tolerances apply unchanged. Inherits this header's +// NEEDS-HARDWARE-VALIDATION status. +// --------------------------------------------------------------------------- + +template +void moe_decode_fp8_dpas_per_group_dispatch( + sycl::queue* q, const ScalarT* activations, const uint8_t* weights_NK, + const ScalarT* scales, ScalarT* outputs, const int* num_tokens_per_expert, + int E, int N, int K, int group_size, int total_tokens) { + if (E == 0 || N == 0 || K == 0 || total_tokens == 0) return; + if (K % group_size != 0) { + throw std::invalid_argument( + "moe_decode_fp8_dpas(per-group): K must be a multiple of group_size"); + } + + compat::set_default_queue(*q); + + using ElementB = std::conditional_t; + using ElementA = cute_scalar_t; + const auto* activations_ca = + reinterpret_cast(activations); + const auto* scales_ca = reinterpret_cast(scales); + auto* outputs_ca = reinterpret_cast(outputs); + + const int A_avg_M = total_tokens / E; + + int32_t* atomic_buffer = get_persistent_atomic_buffer(q); + +#define ARK_DPAS_DECODE_PG_LAUNCH(policy) \ + MoEGEMMLauncher<'R', 'C', policy, ScaleMode::kPerGroup>( \ + *q, activations_ca, reinterpret_cast(weights_NK), \ + scales_ca, static_cast(nullptr), outputs_ca, N, K, \ + num_tokens_per_expert, E, group_size, atomic_buffer); + + if (A_avg_M <= 4) { + ARK_DPAS_DECODE_PG_LAUNCH(dpas_w4a16_policy_m_8); + } else if (A_avg_M <= 8) { + ARK_DPAS_DECODE_PG_LAUNCH(dpas_w8a16_policy_m_16); + } else if (A_avg_M <= 128) { + ARK_DPAS_DECODE_PG_LAUNCH(dpas_w8a16_policy_m_32); + } else { + ARK_DPAS_DECODE_PG_LAUNCH(dpas_w8a16_policy); + } +#undef ARK_DPAS_DECODE_PG_LAUNCH +} + // --------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_PREFILL_DPAS_FP8` (default ON per plan). // diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp index ba2bb9ca6e..0a1c6a923c 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_s4_dpas.hpp @@ -148,34 +148,12 @@ using ::ark::moe_dpas_fp8::make_moe_tensor; // --------------------------------------------------------------------------- // Persistent per-queue atomic work-group counter. // -// The grouped-GEMM launcher below needs a single `int32_t` device slot as a -// global work-group counter (`atomicAdd`). The kernel self-initialises it to 0 -// at launch (group 0 / lane 0 does `atm.store(0)`), so the host never has to -// reset it between calls. Previously each dispatch call allocated this slot -// with `sycl::malloc_device` and released it with `sycl::free`; both operations -// force a queue synchronization, which is pure overhead on the decode hot path -// where the GEMM itself is only tens of microseconds. -// -// Instead, hand out one persistent buffer per queue and reuse it across calls. -// This is safe because every launcher call is synchronous (`event.wait()` in -// `MoEGEMMLauncher_s4`), so two launches can never share the buffer -// concurrently. Buffers live until process exit (one `int32_t` per queue), -// matching the singleton lifetime already used by `EventManager`. +// Shared with the FP8 path -- see `moe_dpas_fp8::get_persistent_atomic_buffer` +// for the rationale (one `int32_t` device slot per queue reused across calls, +// instead of a `sycl::malloc_device` / `sycl::free` pair per dispatch, each of +// which forces a queue synchronization on the decode hot path). // --------------------------------------------------------------------------- -inline int32_t* get_persistent_atomic_buffer(sycl::queue* q) { - static std::mutex mtx; - static std::unordered_map cache; - std::lock_guard lock(mtx); - auto it = cache.find(q); - if (it != cache.end()) return it->second; - int32_t* buf = sycl::malloc_device(1, *q); - if (buf == nullptr) { - throw std::runtime_error( - "moe_dpas_s4: failed to allocate persistent atomic buffer"); - } - cache.emplace(q, buf); - return buf; -} +using ::ark::moe_dpas_fp8::get_persistent_atomic_buffer; // --------------------------------------------------------------------------- // Variant B -- per-K-group S4 (sym) mainloop. From 3423da8f6df4d8cdbe9b0120138d91ac3e148053 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:53:01 +0000 Subject: [PATCH 042/112] test: FP8 decode-mode + DPAS parity/perf coverage; docs: FP8 decode section (EN+CN) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_PREFILL_PERF.md | 110 +++++++++++++++++ .../ark/test/README_MOE_PREFILL_PERF_CN.md | 98 +++++++++++++++ auto_round_extension/ark/test/test_moe.py | 112 ++++++++++++++++++ .../ark/test/test_moe_decode_perf.py | 108 ++++++++++++++++- 4 files changed, 427 insertions(+), 1 deletion(-) diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index 090bb3e1cf..5d5177426a 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -465,6 +465,116 @@ which forces `ARK_MOE_PREFILL_DPAS_S4=1` + exclusively exercised, at the same production shapes as `test_accuracy_int4`, with tolerance `rtol=atol=1e-1`. +## FP8 Decode Paths (`sycl_tla_moe_decode.hpp`) + +int4-sym decode is now at target, and the same two levers that got it +there apply to FP8: get the dequant off the byte-typed datapath, and stop +paying setup cost per decode call. On top of that, the FP8 MoE dispatch +from vllm-xpu-kernels is mirrored into a decode-specialised entry point. + +**Word-native FP8 decode (`ARK_FP8_DECODE_MODE`, default `word`).** The +decode GEMV does roughly one multiply-add per weight byte, so the dequant +*is* the kernel. Both legacy decoders paid real work per byte: `lut` +issues a memory load per weight element into the 128-entry magnitude table +plus a sign select, and `bits` runs a branchy `ldexp` chain. Both also +indexed an 8-bit-typed `sycl::vec`, which Xe's 32-bit ALU +lanes cannot address directly, so IGC expands it into narrow-type +regioning — exactly the problem `decode_int4_octet` fixed for nibbles. + +None of that work is necessary, because an FP8 byte is already an +IEEE-style float and fp16 is a *superset* of both FP8 formats: the whole +conversion is a bit-field move. + +| Format | fp16 bit pattern | Exactness | +| ------ | ---------------- | --------- | +| E5M2 | `byte << 8` | Bit-exact for all 256 encodings — same sign position, same 5-bit exponent, same bias 15. Subnormals stay subnormal, `exp==31` stays Inf/NaN. | +| E4M3 | `(byte + (byte & 0x80)) << 7` | Bit-exact for all 254 finite encodings (normals, subnormals, both zeros), yielding the true value × `2^-8`. | + +E4M3's 4-bit exponent has bias 7 against fp16's bias 15, so the field move +leaves a constant `2^-8` factor; `fp8_word_scale_bias()` (`256.0f`) +is folded into the per-K-group scale, an exact power of two applied once +per group, so it costs nothing per element. Adding the sign bit to itself +carries it exactly one position further, which is why the sign move and +the magnitude move collapse into one add plus one shift. + +The kernel reads the weights as `sycl::vec` — the same +16-byte transaction and the same 16-byte alignment requirement as the byte +vector it replaces — and `decode_fp8_quad_half_bits` turns each 32-bit word +into four fp16 bit patterns in a handful of native DWORD ops (SWAR, no +cross-lane carry). Two partial accumulators break the fp32 dependency +chain, as in `int4_decode_chunk`. Both primitives live in +`sycl_tla_moe_dequant.hpp`, and both were verified exhaustively over all +256 byte values in both formats. + +**E4M3 NaN caveat.** The two E4M3 NaN encodings (`0x7F` / `0xFF`; +`torch.float8_e4m3fn` has no Inf) decode to ±480 instead of NaN, since fp16 +has no NaN pattern reachable by a pure field move. auto-round FP8 +checkpoints are produced by scaling to `finfo(float8_e4m3fn).max == 448` +and clamping, so those two encodings cannot occur. Callers that need NaN +propagation can select `ARK_FP8_DECODE_MODE=lut` or `=bits`. + +**FP8 DPAS decode dispatch.** `moe_decode_fp8_dpas_per_group_dispatch` +(`sycl_tla_moe_prefill_fp8_dpas.hpp`, `ARK_MOE_DECODE_DPAS_FP8` default ON) +is the FP8 twin of the S4 decode dispatch: same mainloop, same `[E, N, K]` +FP8 bytes + `[E, N, K/group]` scales, no repack. It differs from the +prefill dispatch in two decode-specific ways. + +*Finer small-M ladder.* The reference `w8a16` dispatch in vllm-xpu-kernels +bottoms out at the 16-row tile, while its `w4a16` dispatch has an extra +8-row bucket. Decode `A_avg_M` sits far below 16, so the missing rung means +half of every M tile is padding and the bandwidth-bound FP8 weights get +streamed for rows that contribute nothing. `dpas_w4a16_policy_m_8` carries +no 4-bit-specific types — it is purely an `8×64×32` `WGTile` / `SGLayout` +shape — so the FP8 mainloop reuses it verbatim, closing that gap: + +| `A_avg_M` bucket | WG tile (M×N×K) | Policy | +| ---------------- | --------------- | ------ | +| `≤ 4` | `8×64×32` | `dpas_w4a16_policy_m_8` | +| `≤ 8` | `16×64×32` | `dpas_w8a16_policy_m_16` | +| `≤ 128` | `32×64×32` | `dpas_w8a16_policy_m_32` | +| `> 128` | `128×128×16` | `dpas_w8a16_policy` | + +The upper rungs match the S4 *decode* ladder rather than the FP8 prefill +one, whose `≤ 512 → m_32` rung is tuned for prefill-sized batches. + +*Persistent atomic counter.* The prefill dispatch allocates the +work-group counter with `sycl::malloc_device` and releases it with +`sycl::free` on every call; each of those forces a queue synchronization. +At prefill sizes that is noise, at decode sizes — where the GEMM itself is +only tens of microseconds and one call is issued per generated token — it +is a large fraction of the total. The decode dispatch uses a persistent +per-queue slot instead (`get_persistent_atomic_buffer`, now shared with the +S4 header so both paths use one cache). Taking the fast path also skips the +`fill_expert_id_per_token` pre-pass, since the DPAS dispatch consumes +`num_tokens_per_expert` directly — one fewer kernel launch on the decode +timeline. **Status: NEEDS-HARDWARE-VALIDATION** (this header is an +untested port). + +**Occupancy gate — real decode batches stay on the scalar GEMV.** Same +reasoning as int4-sym: the smallest tile the decode ladder can pick +processes 8 token rows per expert, so below 8 tokens per expert on average +the tile is mostly padding. That is exactly the decode regime (MiniMax-M2, +192 experts: 0.04–1.3 tokens per expert), so FP8 decode is routed to the +scalar GEMV unless the batch supplies at least 8 tokens per expert. +`ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` overrides the threshold; `0` disables the +gate, which is what the parity and A/B perf tests set. Shapes that fail the +per-group shape gate (`N%64==0`, `K%32==0`, `K%group_size==0`, +`group_size ∈ {32,64,128,256}`) always fall back to the scalar GEMV. + +| Env var | Default | Effect | +| ------- | ------- | ------ | +| `ARK_FP8_DECODE_MODE` | `word` | FP8 decode implementation for the scalar GEMV: `word` (bit-field move + folded scale bias), `lut` (128-entry magnitude table), `bits` (inline bit manipulation). | +| `ARK_FP8_DECODE_USE_LUT` | unset | Legacy selector, still honoured when set explicitly and when `ARK_FP8_DECODE_MODE` is unset/unrecognised: truthy → `lut`, falsy → `bits`. Also still drives the mixed-input prefill path. | +| `ARK_MOE_DECODE_DPAS_FP8` | ON | Route FP8 decode to the per-group DPAS grouped GEMM when the shape and occupancy gates pass; `0` forces the scalar GEMV. | +| `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | Minimum tokens per expert before the DPAS path is taken; `0` disables the gate (what the parity/A-B tests set). | + +Perf A/B rows are `test_moe_decode_perf.py::test_perf_fp8_word_vs_lut` +(`speedup` is `lut / word`) and `::test_perf_fp8_dpas_vs_scalar` +(`speedup` is `scalar / dpas`). Correctness is covered by +`test_moe.py::test_decode_fp8_modes_match` (all three decoders agree, and +each tracks the dequant reference) and +`::test_decode_fp8_dpas_matches_scalar`. + ## FP8 per-expert (per-tensor) perf tests `test_perf_fp8_per_tensor` benchmarks the Variant A DPAS path against diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 201a2bb49b..27c4badc83 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -366,6 +366,104 @@ token 数(16–128)都远低于该门控(8 × 192 个专家 == 1536 个 token) `ARK_MOE_PREFILL_DPAS_INT8=0`,专门验证单遍 mainloop 路径,形状矩阵与 `test_accuracy_int4` 一致,容差 `rtol=atol=1e-1`。 +## FP8 Decode 路径 (`sycl_tla_moe_decode.hpp`) + +int4-sym decode 的性能已经达标,把它推到达标的两个手段同样适用于 FP8: +让 dequant 离开按字节的数据通路,以及不要在每次 decode 调用里重复付出 +启动开销。在此之上,还把 vllm-xpu-kernels 的 FP8 MoE dispatch 镜像成一个 +decode 专用入口。 + +**Word-native FP8 解码 (`ARK_FP8_DECODE_MODE`, 默认 `word`)。** decode +GEMV 每读一个权重字节大约只做一次乘加,所以 dequant *就是* kernel 本身。 +两条旧解码路径每个字节都要付出真实开销:`lut` 每个权重元素都要向 128 项 +幅值表发一次访存再做一次符号选择,`bits` 则要跑一串带分支的 `ldexp`。 +两者还都索引了 8-bit 类型的 `sycl::vec`,而 Xe 的 ALU 通道是 +32-bit 的、无法直接寻址它,于是 IGC 只能展开成窄类型 regioning —— +正是 `decode_int4_octet` 为 nibble 解决过的那个问题。 + +这些工作其实都不必要:FP8 字节本身就是一个 IEEE 风格的浮点数,而 fp16 是 +两种 FP8 格式的*超集*,整个转换就是一次位域搬移。 + +| 格式 | fp16 位模式 | 精确性 | +| ---- | ----------- | ------ | +| E5M2 | `byte << 8` | 对全部 256 种编码逐位精确 —— 符号位位置相同、5 位指数相同、bias 同为 15。次正规数仍是次正规数,`exp==31` 仍是 Inf/NaN。 | +| E4M3 | `(byte + (byte & 0x80)) << 7` | 对全部 254 种有限编码(正规数、次正规数、两个零)逐位精确,得到真值 × `2^-8`。 | + +E4M3 的 4 位指数 bias 为 7,而 fp16 的 bias 是 15,所以位域搬移会留下一个 +常数因子 `2^-8`;`fp8_word_scale_bias()`(`256.0f`)被折叠进 +per-K-group 的 scale,是一个精确的 2 的幂、每组只乘一次,因此对单个元素而言 +零开销。把符号位加到它自身上,恰好会把它再进位一格,这就是符号搬移与幅值 +搬移能合并成一次加法加一次移位的原因。 + +kernel 以 `sycl::vec` 读取权重 —— 与它替换掉的字节向量是同一次 +16 字节访存、同样的 16 字节对齐要求 —— 再由 `decode_fp8_quad_half_bits` 用 +少量原生 DWORD 运算把每个 32 位字变成四个 fp16 位模式(SWAR,不会跨 lane +进位)。两个部分累加器打断 fp32 依赖链,与 `int4_decode_chunk` 的做法一致。 +两个原语都放在 `sycl_tla_moe_dequant.hpp`,并已对两种格式的全部 256 个字节 +值做过穷举验证。 + +**E4M3 NaN 注意事项。** E4M3 的两个 NaN 编码(`0x7F` / `0xFF`; +`torch.float8_e4m3fn` 没有 Inf)会解码成 ±480 而不是 NaN,因为纯位域搬移 +到不了 fp16 的任何 NaN 模式。auto-round 的 FP8 checkpoint 是按 +`finfo(float8_e4m3fn).max == 448` 缩放并 clamp 得到的,所以这两个编码不可能 +出现。需要 NaN 传播的调用方可以选择 `ARK_FP8_DECODE_MODE=lut` 或 `=bits`。 + +**FP8 DPAS decode dispatch。** `moe_decode_fp8_dpas_per_group_dispatch` +(`sycl_tla_moe_prefill_fp8_dpas.hpp`,`ARK_MOE_DECODE_DPAS_FP8` 默认 ON) +是 S4 decode dispatch 的 FP8 对应物:同一套 mainloop、同样的 `[E, N, K]` +FP8 字节 + `[E, N, K/group]` scale、无需 repack。它与 prefill dispatch 有 +两点 decode 专属的差异。 + +*更细的 small-M 阶梯。* vllm-xpu-kernels 的参考 `w8a16` dispatch 最小只到 +16 行 tile,而它的 `w4a16` dispatch 多一个 8 行档位。decode 的 `A_avg_M` +远低于 16,缺这一档意味着每个 M tile 有一半是 padding,而受带宽约束的 FP8 +权重要为这些毫无贡献的行反复搬运。`dpas_w4a16_policy_m_8` 不含任何 4-bit +专用类型 —— 它纯粹是一个 `8×64×32` 的 `WGTile` / `SGLayout` 形状 —— +所以 FP8 mainloop 可以原样复用它,补上这一档: + +| `A_avg_M` 档位 | WG tile (M×N×K) | Policy | +| -------------- | --------------- | ------ | +| `≤ 4` | `8×64×32` | `dpas_w4a16_policy_m_8` | +| `≤ 8` | `16×64×32` | `dpas_w8a16_policy_m_16` | +| `≤ 128` | `32×64×32` | `dpas_w8a16_policy_m_32` | +| `> 128` | `128×128×16` | `dpas_w8a16_policy` | + +上面几档对齐的是 S4 的 *decode* 阶梯,而不是 FP8 prefill 的那条 —— +后者的 `≤ 512 → m_32` 档是按 prefill 规模的 batch 调过的。 + +*常驻 atomic 计数器。* prefill dispatch 每次调用都用 `sycl::malloc_device` +分配 work-group 计数器、再用 `sycl::free` 释放,这两个操作各会强制一次队列 +同步。在 prefill 规模下这只是噪声,但在 decode 规模下 —— GEMM 本身只有几十 +微秒、且每生成一个 token 就要发一次调用 —— 它占总时间的比例相当可观。 +decode dispatch 改用每队列常驻的一个 slot(`get_persistent_atomic_buffer`, +现已与 S4 头文件共享,两条路径共用一份 cache)。走上这条快路径时还会跳过 +`fill_expert_id_per_token` 前置 pass,因为 DPAS dispatch 直接消费 +`num_tokens_per_expert` —— decode 时间线上少一次 kernel 启动。 +**状态:NEEDS-HARDWARE-VALIDATION**(该头文件是未经硬件验证的移植)。 + +**占用率门控 —— 真实 decode batch 仍走 scalar GEMV。** 理由与 int4-sym +相同:decode 阶梯能选到的最小 tile 每个专家处理 8 行 token,所以平均每专家 +不足 8 个 token 时,tile 大部分是 padding。这正是 decode 的场景(MiniMax-M2, +192 个专家:每专家 0.04–1.3 个 token),因此除非 batch 平均每专家至少提供 +8 个 token,FP8 decode 一律走 scalar GEMV。 +`ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` 可覆盖该阈值;`0` 关闭门控,这也是对齐 +用例与 A/B 性能用例所设置的值。未通过 per-group 形状门控 +(`N%64==0`、`K%32==0`、`K%group_size==0`、 +`group_size ∈ {32,64,128,256}`)的形状始终回退到 scalar GEMV。 + +| Env 变量 | 默认值 | 作用 | +| -------- | ------ | ---- | +| `ARK_FP8_DECODE_MODE` | `word` | scalar GEMV 的 FP8 解码实现:`word`(位域搬移 + 折叠 scale bias)、`lut`(128 项幅值表)、`bits`(内联位运算)。 | +| `ARK_FP8_DECODE_USE_LUT` | 未设置 | 旧的选择开关;当它被显式设置、且 `ARK_FP8_DECODE_MODE` 未设置或取值无法识别时仍然生效:truthy → `lut`,falsy → `bits`。它同时仍然驱动 mixed-input prefill 路径。 | +| `ARK_MOE_DECODE_DPAS_FP8` | ON | 形状与占用率门控都通过时,把 FP8 decode 路由到 per-group DPAS grouped GEMM;`0` 强制走 scalar GEMV。 | +| `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | 走 DPAS 路径所需的最小每专家 token 数;`0` 关闭门控(对齐/A-B 用例所设)。 | + +性能 A/B 行是 `test_moe_decode_perf.py::test_perf_fp8_word_vs_lut` +(`speedup` 为 `lut / word`)与 `::test_perf_fp8_dpas_vs_scalar` +(`speedup` 为 `scalar / dpas`)。正确性由 +`test_moe.py::test_decode_fp8_modes_match`(三种解码器互相一致,且各自都 +对齐 dequant 参考)与 `::test_decode_fp8_dpas_matches_scalar` 覆盖。 + ## FP8 per-expert (per-tensor) 性能测试 `test_perf_fp8_per_tensor` 提供 Variant A DPAS 路径的性能表格,对应 diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index e1eb623531..005643da8b 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -1025,6 +1025,118 @@ def test_decode_fp8(self, dtype, fp8_dtype, group_size): atol = 1e-1 if fp8_dtype == torch.float8_e5m2 else 5e-2 torch.testing.assert_close(out, ref, rtol=rtol, atol=atol) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) + @pytest.mark.parametrize("group_size", [32, 128]) + def test_decode_fp8_modes_match(self, monkeypatch, dtype, fp8_dtype, group_size): + """FP8 decode: the word-native decoder (``ARK_FP8_DECODE_MODE=word``, + the default) must be numerically identical to the LUT and inline-bits + decoders. + + ``word`` converts each FP8 byte to an fp16 bit pattern with a pure + bit-field move and folds E4M3's residual ``2**-8`` into the per-K-group + scale (an exact power of two), so on the finite encodings a real + checkpoint contains all three modes decode to exactly the same value. + Only the fp32 accumulation order differs (``word`` uses two partial + accumulators), hence ``bitwise=False`` and a tight -- not exact -- + tolerance. + """ + num_experts = 4 + tokens_per_expert = [1, 0, 2, 1] + total_tokens = sum(tokens_per_expert) + N, K = 256, 256 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, fp8_dtype) + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + group_size=group_size, + asym=False, + ) + + # Keep every mode on the scalar GEMV so this compares decoders only. + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + monkeypatch.delenv("ARK_FP8_DECODE_USE_LUT", raising=False) + + monkeypatch.setenv("ARK_FP8_DECODE_MODE", "word") + out_word = _run() + monkeypatch.setenv("ARK_FP8_DECODE_MODE", "lut") + out_lut = _run() + monkeypatch.setenv("ARK_FP8_DECODE_MODE", "bits") + out_bits = _run() + + assert out_word.shape == (total_tokens, N) + torch.testing.assert_close(out_word, out_lut, rtol=1e-3, atol=1e-3) + torch.testing.assert_close(out_word, out_bits, rtol=1e-3, atol=1e-3) + + # Every mode must still track the dequant reference. + dequant = _dequant_fp8(packed, scales, group_size, dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + rtol = 1e-1 if fp8_dtype == torch.float8_e5m2 else 5e-2 + atol = 1e-1 if fp8_dtype == torch.float8_e5m2 else 5e-2 + torch.testing.assert_close(out_word, ref, rtol=rtol, atol=atol) + + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) + @pytest.mark.parametrize("group_size", [32, 128]) + def test_decode_fp8_dpas_matches_scalar(self, monkeypatch, dtype, fp8_dtype, group_size): + """FP8 decode: the per-group DPAS path (``ARK_MOE_DECODE_DPAS_FP8=1``, + the default) must match both the scalar GEMV fallback + (``ARK_MOE_DECODE_DPAS_FP8=0``) and the dequant->bmm reference. + + Shapes satisfy the DPAS per-group shape gate (N%64==0, K%32==0, + group_size in {32,64,128,256}) and ``ARK_MOE_DECODE_DPAS_FP8_MIN_TPE=0`` + disables the tokens-per-expert occupancy gate (these tiny token counts + would otherwise be routed to the scalar GEMV, which is faster there) so + the DPAS fast path is actually taken. + """ + num_experts = 4 + tokens_per_expert = [1, 0, 2, 1] + total_tokens = sum(tokens_per_expert) + N, K = 320, 256 # N%64==0, K%32==0 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, fp8_dtype) + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "1") + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8_MIN_TPE", "0") + out_dpas = _run() + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + out_scalar = _run() + + dequant = _dequant_fp8(packed, scales, group_size, dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + rtol = 1e-1 if fp8_dtype == torch.float8_e5m2 else 5e-2 + atol = 1e-1 if fp8_dtype == torch.float8_e5m2 else 5e-2 + + assert out_dpas.shape == (total_tokens, N) + assert out_scalar.shape == (total_tokens, N) + torch.testing.assert_close(out_dpas, ref, rtol=rtol, atol=atol) + torch.testing.assert_close(out_scalar, ref, rtol=rtol, atol=atol) + torch.testing.assert_close(out_dpas, out_scalar, rtol=rtol, atol=atol) + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index 60861b407e..bd4da41a25 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -695,7 +695,113 @@ def test_perf_fp8(self, dtype, fp8_dtype): @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) - def test_perf_fp8_per_tensor(self, dtype, fp8_dtype): + def test_perf_fp8_word_vs_lut(self, monkeypatch, dtype, fp8_dtype): + """FP8 scalar-GEMV A/B: word-native decode (``ARK_FP8_DECODE_MODE=word``, + the default) vs the 128-entry magnitude LUT (``=lut``, the old default). + + ``speedup`` is ``lut / word`` (the word-native decoder is the "ark" + column). The decode GEMV does roughly one multiply-add per weight byte, + so the dequant *is* the kernel: the LUT path issues a memory load per + weight element and both legacy paths index an 8-bit-typed + ``sycl::vec``, which Xe's 32-bit ALU lanes cannot address + directly. The word-native path reads the same bytes as + ``sycl::vec`` and turns each 32-bit word into four fp16 bit + patterns with a couple of native DWORD ops, folding E4M3's residual + ``2**-8`` into the per-K-group scale. This is the same treatment that + made int4-sym decode fast (see ``decode_int4_octet``). + + The FP8 DPAS fast path is disabled so both columns run the scalar GEMV + -- the only path the decode-mode flag affects. + """ + group_size = 128 + _print_header( + f"FP8 {str(fp8_dtype).split('.')[-1]} word vs lut decode (group_size={group_size}, " + f"act={str(dtype).split('.')[-1]}) -- LUT GEMV (baseline) vs word-native GEMV (ark)" + ) + for label, E, tpe, N, K in DECODE_SHAPES: + if K % group_size != 0: + continue + total_tokens = sum(tpe) + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, fp8_dtype) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + monkeypatch.delenv("ARK_FP8_DECODE_USE_LUT", raising=False) + monkeypatch.setenv("ARK_FP8_DECODE_MODE", "lut") + lut_ms = _xpu_time_ms(_run) + monkeypatch.setenv("ARK_FP8_DECODE_MODE", "word") + word_ms = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_FP8_DECODE_MODE", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8", raising=False) + _print_row(label, N, K, total_tokens, lut_ms, word_ms) + + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) + def test_perf_fp8_dpas_vs_scalar(self, monkeypatch, dtype, fp8_dtype): + """FP8 decode: the per-group DPAS grouped GEMM + (``ARK_MOE_DECODE_DPAS_FP8=1``) vs the scalar GEMV (``=0``). + + ``speedup`` is ``scalar / dpas`` (the DPAS path is the "ark" column). + Only shapes that clear the DPAS shape gate are timed. + ``ARK_MOE_DECODE_DPAS_FP8_MIN_TPE=0`` disables the tokens-per-expert + occupancy gate so the DPAS column really runs DPAS (by default these + decode-sized batches are routed to the scalar GEMV). + + This is the FP8 twin of ``test_perf_int4_sym_dpas_vs_scalar`` and + exists for the same reason: to locate the batch size where the DPAS + pipeline overtakes the scalar GEMV, which is what the default + occupancy threshold encodes. On MiniMax-M2 decode shapes (192 experts, + 0.04-1.3 tokens/expert) the DPAS M tile is starved -- even the 8-row + ``dpas_w4a16_policy_m_8`` bucket this decode ladder adds on top of the + reference ``w8a16`` ladder -- so the scalar column is expected to win + there. + """ + group_size = 128 + _print_header( + f"FP8 {str(fp8_dtype).split('.')[-1]} DPAS vs scalar (group_size={group_size}, " + f"act={str(dtype).split('.')[-1]}) -- scalar GEMV (baseline) vs FP8 DPAS (ark)" + ) + for label, E, tpe, N, K in DECODE_SHAPES: + if K % group_size != 0 or N % 64 != 0 or K % 32 != 0: + continue + total_tokens = sum(tpe) + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, fp8_dtype) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + scalar_ms = _xpu_time_ms(_run) + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "1") + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8_MIN_TPE", "0") + dpas_ms = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8_MIN_TPE", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8", raising=False) + _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) """Perf: FP8 per-expert (per-tensor) scale for the decode path. The C++ decode kernel does NOT expose a native ``[E]`` per-tensor From c6f67a131161551711badb309657576b675207ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:07:58 +0000 Subject: [PATCH 043/112] fix: restore test_perf_fp8_per_tensor definition; make fp8_decode_mode allocation-free Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_dequant.hpp | 21 +++++++++++++------ .../ark/test/test_moe_decode_perf.py | 4 ++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp index 736ab0e3c0..7ba9c8fc61 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_dequant.hpp @@ -415,16 +415,25 @@ inline bool fp8_decode_use_lut() { // // Re-read on every call (not cached) so tests and benchmarks can toggle the // path in-process; the result is passed into the kernel as a template argument, -// so there is no per-element runtime branch. +// so there is no per-element runtime branch. The comparison is done in place +// rather than via `std::string` because decode issues one call per generated +// token, and a heap allocation per lookup on that path buys nothing (same +// reasoning as `moe_decode_detail::env_flag_enabled`). // ---------------------------------------------------------------------------- inline Fp8DecodeMode fp8_decode_mode() { const char* mode = std::getenv("ARK_FP8_DECODE_MODE"); if (mode != nullptr) { - std::string s(mode); - for (char& c : s) c = static_cast(std::tolower(static_cast(c))); - if (s == "word") return Fp8DecodeMode::kWord; - if (s == "lut") return Fp8DecodeMode::kLut; - if (s == "bits") return Fp8DecodeMode::kBits; + auto iequals = [](const char* value, const char* lowercase_literal) { + const char* a = value; + const char* b = lowercase_literal; + for (; *a != '\0' && *b != '\0'; ++a, ++b) { + if (static_cast(std::tolower(static_cast(*a))) != *b) return false; + } + return *a == '\0' && *b == '\0'; + }; + if (iequals(mode, "word")) return Fp8DecodeMode::kWord; + if (iequals(mode, "lut")) return Fp8DecodeMode::kLut; + if (iequals(mode, "bits")) return Fp8DecodeMode::kBits; // Unrecognised value: fall through to the legacy variable / default. } if (std::getenv("ARK_FP8_DECODE_USE_LUT") != nullptr) { diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index bd4da41a25..1efdc6ccc1 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -802,6 +802,10 @@ def _run(): monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8_MIN_TPE", raising=False) monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8", raising=False) _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) + + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) + def test_perf_fp8_per_tensor(self, dtype, fp8_dtype): """Perf: FP8 per-expert (per-tensor) scale for the decode path. The C++ decode kernel does NOT expose a native ``[E]`` per-tensor From 7102f0eca367cf29e8259ab7395281aa43b76547 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:38:05 +0000 Subject: [PATCH 044/112] perf: add K-split lane-parallel FP8 MoE decode GEMV Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 199 +++++++++++++++++- auto_round_extension/ark/test/test_moe.py | 100 +++++++++ .../ark/test/test_moe_decode_perf.py | 56 +++++ 3 files changed, 348 insertions(+), 7 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 1c03de22ef..c8af8e40c8 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -34,6 +34,12 @@ // so no cross-lane reduction is needed and activation reads are coalesced across // the sub-group through the L1 cache. // +// The FP8 scalar path additionally offers a K-split mapping (one sub-group per +// output element, lanes splitting K, `ARK_MOE_DECODE_FP8_KSPLIT`, default ON): +// it trades a sub-group reduction for fully coalesced weight loads and 16x the +// thread count, which is what the memory-bound decode GEMV is short of. See the +// block comment above `launch_fp8_ksplit`. +// // Copyright (C) 2026 Intel Corporation // SPDX-License-Identifier: Apache-2.0 @@ -154,6 +160,9 @@ class MoEDecodeKernelInt2; template class MoEDecodeKernelFP8; +template +class MoEDecodeKernelFP8KSplit; + // ---------------------------------------------------------------------------- // FP8 weight dequantization primitives + host-side env-var reader live in // `sycl_tla_moe_dequant.hpp` so the prefill (mixed-input Grouped GEMM) and @@ -1241,6 +1250,10 @@ void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // // * `kLut` / `kBits` -- the original per-byte `decode_fp8` // decoders, kept for A/B measurement and regression escape. +// +// Two lane mappings share those decoders: the legacy per-work-item GEMV +// (`launch_fp8`) and the K-split GEMV (`launch_fp8_ksplit`, default, see its +// block comment). `launch_fp8_by_mode` picks between them. // ---------------------------------------------------------------------------- // Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK weight @@ -1398,26 +1411,198 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh }); } +// ---------------------------------------------------------------------------- +// FP8 K-split (lane-parallel) decode GEMV. +// +// `launch_fp8` above maps one *work-item* to one output element, so a lane +// walks a whole `[n_global, K]` weight row on its own. Two things follow from +// that mapping, and both cost real bandwidth on a kernel that does ~1 MAC per +// weight byte: +// +// 1. Weight loads are not coalesced. Lane `l` and lane `l+1` of a sub-group +// read bytes that are `K` apart, so every 16-byte load instruction turns +// into 16 scattered cache-line requests. The lines are eventually fully +// consumed (each lane walks its own row sequentially), so no DRAM byte is +// wasted -- but the memory controller sees `16 x resident sub-groups` +// independent streams instead of one per thread, which is exactly the +// access pattern DRAM row buffers handle worst. +// 2. The grid is small. Decode runs `total_tokens * N / 16` sub-groups; for +// a batch-1 MiniMax-M2 step (8 tokens, N=1536) that is 768 SIMD16 +// threads, below the thread slots of a BMG-class GPU, so there are not +// enough outstanding loads in flight to cover DRAM latency. +// +// This kernel transposes the lane mapping: a whole *sub-group* cooperates on +// one output element and the lanes split K. Lane `l` owns the `KSPLIT_CH` +// consecutive K elements at `l * KSPLIT_CH` inside each `KSPLIT_STEP`-wide +// K-tile, so per instruction the sub-group covers `KSPLIT_STEP` *contiguous* +// weight bytes (256 B -- four full cache lines) and `2 * KSPLIT_STEP` +// contiguous activation bytes. Each thread now walks a single sequential +// stream, and the grid grows by `SG_SIZE` (12288 sub-groups for the batch-1 +// step above), which is what puts enough requests in flight. The per-lane +// partial sums are reduced once at the end with `reduce_over_group` -- a +// handful of shuffles per output element against `K` multiply-adds. +// +// The int4 fallback solves the same coalescing problem by repacking the packed +// weights into an N-tiled layout (`launch_int4_coalesced`), which costs a full +// pass over the weight tensor and is therefore gated on a token-count +// amortization heuristic. FP8 weights are one byte per element and already +// K-contiguous, so K-splitting the lane mapping gets the same coalescing with +// no repack, no scratch buffer and no extra kernel launch. +// +// Scale handling: a lane's chunk is `KSPLIT_CH` consecutive K elements +// starting at a multiple of `KSPLIT_CH`, so with `group_size` a power of two +// that is >= `KSPLIT_CH` (the shape gate below) the chunk always sits inside a +// single K-group and its scale index is `k0 >> log2(group_size)` -- one shift, +// no integer division in the hot loop. The scale is applied per chunk instead +// of once per group; that is one extra multiply per `KSPLIT_CH` elements and +// keeps the `Sigma a * (w * s) == s * Sigma a * w` fold exact-per-group, +// including the folded `2^-8` E4M3 word-decode bias. +// ---------------------------------------------------------------------------- + +// K elements a lane owns per step. 16 FP8 bytes = one 16-byte weight load and +// one `vec` (32-byte) activation load per lane, i.e. exactly the +// transactions `fp8_decode_chunk` already issues, so the alignment contract is +// unchanged. +constexpr int KSPLIT_CH = 16; +// K elements a sub-group covers per step: the contiguous span its 16 lanes +// read in one instruction. +constexpr int KSPLIT_STEP = SG_SIZE * KSPLIT_CH; +// Sub-groups per work-group. Each owns one output column, so a work-group +// covers `N_TILE` consecutive columns and `N % N_TILE == 0` (already required +// by every decode path) is enough to tile N exactly. +constexpr int KSPLIT_WG_SGS = N_TILE; + +// ---------------------------------------------------------------------------- +// Env-flag helper -- `ARK_MOE_DECODE_FP8_KSPLIT` (default ON). When ON, the FP8 +// scalar decode GEMV uses the K-split kernel above; setting the var to "0" / +// "false" / "off" / "no" (case-insensitive) forces the legacy per-lane-strided +// `launch_fp8`, for A/B comparison and regression escape. Re-read on every call +// so tests and benchmarks can toggle the path in-process. +// ---------------------------------------------------------------------------- +inline bool moe_decode_fp8_ksplit_enabled() { + return env_flag_enabled("ARK_MOE_DECODE_FP8_KSPLIT", true); // default ON +} + +// Shape gate for the K-split kernel. `group_size` must be a power of two of at +// least `KSPLIT_CH` so that (a) a lane's chunk never straddles a K-group +// boundary and (b) the group index is a shift rather than an integer division +// on the hot path. Every shipped FP8 quant config (32 / 64 / 128 / 256) passes; +// anything else keeps the legacy GEMV, which handles arbitrary group sizes. +inline bool moe_decode_fp8_ksplit_shape_ok(int N, int K, int group_size) { + if (N % N_TILE != 0) return false; + if (group_size < KSPLIT_CH) return false; + if ((group_size & (group_size - 1)) != 0) return false; // not a power of two + if (K % group_size != 0) return false; + return true; +} + +template +void launch_fp8_ksplit(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, + ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, + int group_size) { + if (!moe_decode_fp8_ksplit_shape_ok(N, K, group_size)) { + throw std::invalid_argument("moe_gemm_decode(fp8): K-split GEMV called on an unsupported shape"); + } + if (total_tokens == 0) return; + + const int num_groups_k = K / group_size; + int log2_group = 0; + while ((1 << log2_group) < group_size) ++log2_group; + // Undoes the exponent re-bias the word-native decode leaves behind (1.0f for + // every other mode). Exact power of two, applied once per lane chunk. + constexpr float kScaleBias = (Mode == Fp8DecodeMode::kWord) ? fp8_word_scale_bias() : 1.0f; + + // One sub-group per (token, output column); `KSPLIT_WG_SGS` of them per + // work-group so the dispatcher sees `N / N_TILE` work-groups per token + // instead of `N` single-sub-group ones. + sycl::range<2> global{static_cast(total_tokens), static_cast(N) * SG_SIZE}; + sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), + [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const auto sg = it.get_sub_group(); + const int token = static_cast(it.get_global_id(0)); + const int local_id = static_cast(it.get_local_id(1)); + // The work-group is one row of `KSPLIT_WG_SGS * SG_SIZE` work-items, so + // sub-group index and lane index are just the halves of the local id. + const int lane = local_id % SG_SIZE; + const int n_global = static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE; + + const int expert = expert_id_per_token[token]; + const ScalarT* act_row = activations + static_cast(token) * K; + const uint8_t* w_row = + weights + (static_cast(expert) * N + static_cast(n_global)) * K; + const ScalarT* s_row = + scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; + + // Each lane accumulates the scaled partial dot product of the chunks + // it owns; `fp8_decode_chunk` keeps two partial accumulators per chunk + // so the fp32 dependency chain stays broken. + float acc = 0.0f; + int k0 = lane * KSPLIT_CH; + // Two chunks per iteration: their loads are independent, so the pair + // doubles the number of weight requests a thread keeps in flight. + for (; k0 + KSPLIT_STEP + KSPLIT_CH <= K; k0 += 2 * KSPLIT_STEP) { + float a0 = 0.0f, a1 = 0.0f, b0 = 0.0f, b1 = 0.0f; + fp8_decode_chunk(act_row + k0, w_row + k0, a0, a1); + fp8_decode_chunk(act_row + k0 + KSPLIT_STEP, + w_row + k0 + KSPLIT_STEP, b0, b1); + const float s0 = static_cast(s_row[k0 >> log2_group]) * kScaleBias; + const float s1 = static_cast(s_row[(k0 + KSPLIT_STEP) >> log2_group]) * kScaleBias; + acc += (a0 + a1) * s0 + (b0 + b1) * s1; + } + // Remainder (at most one chunk per lane, plus the lanes whose chunk + // falls past K when K < KSPLIT_STEP -- those simply contribute 0). + for (; k0 < K; k0 += KSPLIT_STEP) { + float p0 = 0.0f, p1 = 0.0f; + fp8_decode_chunk(act_row + k0, w_row + k0, p0, p1); + acc += (p0 + p1) * (static_cast(s_row[k0 >> log2_group]) * kScaleBias); + } + + const float total = sycl::reduce_over_group(sg, acc, sycl::plus{}); + if (lane == 0) { + outputs[static_cast(token) * N + n_global] = static_cast(total); + } + }); +} + // Runtime -> compile-time bridge for the decode-mode selector. Keeps the // `moe_gemm_decode` dispatch to one branch per (act dtype, format) instead of -// re-nesting the mode selection at every call site. +// re-nesting the mode selection at every call site. The K-split vs legacy +// choice is made here as well, so all three decode modes run the same kernel +// structure and `word` / `lut` / `bits` stay comparable to one another. +template +void launch_fp8_dispatch(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, + const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int group_size, bool ksplit) { + if (ksplit) { + launch_fp8_ksplit(q, activations, weights, scales, outputs, expert_id_per_token, + total_tokens, N, K, group_size); + } else { + launch_fp8(q, activations, weights, scales, outputs, expert_id_per_token, + total_tokens, N, K, group_size); + } +} + template void launch_fp8_by_mode(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, int group_size) { + const bool ksplit = moe_decode_fp8_ksplit_enabled() && moe_decode_fp8_ksplit_shape_ok(N, K, group_size); switch (fp8_decode_mode()) { case Fp8DecodeMode::kLut: - launch_fp8(q, activations, weights, scales, outputs, - expert_id_per_token, total_tokens, N, K, group_size); + launch_fp8_dispatch( + q, activations, weights, scales, outputs, expert_id_per_token, total_tokens, N, K, group_size, ksplit); return; case Fp8DecodeMode::kBits: - launch_fp8(q, activations, weights, scales, outputs, - expert_id_per_token, total_tokens, N, K, group_size); + launch_fp8_dispatch( + q, activations, weights, scales, outputs, expert_id_per_token, total_tokens, N, K, group_size, ksplit); return; case Fp8DecodeMode::kWord: default: - launch_fp8(q, activations, weights, scales, outputs, - expert_id_per_token, total_tokens, N, K, group_size); + launch_fp8_dispatch( + q, activations, weights, scales, outputs, expert_id_per_token, total_tokens, N, K, group_size, ksplit); return; } } diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index 005643da8b..790c7e7aba 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -1137,6 +1137,106 @@ def _run(): torch.testing.assert_close(out_scalar, ref, rtol=rtol, atol=atol) torch.testing.assert_close(out_dpas, out_scalar, rtol=rtol, atol=atol) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) + @pytest.mark.parametrize("group_size", [32, 128]) + def test_decode_fp8_ksplit_matches_strided(self, monkeypatch, dtype, fp8_dtype, group_size): + """FP8 decode: the K-split GEMV (``ARK_MOE_DECODE_FP8_KSPLIT=1``, the + default) must match the legacy per-work-item GEMV + (``ARK_MOE_DECODE_FP8_KSPLIT=0``) and the dequant reference. + + The two kernels compute the same dot products with a different lane + mapping: the legacy one gives a whole K row to one work-item, the + K-split one gives one output element to a whole sub-group and splits K + across its 16 lanes, then reduces. Only the fp32 summation order + differs, so the two outputs are compared at the same tight tolerance + used by ``test_decode_fp8_modes_match``. + + K is deliberately larger than one sub-group step (16 lanes x 16 + elements = 256) so the kernel's unrolled main loop *and* its remainder + loop are both exercised; ``group_size`` covers a group narrower and a + group wider than a lane's 16-element chunk. + """ + num_experts = 4 + tokens_per_expert = [1, 0, 2, 1] + total_tokens = sum(tokens_per_expert) + N, K = 256, 640 # K = 2*256 + 128 -> main loop plus a partial step + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, fp8_dtype) + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + group_size=group_size, + asym=False, + ) + + # Compare the two scalar-GEMV lane mappings only: keep DPAS off. + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "1") + out_ksplit = _run() + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "0") + out_strided = _run() + + dequant = _dequant_fp8(packed, scales, group_size, dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + rtol = 1e-1 if fp8_dtype == torch.float8_e5m2 else 5e-2 + atol = 1e-1 if fp8_dtype == torch.float8_e5m2 else 5e-2 + + assert out_ksplit.shape == (total_tokens, N) + torch.testing.assert_close(out_ksplit, out_strided, rtol=1e-3, atol=1e-3) + torch.testing.assert_close(out_ksplit, ref, rtol=rtol, atol=atol) + torch.testing.assert_close(out_strided, ref, rtol=rtol, atol=atol) + + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("group_size", [48, 96]) + def test_decode_fp8_ksplit_non_pow2_group_falls_back(self, monkeypatch, dtype, group_size): + """FP8 decode: a non-power-of-two ``group_size`` must still be correct. + + The K-split GEMV indexes the scale array with a shift, which is only + valid when ``group_size`` is a power of two of at least 16 (a lane's + 16-element chunk must sit inside one K-group). Other group sizes take + the legacy GEMV; this pins that fallback so a future gate change cannot + silently start feeding them to the shift-indexed kernel. + + ``group_size`` stays a multiple of 16 because the shared vectorized + inner loop reads 16-element blocks from the start of every K-group. + """ + num_experts = 4 + tokens_per_expert = [1, 0, 2, 1] + total_tokens = sum(tokens_per_expert) + N, K = 256, 480 # divisible by both 48 and 96 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, torch.float8_e4m3fn) + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "1") + out = ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + group_size=group_size, + asym=False, + ) + + dequant = _dequant_fp8(packed, scales, group_size, dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + assert out.shape == (total_tokens, N) + torch.testing.assert_close(out, ref, rtol=5e-2, atol=5e-2) + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index 1efdc6ccc1..a9b6b0723c 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -748,6 +748,62 @@ def _run(): monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8", raising=False) _print_row(label, N, K, total_tokens, lut_ms, word_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) + def test_perf_fp8_ksplit_vs_strided(self, monkeypatch, dtype, fp8_dtype): + """FP8 scalar-GEMV A/B: K-split lane mapping + (``ARK_MOE_DECODE_FP8_KSPLIT=1``, the default) vs the legacy + per-work-item mapping (``=0``). + + ``speedup`` is ``strided / ksplit`` (the K-split kernel is the "ark" + column). Decode does ~1 multiply-add per weight byte, so the kernel is + bound by how fast the weight tile streams in, not by arithmetic. The + legacy mapping gives each work-item its own ``[n, K]`` weight row, so + the 16 lanes of a sub-group read bytes ``K`` apart and one load + instruction touches 16 cache lines; it also launches only + ``total_tokens * N / 16`` threads, too few to keep enough loads in + flight to hide DRAM latency at batch 1. The K-split mapping gives one + output element to a whole sub-group and splits K across its lanes, so a + load instruction covers 256 contiguous weight bytes and the thread + count grows 16x, at the cost of one sub-group reduction per output + element. + + The FP8 DPAS fast path is disabled so both columns run the scalar GEMV. + """ + group_size = 128 + _print_header( + f"FP8 {str(fp8_dtype).split('.')[-1]} K-split vs strided GEMV (group_size={group_size}, " + f"act={str(dtype).split('.')[-1]}) -- per-work-item GEMV (baseline) vs K-split GEMV (ark)" + ) + for label, E, tpe, N, K in DECODE_SHAPES: + if K % group_size != 0: + continue + total_tokens = sum(tpe) + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, fp8_dtype) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "0") + strided_ms = _xpu_time_ms(_run) + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "1") + ksplit_ms = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_MOE_DECODE_FP8_KSPLIT", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8", raising=False) + _print_row(label, N, K, total_tokens, strided_ms, ksplit_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) def test_perf_fp8_dpas_vs_scalar(self, monkeypatch, dtype, fp8_dtype): From e43b98f7d59c3244329944aae53d5cc0e2312f76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:44:42 +0000 Subject: [PATCH 045/112] docs: describe the K-split FP8 decode GEMV (EN + CN) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_decode.hpp | 10 ++-- .../ark/test/README_MOE_PREFILL_PERF.md | 50 ++++++++++++++++++- .../ark/test/README_MOE_PREFILL_PERF_CN.md | 44 +++++++++++++++- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index c8af8e40c8..8793da4098 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1474,7 +1474,7 @@ constexpr int KSPLIT_WG_SGS = N_TILE; // ---------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_DECODE_FP8_KSPLIT` (default ON). When ON, the FP8 -// scalar decode GEMV uses the K-split kernel above; setting the var to "0" / +// scalar decode GEMV uses the K-split kernel below; setting the var to "0" / // "false" / "off" / "no" (case-insensitive) forces the legacy per-lane-strided // `launch_fp8`, for A/B comparison and regression escape. Re-read on every call // so tests and benchmarks can toggle the path in-process. @@ -1488,8 +1488,12 @@ inline bool moe_decode_fp8_ksplit_enabled() { // boundary and (b) the group index is a shift rather than an integer division // on the hot path. Every shipped FP8 quant config (32 / 64 / 128 / 256) passes; // anything else keeps the legacy GEMV, which handles arbitrary group sizes. +// `K >= KSPLIT_STEP` additionally keeps every lane of the sub-group busy -- +// below that some lanes own no chunk at all and only pay the reduction, which +// is the one regime where splitting K cannot pay for itself. inline bool moe_decode_fp8_ksplit_shape_ok(int N, int K, int group_size) { if (N % N_TILE != 0) return false; + if (K < KSPLIT_STEP) return false; if (group_size < KSPLIT_CH) return false; if ((group_size & (group_size - 1)) != 0) return false; // not a power of two if (K % group_size != 0) return false; @@ -1552,8 +1556,8 @@ void launch_fp8_ksplit(sycl::queue* q, const ScalarT* activations, const uint8_t const float s1 = static_cast(s_row[(k0 + KSPLIT_STEP) >> log2_group]) * kScaleBias; acc += (a0 + a1) * s0 + (b0 + b1) * s1; } - // Remainder (at most one chunk per lane, plus the lanes whose chunk - // falls past K when K < KSPLIT_STEP -- those simply contribute 0). + // Remainder: the lanes whose last chunk does not have a partner a + // full step away. At most one chunk per lane given the shape gate. for (; k0 < K; k0 += KSPLIT_STEP) { float p0 = 0.0f, p1 = 0.0f; fp8_decode_chunk(act_row + k0, w_row + k0, p0, p1); diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index 5d5177426a..c9d8524912 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -513,6 +513,48 @@ checkpoints are produced by scaling to `finfo(float8_e4m3fn).max == 448` and clamping, so those two encodings cannot occur. Callers that need NaN propagation can select `ARK_FP8_DECODE_MODE=lut` or `=bits`. +**K-split lane mapping (`ARK_MOE_DECODE_FP8_KSPLIT`, default ON).** Once +the dequant is a couple of DWORD ops, the scalar GEMV is purely a +bandwidth problem: at ~1 multiply-add per weight byte it can only run as +fast as the expert tile streams in. The original mapping gave one output +element to one *work-item*, so a lane walked a whole `[n, K]` weight row on +its own. Two costs follow: + +* **Uncoalesced weight loads.** Lanes `l` and `l+1` of a sub-group read + bytes `K` apart, so each 16-byte load instruction is split into 16 + cache-line requests. No DRAM byte is wasted (each lane consumes its lines + as it walks the row) but the memory controller sees 16 independent + streams per sub-group, the pattern DRAM row buffers handle worst. +* **Too few threads.** The grid is `total_tokens × N / 16` sub-groups — + 768 SIMD16 threads for a MiniMax-M2 batch-1 step (8 tokens, N=1536), + below the thread slots of a BMG-class GPU, so there are never enough + loads in flight to hide DRAM latency. + +`launch_fp8_ksplit` transposes the mapping: one *sub-group* per output +element, with the 16 lanes splitting K. Lane `l` owns the 16 consecutive K +elements at `l*16` inside each 256-element step, so one instruction covers +256 **contiguous** weight bytes (four full cache lines) and 512 contiguous +activation bytes, each thread walks a single sequential stream, and the +thread count grows 16× (12288 sub-groups for that batch-1 step). The price +is one `reduce_over_group` per output element — a handful of shuffles +against `K` multiply-adds — and 16× more activation traffic out of L1, +which has ample headroom at this arithmetic intensity. + +This is the same problem the int4 fallback solves by repacking weights +into an N-tiled layout (`ARK_MOE_DECODE_COALESCE_INT4`), which costs a +full extra pass over the weight tensor and a scratch buffer. FP8 weights +are one byte per element and already K-contiguous, so K-splitting the lane +mapping gets the same coalescing with no repack, no scratch and no extra +kernel launch. + +The kernel indexes the scale array with a shift, so the shape gate +requires a power-of-two `group_size ≥ 16` (every shipped FP8 config — 32 / +64 / 128 / 256 — passes) plus `N%16==0`, `K%group_size==0` and `K ≥ 256` (so +every lane of the sub-group owns at least one chunk); anything else keeps the +legacy GEMV, which handles arbitrary group sizes. All three +`ARK_FP8_DECODE_MODE` decoders run under both mappings, so the mode A/B +stays apples-to-apples. **Status: NEEDS-HARDWARE-VALIDATION.** + **FP8 DPAS decode dispatch.** `moe_decode_fp8_dpas_per_group_dispatch` (`sycl_tla_moe_prefill_fp8_dpas.hpp`, `ARK_MOE_DECODE_DPAS_FP8` default ON) is the FP8 twin of the S4 decode dispatch: same mainloop, same `[E, N, K]` @@ -567,12 +609,16 @@ per-group shape gate (`N%64==0`, `K%32==0`, `K%group_size==0`, | `ARK_FP8_DECODE_USE_LUT` | unset | Legacy selector, still honoured when set explicitly and when `ARK_FP8_DECODE_MODE` is unset/unrecognised: truthy → `lut`, falsy → `bits`. Also still drives the mixed-input prefill path. | | `ARK_MOE_DECODE_DPAS_FP8` | ON | Route FP8 decode to the per-group DPAS grouped GEMM when the shape and occupancy gates pass; `0` forces the scalar GEMV. | | `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | Minimum tokens per expert before the DPAS path is taken; `0` disables the gate (what the parity/A-B tests set). | +| `ARK_MOE_DECODE_FP8_KSPLIT` | ON | Scalar-GEMV lane mapping: one sub-group per output element with the lanes splitting K (coalesced weight loads, 16× the threads); `0` forces the legacy one-work-item-per-output-element GEMV. Shapes outside the gate (power-of-two `group_size ≥ 16`, `N%16==0`, `K%group_size==0`, `K ≥ 256`) always use the legacy mapping. | Perf A/B rows are `test_moe_decode_perf.py::test_perf_fp8_word_vs_lut` -(`speedup` is `lut / word`) and `::test_perf_fp8_dpas_vs_scalar` +(`speedup` is `lut / word`), `::test_perf_fp8_ksplit_vs_strided` +(`speedup` is `strided / ksplit`) and `::test_perf_fp8_dpas_vs_scalar` (`speedup` is `scalar / dpas`). Correctness is covered by `test_moe.py::test_decode_fp8_modes_match` (all three decoders agree, and -each tracks the dequant reference) and +each tracks the dequant reference), +`::test_decode_fp8_ksplit_matches_strided` (both lane mappings agree, plus +a non-power-of-two `group_size` fallback case) and `::test_decode_fp8_dpas_matches_scalar`. ## FP8 per-expert (per-tensor) perf tests diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index 27c4badc83..e965f0e612 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -408,6 +408,42 @@ kernel 以 `sycl::vec` 读取权重 —— 与它替换掉的字节 `finfo(float8_e4m3fn).max == 448` 缩放并 clamp 得到的,所以这两个编码不可能 出现。需要 NaN 传播的调用方可以选择 `ARK_FP8_DECODE_MODE=lut` 或 `=bits`。 +**K-split lane 映射(`ARK_MOE_DECODE_FP8_KSPLIT`,默认 ON)。** 当 dequant +只剩几条 DWORD 运算之后,scalar GEMV 就是一个纯粹的带宽问题:每个权重字节 +大约只做一次乘加,所以它最快只能跑到专家 tile 的搬运速度。原来的映射把一个 +输出元素交给一个 *work-item*,于是一个 lane 要独自走完整条 `[n, K]` 权重行。 +由此带来两笔开销: + +* **权重访存不合并。** 同一 sub-group 中 lane `l` 与 lane `l+1` 读到的字节 + 相距 `K`,因此每条 16 字节的 load 指令都会被拆成 16 个 cache line 请求。 + DRAM 字节并没有浪费(每个 lane 会沿着自己的行把这些 line 用完),但内存 + 控制器看到的是每个 sub-group 16 条互相独立的数据流 —— 这正是 DRAM row + buffer 最不擅长的访问模式。 +* **线程太少。** grid 只有 `total_tokens × N / 16` 个 sub-group —— + MiniMax-M2 batch-1 一步(8 个 token,N=1536)只有 768 个 SIMD16 线程, + 低于 BMG 级 GPU 的线程槽数量,飞行中的 load 永远不足以掩盖 DRAM 延迟。 + +`launch_fp8_ksplit` 把映射转置过来:一个 *sub-group* 负责一个输出元素,由它 +的 16 个 lane 切分 K。lane `l` 在每个 256 元素的步长内拥有起点为 `l*16` 的 +16 个连续 K 元素,于是一条指令覆盖 256 字节**连续**权重(四条完整 cache +line)和 512 字节连续激活,每个线程只走一条顺序数据流,线程数则提升 16× +(上述 batch-1 场景为 12288 个 sub-group)。代价是每个输出元素一次 +`reduce_over_group` —— 相对 `K` 次乘加只是几条 shuffle —— 以及 16× 的 L1 +激活流量,而在这样的计算密度下 L1 有充足余量。 + +int4 的回退路径解决的是同一个问题,办法是把权重 repack 成 N-tiled 布局 +(`ARK_MOE_DECODE_COALESCE_INT4`),那需要额外完整扫一遍权重张量并占用 +scratch 显存。FP8 权重每元素一个字节、本来就是 K 连续的,所以只切分 lane +映射就能拿到同样的合并访存,无需 repack、无需 scratch、也不多一次 kernel +启动。 + +该 kernel 用移位来索引 scale 数组,因此形状门控要求 `group_size` 是 ≥ 16 的 +2 的幂(已发布的 FP8 配置 —— 32 / 64 / 128 / 256 —— 全部满足),另外还要 +`N%16==0`、`K%group_size==0` 以及 `K ≥ 256`(保证 sub-group 的每个 lane 至少 +分到一个 chunk);其余情况继续走老的 GEMV,它支持任意 group size。三种 `ARK_FP8_DECODE_MODE` 解码器在两种映射下都能运行,所以 +decode mode 的 A/B 依然是同口径对比。 +**状态:NEEDS-HARDWARE-VALIDATION。** + **FP8 DPAS decode dispatch。** `moe_decode_fp8_dpas_per_group_dispatch` (`sycl_tla_moe_prefill_fp8_dpas.hpp`,`ARK_MOE_DECODE_DPAS_FP8` 默认 ON) 是 S4 decode dispatch 的 FP8 对应物:同一套 mainloop、同样的 `[E, N, K]` @@ -457,12 +493,16 @@ decode dispatch 改用每队列常驻的一个 slot(`get_persistent_atomic_buffe | `ARK_FP8_DECODE_USE_LUT` | 未设置 | 旧的选择开关;当它被显式设置、且 `ARK_FP8_DECODE_MODE` 未设置或取值无法识别时仍然生效:truthy → `lut`,falsy → `bits`。它同时仍然驱动 mixed-input prefill 路径。 | | `ARK_MOE_DECODE_DPAS_FP8` | ON | 形状与占用率门控都通过时,把 FP8 decode 路由到 per-group DPAS grouped GEMM;`0` 强制走 scalar GEMV。 | | `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | 走 DPAS 路径所需的最小每专家 token 数;`0` 关闭门控(对齐/A-B 用例所设)。 | +| `ARK_MOE_DECODE_FP8_KSPLIT` | ON | scalar GEMV 的 lane 映射:一个 sub-group 负责一个输出元素、由 lane 切分 K(访存合并,线程数 ×16);`0` 强制走老的「一个 work-item 一个输出元素」GEMV。未通过门控(`group_size` 为 ≥ 16 的 2 的幂、`N%16==0`、`K%group_size==0`、`K ≥ 256`)的形状始终使用老映射。 | 性能 A/B 行是 `test_moe_decode_perf.py::test_perf_fp8_word_vs_lut` -(`speedup` 为 `lut / word`)与 `::test_perf_fp8_dpas_vs_scalar` +(`speedup` 为 `lut / word`)、`::test_perf_fp8_ksplit_vs_strided` +(`speedup` 为 `strided / ksplit`)与 `::test_perf_fp8_dpas_vs_scalar` (`speedup` 为 `scalar / dpas`)。正确性由 `test_moe.py::test_decode_fp8_modes_match`(三种解码器互相一致,且各自都 -对齐 dequant 参考)与 `::test_decode_fp8_dpas_matches_scalar` 覆盖。 +对齐 dequant 参考)、`::test_decode_fp8_ksplit_matches_strided`(两种 lane +映射一致,并覆盖非 2 的幂 `group_size` 的回退)与 +`::test_decode_fp8_dpas_matches_scalar` 覆盖。 ## FP8 per-expert (per-tensor) 性能测试 From 51328f366531f58e197691722cdb3e25173282c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:48:24 +0000 Subject: [PATCH 046/112] perf: N-block the FP8 K-split decode GEMV and drop the per-call routing sync Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 58 +++- .../wrapper/include/sycl_tla_moe_decode.hpp | 285 +++++++++++++----- .../ark/test/README_MOE_PREFILL_PERF.md | 54 +++- .../ark/test/README_MOE_PREFILL_PERF_CN.md | 48 ++- auto_round_extension/ark/test/test_moe.py | 116 +++++++ .../ark/test/test_moe_decode_perf.py | 63 ++++ 6 files changed, 528 insertions(+), 96 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index 0a485bd7c8..9ead9ff265 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -1610,7 +1610,10 @@ def moe_gemm_decode( ``K_packed == K``. ``weight_bits`` is ignored; ``asym`` must be ``False`` (no zero-points for FP8). num_tokens_per_expert: ``[E]`` int32. Sum must equal - ``activations.shape[0]``. + ``activations.shape[0]``; this is a caller contract. It is checked + eagerly only when the tensor lives on the host, or when + ``ARK_MOE_VALIDATE_ROUTING`` is set -- summing a device tensor + costs a blocking device-to-host sync on the decode hot path. scales: ``[E, N, K // group_size]`` in activations dtype. Required for all quantized paths (int8/int4/int2/fp8); must be ``None`` for unquantized weights. @@ -1686,6 +1689,43 @@ def moe_decode_release_scratch() -> None: lib.moe_decode_release_scratch() +def moe_routing_validation_enabled() -> bool: + """Whether ``num_tokens_per_expert`` is checked against ``total_tokens``. + + The check needs the *sum* of the routing table, which for a table that + already lives on the device costs a reduction kernel plus a blocking + device-to-host copy -- a full pipeline flush on every call. Decode issues + one call per generated token, so that sync lands directly in the + token-latency path (and inside the timed region of the decode benchmarks), + where it is worth tens of microseconds against kernels that take ~150us. + + So the check runs unconditionally for host-side (CPU) routing tables, where + it is free, and is skipped for device tables unless + ``ARK_MOE_VALIDATE_ROUTING`` is set to a truthy value. The C++ side does not + need the host value: it consumes the device pointer directly and derives + ``expert_id_per_token`` on-device, clamped to ``num_experts - 1``. + + Truthy values (case-insensitive): anything other than "0", "false", "off", + "no". Unset means disabled (no sync). + """ + env = os.environ.get("ARK_MOE_VALIDATE_ROUTING") + if env is None: + return False + return env.strip().lower() not in ("0", "false", "off", "no") + + +def _check_routing_total(num_tokens_per_expert: torch.Tensor, total_tokens: int) -> None: + """Check ``sum(num_tokens_per_expert) == total_tokens`` without a device sync. + + See :func:`moe_routing_validation_enabled` for when the check is skipped. + """ + if num_tokens_per_expert.device.type != "cpu" and not moe_routing_validation_enabled(): + return + expected_total = int(num_tokens_per_expert.sum().item()) + if expected_total != total_tokens: + raise ValueError(f"Sum of num_tokens_per_expert ({expected_total}) != total_tokens ({total_tokens})") + + def _validate_moe_quant_args( activations: torch.Tensor, weights: torch.Tensor, @@ -1704,6 +1744,10 @@ def _validate_moe_quant_args( kernel-call site: ``(activations, weights, scales, zeros, num_tokens_per_expert, weight_dtype, total_tokens, N, K, num_experts)``. + + The caller owns the contract that ``num_tokens_per_expert`` sums to + ``activations.shape[0]``; see :func:`moe_routing_validation_enabled` for how + that is (or is not) enforced. """ if activations.device.type != "xpu": raise NotImplementedError(f"{api_name} is only supported on XPU") @@ -1817,9 +1861,7 @@ def _validate_moe_quant_args( if N % 16 != 0: raise ValueError(f"N must be a multiple of 16 (got {N})") - expected_total = int(num_tokens_per_expert.sum().item()) - if expected_total != total_tokens: - raise ValueError(f"Sum of num_tokens_per_expert ({expected_total}) != total_tokens ({total_tokens})") + _check_routing_total(num_tokens_per_expert, total_tokens) return (activations, weights, scales, zeros, num_tokens_per_expert, weight_dtype, total_tokens, N, K, num_experts) @@ -1875,9 +1917,7 @@ def moe_gemm( raise ValueError(f"num_tokens_per_expert length {num_tokens_per_expert.shape[0]} != num_experts {num_experts}") # Validate total tokens - expected_total = int(num_tokens_per_expert.sum().item()) - if expected_total != total_tokens: - raise ValueError(f"Sum of num_tokens_per_expert ({expected_total}) != total_tokens ({total_tokens})") + _check_routing_total(num_tokens_per_expert, total_tokens) lib = get_lib(activations) stream = get_stream(activations) @@ -2110,7 +2150,7 @@ def moe_gemm_prefill( ``[E, N, K]`` -- callers providing already-``[E, K, N]`` weights (as ``moe_gemm`` requires) should call ``moe_gemm`` directly. num_tokens_per_expert: ``[E]`` int32. Sum must equal - ``activations.shape[0]``. + ``activations.shape[0]`` (see :func:`moe_gemm_decode`). scales: ``[E, N, K // group_size]`` in activations dtype. Required for quantized paths; ignored (must be ``None``) for unquantized. zeros: ``[E, N, K // group_size]`` in activations dtype, required when @@ -2427,7 +2467,7 @@ def moe( weights: ``[E, N, K_packed]`` -- see :func:`moe_gemm_decode` for the quant-specific layout/dtype contract. num_tokens_per_expert: ``[E]`` int32. Sum must equal - ``activations.shape[0]``. + ``activations.shape[0]`` (see :func:`moe_gemm_decode`). scales, zeros, weight_bits, group_size, asym: forwarded to the underlying kernel; see :func:`moe_gemm_decode`. phase: dispatch mode. diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 8793da4098..65d1089d7e 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -53,6 +53,7 @@ #include #include #include +#include #include "bestla/bestla.h" #include "sycl_tla_moe_dequant.hpp" @@ -160,7 +161,7 @@ class MoEDecodeKernelInt2; template class MoEDecodeKernelFP8; -template +template class MoEDecodeKernelFP8KSplit; // ---------------------------------------------------------------------------- @@ -1256,69 +1257,92 @@ void launch_int2(sycl::queue* q, const ScalarT* activations, const uint8_t* weig // block comment). `launch_fp8_by_mode` picks between them. // ---------------------------------------------------------------------------- +// Activation / weight vector types for one 16-element FP8 sub-block, plus the +// MAC that consumes them. Splitting "load" from "multiply-accumulate" lets a +// caller issue several independent loads before any of them is consumed -- +// which is what the N-blocked K-split kernel below needs to keep more than one +// weight request per thread in flight. `fp8_decode_chunk` is a thin +// load-then-MAC wrapper over these, so both callers run identical arithmetic. +constexpr int FP8_SUB = 16; + +using Fp8ActVec16 = sycl::vec; + +// `kWord` mode reads the 16 weight bytes as four 32-bit words so the decode +// never leaves the native datapath; the other modes read them as bytes. Either +// way it is the same single 16-byte transaction with the same 16-byte alignment +// requirement. +template +using Fp8WeightVec16 = + std::conditional_t, sycl::vec>; + +template +static inline Fp8ActVec16 load_fp8_act_vec16(const ScalarT* act_ptr) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + return *reinterpret_cast(act_ptr); +} + +template +static inline Fp8WeightVec16 load_fp8_weight_vec16(const uint8_t* w_ptr) { + return *reinterpret_cast*>(w_ptr); +} + +// Accumulate `FP8_SUB` products of an already-loaded activation / weight pair. +// Two independent partial accumulators break the single fp32 dependency chain +// so the FMA pipeline is not latency-bound (same trick as `int4_decode_chunk`); +// the caller reduces the pair. +template +static inline void fp8_mac_vec16(const Fp8ActVec16& av, const Fp8WeightVec16& wv, float& acc0, float& acc1) { + if constexpr (Mode == Fp8DecodeMode::kWord) { + constexpr int WORDS = FP8_SUB / 4; // one 32-bit word per 4 FP8 bytes +#pragma unroll + for (int w = 0; w < WORDS; ++w) { + uint32_t lo2, hi2; + decode_fp8_quad_half_bits(wv[w], lo2, hi2); + const uint16_t hb[4] = {static_cast(lo2), static_cast(lo2 >> 16), + static_cast(hi2), static_cast(hi2 >> 16)}; +#pragma unroll + for (int u = 0; u < 4; u += 2) { + const ScalarT a0 = sycl::bit_cast(static_cast(av[4 * w + u])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[4 * w + u + 1])); + acc0 += static_cast(a0) * static_cast(sycl::bit_cast(hb[u])); + acc1 += static_cast(a1) * static_cast(sycl::bit_cast(hb[u + 1])); + } + } + } else { + constexpr bool kUseLut = (Mode == Fp8DecodeMode::kLut); +#pragma unroll + for (int u = 0; u < FP8_SUB; u += 2) { + const float w0 = decode_fp8(wv[u]); + const float w1 = decode_fp8(wv[u + 1]); + const ScalarT a0 = sycl::bit_cast(static_cast(av[u])); + const ScalarT a1 = sycl::bit_cast(static_cast(av[u + 1])); + acc0 += static_cast(a0) * w0; + acc1 += static_cast(a1) * w1; + } + } +} + // Vectorized inner accumulation over CHUNK consecutive K elements (CHUNK weight // bytes + a vec activation block). Templated on CHUNK so the // caller can run a wide (32) stage first and a narrower (16) stage for the // remainder, mirroring the int4/int8 paths. sycl::vec only supports widths of // 1, 2, 3, 4, 8 or 16, so CHUNK is processed in 16-wide sub-blocks. // -// In `kWord` mode the 16 weight bytes of a sub-block are read as a -// `sycl::vec` -- the same 16-byte transaction (and the same -// 16-byte alignment requirement) as the byte vector it replaces, but 32-bit -// typed, so the decode never leaves the native datapath. -// // The per-group scale is constant across the whole group, so it is NOT applied // here: this accumulates the raw dot product (sum of act * decoded_fp8) and the // caller multiplies the group total by the scale once (Σ a·(w·s) == s·Σ a·w). // For the per-expert / per-tensor scale case (group_size == K, one scale per // output row) this collapses the whole K reduction to a single scale multiply, // removing one multiply per K element on the decode hot path. -// -// Two independent partial accumulators break the single fp32 dependency chain -// so the FMA pipeline is not latency-bound (same trick as `int4_decode_chunk`); -// the caller reduces the pair. template static inline void fp8_decode_chunk(const ScalarT* act_ptr, const uint8_t* w_ptr, float& acc0, float& acc1) { static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - static_assert(CHUNK % 16 == 0, "CHUNK must be a multiple of 16"); - constexpr int SUB = 16; - using ActVec = sycl::vec; -#pragma unroll - for (int s = 0; s < CHUNK / SUB; ++s) { - const ActVec av = *reinterpret_cast(act_ptr + s * SUB); - if constexpr (Mode == Fp8DecodeMode::kWord) { - constexpr int WORDS = SUB / 4; // one 32-bit word per 4 FP8 bytes - using WordVec = sycl::vec; - const WordVec wv = *reinterpret_cast(w_ptr + s * SUB); -#pragma unroll - for (int w = 0; w < WORDS; ++w) { - uint32_t lo2, hi2; - decode_fp8_quad_half_bits(wv[w], lo2, hi2); - const uint16_t hb[4] = {static_cast(lo2), static_cast(lo2 >> 16), - static_cast(hi2), static_cast(hi2 >> 16)}; -#pragma unroll - for (int u = 0; u < 4; u += 2) { - const ScalarT a0 = sycl::bit_cast(static_cast(av[4 * w + u])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[4 * w + u + 1])); - acc0 += static_cast(a0) * static_cast(sycl::bit_cast(hb[u])); - acc1 += static_cast(a1) * static_cast(sycl::bit_cast(hb[u + 1])); - } - } - } else { - constexpr bool kUseLut = (Mode == Fp8DecodeMode::kLut); - using ByteVec = sycl::vec; - const ByteVec wv = *reinterpret_cast(w_ptr + s * SUB); + static_assert(CHUNK % FP8_SUB == 0, "CHUNK must be a multiple of 16"); #pragma unroll - for (int u = 0; u < SUB; u += 2) { - const float w0 = decode_fp8(wv[u]); - const float w1 = decode_fp8(wv[u + 1]); - const ScalarT a0 = sycl::bit_cast(static_cast(av[u])); - const ScalarT a1 = sycl::bit_cast(static_cast(av[u + 1])); - acc0 += static_cast(a0) * w0; - acc1 += static_cast(a1) * w1; - } - } + for (int s = 0; s < CHUNK / FP8_SUB; ++s) { + fp8_mac_vec16(load_fp8_act_vec16(act_ptr + s * FP8_SUB), + load_fp8_weight_vec16(w_ptr + s * FP8_SUB), acc0, acc1); } } @@ -1457,6 +1481,10 @@ void launch_fp8(sycl::queue* q, const ScalarT* activations, const uint8_t* weigh // of once per group; that is one extra multiply per `KSPLIT_CH` elements and // keeps the `Sigma a * (w * s) == s * Sigma a * w` fold exact-per-group, // including the folded `2^-8` E4M3 word-decode bias. +// +// On top of that mapping the sub-group also blocks N: it owns `NCOLS` +// consecutive output columns and reuses one activation load across all of them +// (see `moe_decode_fp8_ksplit_ncols`). // ---------------------------------------------------------------------------- // K elements a lane owns per step. 16 FP8 bytes = one 16-byte weight load and @@ -1467,9 +1495,8 @@ constexpr int KSPLIT_CH = 16; // K elements a sub-group covers per step: the contiguous span its 16 lanes // read in one instruction. constexpr int KSPLIT_STEP = SG_SIZE * KSPLIT_CH; -// Sub-groups per work-group. Each owns one output column, so a work-group -// covers `N_TILE` consecutive columns and `N % N_TILE == 0` (already required -// by every decode path) is enough to tile N exactly. +// Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group +// covers `N_TILE * NCOLS` consecutive columns. constexpr int KSPLIT_WG_SGS = N_TILE; // ---------------------------------------------------------------------------- @@ -1500,11 +1527,55 @@ inline bool moe_decode_fp8_ksplit_shape_ok(int N, int K, int group_size) { return true; } -template +// ---------------------------------------------------------------------------- +// N-blocking factor: output columns a sub-group owns. +// +// With one column per sub-group the hot loop issues, per 16-byte weight chunk, +// one weight message *and* one 32-byte activation message -- half the traffic a +// thread requests is the activation row, which every column of that token +// re-reads. Giving a sub-group NCOLS consecutive columns loads the activation +// chunk once and reuses it for all NCOLS weight chunks, so +// +// activation messages per weight chunk: 1 -> 1 / NCOLS +// independent weight loads in flight: 2 -> 2 * NCOLS +// +// The first effect cuts request-queue pressure; the second raises memory-level +// parallelism, which is what a pure-streaming GEMV is actually limited by (the +// measured kernel sits well below peak DRAM bandwidth, so it is latency- and +// message-bound, not bandwidth-bound). The cost is NCOLS times the live weight +// vectors and accumulators, so the factor is kept small. +// +// A work-group still holds `KSPLIT_WG_SGS` sub-groups, so it now covers +// `KSPLIT_WG_SGS * NCOLS` columns and N must divide by that. NCOLS == 1 +// reproduces the previous kernel instruction-for-instruction. +// `ARK_MOE_DECODE_FP8_KSPLIT_NCOLS` overrides the default (accepted values 1, 2 +// and 4); anything else, or a factor the shape cannot tile, falls back to the +// largest valid smaller power of two. +// ---------------------------------------------------------------------------- +constexpr int KSPLIT_NCOLS_DEFAULT = 2; +constexpr int KSPLIT_NCOLS_MAX = 4; + +inline int moe_decode_fp8_ksplit_ncols(int N) { + int ncols = KSPLIT_NCOLS_DEFAULT; + const char* env = std::getenv("ARK_MOE_DECODE_FP8_KSPLIT_NCOLS"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 1 && v <= KSPLIT_NCOLS_MAX && (v & (v - 1)) == 0) { + ncols = static_cast(v); + } + } + // A work-group covers `KSPLIT_WG_SGS * ncols` columns; shrink until it tiles. + while (ncols > 1 && (N % (KSPLIT_WG_SGS * ncols)) != 0) ncols /= 2; + return ncols; +} + +template void launch_fp8_ksplit(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, int group_size) { - if (!moe_decode_fp8_ksplit_shape_ok(N, K, group_size)) { + static_assert(NCOLS >= 1 && (NCOLS & (NCOLS - 1)) == 0, "NCOLS must be a power of two"); + if (!moe_decode_fp8_ksplit_shape_ok(N, K, group_size) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { throw std::invalid_argument("moe_gemm_decode(fp8): K-split GEMV called on an unsupported shape"); } if (total_tokens == 0) return; @@ -1516,13 +1587,13 @@ void launch_fp8_ksplit(sycl::queue* q, const ScalarT* activations, const uint8_t // every other mode). Exact power of two, applied once per lane chunk. constexpr float kScaleBias = (Mode == Fp8DecodeMode::kWord) ? fp8_word_scale_bias() : 1.0f; - // One sub-group per (token, output column); `KSPLIT_WG_SGS` of them per - // work-group so the dispatcher sees `N / N_TILE` work-groups per token - // instead of `N` single-sub-group ones. - sycl::range<2> global{static_cast(total_tokens), static_cast(N) * SG_SIZE}; + // One sub-group per (token, NCOLS output columns); `KSPLIT_WG_SGS` of them + // per work-group so the dispatcher sees `N / (N_TILE * NCOLS)` work-groups + // per token instead of `N` single-sub-group ones. + sycl::range<2> global{static_cast(total_tokens), static_cast(N / NCOLS) * SG_SIZE}; sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; - q->parallel_for>( + q->parallel_for>( sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { const auto sg = it.get_sub_group(); @@ -1531,46 +1602,98 @@ void launch_fp8_ksplit(sycl::queue* q, const ScalarT* activations, const uint8_t // The work-group is one row of `KSPLIT_WG_SGS * SG_SIZE` work-items, so // sub-group index and lane index are just the halves of the local id. const int lane = local_id % SG_SIZE; - const int n_global = static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE; + const int n_base = + (static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE) * NCOLS; const int expert = expert_id_per_token[token]; const ScalarT* act_row = activations + static_cast(token) * K; - const uint8_t* w_row = - weights + (static_cast(expert) * N + static_cast(n_global)) * K; - const ScalarT* s_row = - scales + (static_cast(expert) * N + static_cast(n_global)) * num_groups_k; + const size_t row0 = (static_cast(expert) * N + static_cast(n_base)); + const uint8_t* w_rows[NCOLS]; + const ScalarT* s_rows[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + w_rows[c] = weights + (row0 + static_cast(c)) * K; + s_rows[c] = scales + (row0 + static_cast(c)) * num_groups_k; + } // Each lane accumulates the scaled partial dot product of the chunks - // it owns; `fp8_decode_chunk` keeps two partial accumulators per chunk - // so the fp32 dependency chain stays broken. - float acc = 0.0f; + // it owns, for each of its NCOLS columns; `fp8_mac_vec16` keeps two + // partial accumulators per chunk so the fp32 dependency chain stays + // broken. NCOLS is a compile-time constant, so `acc` and the staged + // weight vectors below stay in registers. + float acc[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) acc[c] = 0.0f; + int k0 = lane * KSPLIT_CH; // Two chunks per iteration: their loads are independent, so the pair - // doubles the number of weight requests a thread keeps in flight. + // doubles the number of weight requests a thread keeps in flight. All + // 2 * NCOLS weight loads are issued before the first is consumed. for (; k0 + KSPLIT_STEP + KSPLIT_CH <= K; k0 += 2 * KSPLIT_STEP) { - float a0 = 0.0f, a1 = 0.0f, b0 = 0.0f, b1 = 0.0f; - fp8_decode_chunk(act_row + k0, w_row + k0, a0, a1); - fp8_decode_chunk(act_row + k0 + KSPLIT_STEP, - w_row + k0 + KSPLIT_STEP, b0, b1); - const float s0 = static_cast(s_row[k0 >> log2_group]) * kScaleBias; - const float s1 = static_cast(s_row[(k0 + KSPLIT_STEP) >> log2_group]) * kScaleBias; - acc += (a0 + a1) * s0 + (b0 + b1) * s1; + const Fp8ActVec16 av0 = load_fp8_act_vec16(act_row + k0); + const Fp8ActVec16 av1 = load_fp8_act_vec16(act_row + k0 + KSPLIT_STEP); + Fp8WeightVec16 wv0[NCOLS], wv1[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + wv0[c] = load_fp8_weight_vec16(w_rows[c] + k0); + wv1[c] = load_fp8_weight_vec16(w_rows[c] + k0 + KSPLIT_STEP); + } + const int g0 = k0 >> log2_group; + const int g1 = (k0 + KSPLIT_STEP) >> log2_group; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + float a0 = 0.0f, a1 = 0.0f, b0 = 0.0f, b1 = 0.0f; + fp8_mac_vec16(av0, wv0[c], a0, a1); + fp8_mac_vec16(av1, wv1[c], b0, b1); + const float s0 = static_cast(s_rows[c][g0]) * kScaleBias; + const float s1 = static_cast(s_rows[c][g1]) * kScaleBias; + acc[c] += (a0 + a1) * s0 + (b0 + b1) * s1; + } } // Remainder: the lanes whose last chunk does not have a partner a // full step away. At most one chunk per lane given the shape gate. for (; k0 < K; k0 += KSPLIT_STEP) { - float p0 = 0.0f, p1 = 0.0f; - fp8_decode_chunk(act_row + k0, w_row + k0, p0, p1); - acc += (p0 + p1) * (static_cast(s_row[k0 >> log2_group]) * kScaleBias); + const Fp8ActVec16 av = load_fp8_act_vec16(act_row + k0); + const int g = k0 >> log2_group; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + float p0 = 0.0f, p1 = 0.0f; + fp8_mac_vec16(av, load_fp8_weight_vec16(w_rows[c] + k0), p0, p1); + acc[c] += (p0 + p1) * (static_cast(s_rows[c][g]) * kScaleBias); + } } - const float total = sycl::reduce_over_group(sg, acc, sycl::plus{}); - if (lane == 0) { - outputs[static_cast(token) * N + n_global] = static_cast(total); +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + const float total = sycl::reduce_over_group(sg, acc[c], sycl::plus{}); + if (lane == 0) { + outputs[static_cast(token) * N + n_base + c] = static_cast(total); + } } }); } +// Runtime NCOLS -> compile-time NCOLS bridge. +template +void launch_fp8_ksplit_by_ncols(sycl::queue* q, const ScalarT* activations, const uint8_t* weights, + const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int group_size) { + switch (moe_decode_fp8_ksplit_ncols(N)) { + case 4: + launch_fp8_ksplit(q, activations, weights, scales, outputs, expert_id_per_token, + total_tokens, N, K, group_size); + return; + case 2: + launch_fp8_ksplit(q, activations, weights, scales, outputs, expert_id_per_token, + total_tokens, N, K, group_size); + return; + default: + launch_fp8_ksplit(q, activations, weights, scales, outputs, expert_id_per_token, + total_tokens, N, K, group_size); + return; + } +} + // Runtime -> compile-time bridge for the decode-mode selector. Keeps the // `moe_gemm_decode` dispatch to one branch per (act dtype, format) instead of // re-nesting the mode selection at every call site. The K-split vs legacy @@ -1581,8 +1704,8 @@ void launch_fp8_dispatch(sycl::queue* q, const ScalarT* activations, const uint8 const ScalarT* scales, ScalarT* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, int group_size, bool ksplit) { if (ksplit) { - launch_fp8_ksplit(q, activations, weights, scales, outputs, expert_id_per_token, - total_tokens, N, K, group_size); + launch_fp8_ksplit_by_ncols(q, activations, weights, scales, outputs, + expert_id_per_token, total_tokens, N, K, group_size); } else { launch_fp8(q, activations, weights, scales, outputs, expert_id_per_token, total_tokens, N, K, group_size); diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index c9d8524912..8655e53a63 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -555,6 +555,48 @@ legacy GEMV, which handles arbitrary group sizes. All three `ARK_FP8_DECODE_MODE` decoders run under both mappings, so the mode A/B stays apples-to-apples. **Status: NEEDS-HARDWARE-VALIDATION.** +**N-blocking inside the K-split kernel (`ARK_MOE_DECODE_FP8_KSPLIT_NCOLS`, +default 2).** With one output column per sub-group the hot loop issues, per +16-byte weight chunk, one weight message *and* one 32-byte activation +message — half of what a thread requests is the activation row, which every +column of that token re-reads — and only two weight loads are ever in +flight. Giving a sub-group `NCOLS` consecutive columns loads the activation +chunk once and reuses it for all of them: + +| | `NCOLS=1` | `NCOLS=n` | +| --- | --- | --- | +| activation messages per weight chunk | 1 | 1/n | +| independent weight loads in flight | 2 | 2n | + +The first effect cuts request-queue pressure; the second raises +memory-level parallelism, which is what a streaming GEMV sitting well below +peak DRAM bandwidth is actually limited by. The cost is `n` times the live +weight vectors and accumulators, so past some point the kernel spills — hence +the small ladder (1, 2, 4) and the conservative default. + +A work-group still holds 16 sub-groups, so it now covers `16 * NCOLS` +columns; an `N` that cannot be tiled at the requested factor falls back to +the largest valid smaller power of two on the host side (`N=1536` and +`N=3072` tile at every factor). The lane → K-chunk mapping, the per-chunk +scale fold and the final `reduce_over_group` are untouched, so the +arithmetic per output element is unchanged and `NCOLS=1` reproduces the +previous kernel exactly. `test_perf_fp8_ksplit_ncols_sweep` prints all three +factors per shape so the default can be set from measured data. +**Status: NEEDS-HARDWARE-VALIDATION.** + +**Routing-table validation (`ARK_MOE_VALIDATE_ROUTING`, default OFF).** The +Python entry point used to check `sum(num_tokens_per_expert) == total_tokens` +on every call. For a routing table that already lives on the device that +sum means a reduction kernel plus a *blocking* device-to-host copy, i.e. a +full pipeline flush — on a decode step whose kernel takes ~150 µs, and once +per generated token. It also lands inside the timed region of every decode +benchmark, because the queue is idle when the timing event is recorded. +The sum is now a caller contract (the C++ side never needed the host value: +it consumes the device pointer and derives `expert_id_per_token` on-device, +clamped to `num_experts - 1`); set `ARK_MOE_VALIDATE_ROUTING=1` to restore +the eager check when debugging a router. Host-side (CPU) routing tables are +still checked unconditionally, since summing those is free. + **FP8 DPAS decode dispatch.** `moe_decode_fp8_dpas_per_group_dispatch` (`sycl_tla_moe_prefill_fp8_dpas.hpp`, `ARK_MOE_DECODE_DPAS_FP8` default ON) is the FP8 twin of the S4 decode dispatch: same mainloop, same `[E, N, K]` @@ -610,15 +652,21 @@ per-group shape gate (`N%64==0`, `K%32==0`, `K%group_size==0`, | `ARK_MOE_DECODE_DPAS_FP8` | ON | Route FP8 decode to the per-group DPAS grouped GEMM when the shape and occupancy gates pass; `0` forces the scalar GEMV. | | `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | Minimum tokens per expert before the DPAS path is taken; `0` disables the gate (what the parity/A-B tests set). | | `ARK_MOE_DECODE_FP8_KSPLIT` | ON | Scalar-GEMV lane mapping: one sub-group per output element with the lanes splitting K (coalesced weight loads, 16× the threads); `0` forces the legacy one-work-item-per-output-element GEMV. Shapes outside the gate (power-of-two `group_size ≥ 16`, `N%16==0`, `K%group_size==0`, `K ≥ 256`) always use the legacy mapping. | +| `ARK_MOE_DECODE_FP8_KSPLIT_NCOLS` | `2` | Output columns one sub-group owns in the K-split GEMV (1, 2 or 4). Higher values reuse one activation load across more columns and keep more weight loads in flight, at the cost of more live registers. An `N` that `16 * NCOLS` cannot tile falls back to the largest valid smaller power of two. | +| `ARK_MOE_VALIDATE_ROUTING` | OFF | Eagerly check `sum(num_tokens_per_expert) == activations.shape[0]` for device-resident routing tables. The check costs a blocking device-to-host sync per call, so it is opt-in; CPU-resident tables are always checked. | Perf A/B rows are `test_moe_decode_perf.py::test_perf_fp8_word_vs_lut` (`speedup` is `lut / word`), `::test_perf_fp8_ksplit_vs_strided` -(`speedup` is `strided / ksplit`) and `::test_perf_fp8_dpas_vs_scalar` -(`speedup` is `scalar / dpas`). Correctness is covered by +(`speedup` is `strided / ksplit`), `::test_perf_fp8_ksplit_ncols_sweep` +(`speedup` is `NCOLS=1 / best NCOLS`, with all factors printed) and +`::test_perf_fp8_dpas_vs_scalar` (`speedup` is `scalar / dpas`). +Correctness is covered by `test_moe.py::test_decode_fp8_modes_match` (all three decoders agree, and each tracks the dequant reference), `::test_decode_fp8_ksplit_matches_strided` (both lane mappings agree, plus -a non-power-of-two `group_size` fallback case) and +a non-power-of-two `group_size` fallback case), +`::test_decode_fp8_ksplit_ncols_match` (every blocking factor agrees, plus +an untileable-`N` fallback case) and `::test_decode_fp8_dpas_matches_scalar`. ## FP8 per-expert (per-tensor) perf tests diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index e965f0e612..c33590c6a5 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -444,6 +444,43 @@ scratch 显存。FP8 权重每元素一个字节、本来就是 K 连续的,所 decode mode 的 A/B 依然是同口径对比。 **状态:NEEDS-HARDWARE-VALIDATION。** +**K-split kernel 内的 N 分块(`ARK_MOE_DECODE_FP8_KSPLIT_NCOLS`,默认 2)。** +当一个 sub-group 只负责一个输出列时,热循环中每读一个 16 字节权重 chunk, +既要发一条权重访存,又要发一条 32 字节的激活访存 —— 线程请求的数据里有一半 +是激活行,而该 token 的每一列都会重复读它 —— 并且飞行中的权重 load 始终只 +有两条。让一个 sub-group 负责 `NCOLS` 个连续列,激活 chunk 只需读一次就能 +被所有列复用: + +| | `NCOLS=1` | `NCOLS=n` | +| --- | --- | --- | +| 每个权重 chunk 的激活访存条数 | 1 | 1/n | +| 飞行中的独立权重 load | 2 | 2n | + +前者降低请求队列压力,后者提升 memory-level parallelism —— 对于一个远低于 +DRAM 峰值带宽的流式 GEMV,后者才是真正的瓶颈。代价是活跃的权重向量与 +累加器变成 `n` 倍,超过某个点 kernel 就会 spill,所以只提供 1、2、4 这个 +很短的阶梯,并且默认值取得保守。 + +一个 work-group 仍然是 16 个 sub-group,因此它现在覆盖 `16 * NCOLS` 列; +若 `N` 无法按所请求的因子切分,host 侧会回退到最大的、合法的更小 2 的幂 +(`N=1536` 与 `N=3072` 在所有因子下都能整除)。lane → K chunk 的映射、 +每个 chunk 的 scale 折叠以及最后的 `reduce_over_group` 都没有改动,因此 +单个输出元素的算术完全不变,`NCOLS=1` 与改动前的 kernel 完全一致。 +`test_perf_fp8_ksplit_ncols_sweep` 会逐形状打印全部三个因子的耗时,便于用 +实测数据确定默认值。 +**状态:NEEDS-HARDWARE-VALIDATION。** + +**路由表校验(`ARK_MOE_VALIDATE_ROUTING`,默认 OFF)。** Python 入口原先 +在每次调用时都会检查 `sum(num_tokens_per_expert) == total_tokens`。当路由表 +本身就在设备上时,这个求和意味着一次 reduction kernel 外加一次**阻塞式**的 +device-to-host 拷贝,也就是一次完整的流水线 flush —— 而 decode 一步的 kernel +本身只有约 150 µs,并且每生成一个 token 就要付一次。它同样落在 decode +benchmark 的计时区间内,因为记录计时 event 时队列正好是空的。 +现在这个求和关系是调用方契约(C++ 侧本来就不需要 host 上的值:它直接使用 +设备指针,并在设备上推导 `expert_id_per_token`,且会 clamp 到 +`num_experts - 1`);调试 router 时可设置 `ARK_MOE_VALIDATE_ROUTING=1` 恢复 +即时校验。位于 host(CPU)上的路由表仍然始终校验,因为对它们求和是免费的。 + **FP8 DPAS decode dispatch。** `moe_decode_fp8_dpas_per_group_dispatch` (`sycl_tla_moe_prefill_fp8_dpas.hpp`,`ARK_MOE_DECODE_DPAS_FP8` 默认 ON) 是 S4 decode dispatch 的 FP8 对应物:同一套 mainloop、同样的 `[E, N, K]` @@ -494,14 +531,19 @@ decode dispatch 改用每队列常驻的一个 slot(`get_persistent_atomic_buffe | `ARK_MOE_DECODE_DPAS_FP8` | ON | 形状与占用率门控都通过时,把 FP8 decode 路由到 per-group DPAS grouped GEMM;`0` 强制走 scalar GEMV。 | | `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | 走 DPAS 路径所需的最小每专家 token 数;`0` 关闭门控(对齐/A-B 用例所设)。 | | `ARK_MOE_DECODE_FP8_KSPLIT` | ON | scalar GEMV 的 lane 映射:一个 sub-group 负责一个输出元素、由 lane 切分 K(访存合并,线程数 ×16);`0` 强制走老的「一个 work-item 一个输出元素」GEMV。未通过门控(`group_size` 为 ≥ 16 的 2 的幂、`N%16==0`、`K%group_size==0`、`K ≥ 256`)的形状始终使用老映射。 | +| `ARK_MOE_DECODE_FP8_KSPLIT_NCOLS` | `2` | K-split GEMV 中一个 sub-group 负责的输出列数(1、2 或 4)。取值越大,一次激活 load 被复用的列越多、飞行中的权重 load 越多,代价是活跃寄存器更多。若 `16 * NCOLS` 无法整除 `N`,会回退到最大的、合法的更小 2 的幂。 | +| `ARK_MOE_VALIDATE_ROUTING` | OFF | 即时校验 `sum(num_tokens_per_expert) == activations.shape[0]`(针对位于设备上的路由表)。该校验每次调用都要付一次阻塞式 device-to-host 同步,因此改为按需开启;位于 CPU 上的路由表始终校验。 | 性能 A/B 行是 `test_moe_decode_perf.py::test_perf_fp8_word_vs_lut` (`speedup` 为 `lut / word`)、`::test_perf_fp8_ksplit_vs_strided` -(`speedup` 为 `strided / ksplit`)与 `::test_perf_fp8_dpas_vs_scalar` -(`speedup` 为 `scalar / dpas`)。正确性由 +(`speedup` 为 `strided / ksplit`)、`::test_perf_fp8_ksplit_ncols_sweep` +(`speedup` 为 `NCOLS=1 / 最优 NCOLS`,并打印全部因子)与 +`::test_perf_fp8_dpas_vs_scalar`(`speedup` 为 `scalar / dpas`)。正确性由 `test_moe.py::test_decode_fp8_modes_match`(三种解码器互相一致,且各自都 对齐 dequant 参考)、`::test_decode_fp8_ksplit_matches_strided`(两种 lane -映射一致,并覆盖非 2 的幂 `group_size` 的回退)与 +映射一致,并覆盖非 2 的幂 `group_size` 的回退)、 +`::test_decode_fp8_ksplit_ncols_match`(各分块因子结果一致,并覆盖 `N` +无法整除时的回退)与 `::test_decode_fp8_dpas_matches_scalar` 覆盖。 ## FP8 per-expert (per-tensor) 性能测试 diff --git a/auto_round_extension/ark/test/test_moe.py b/auto_round_extension/ark/test/test_moe.py index 790c7e7aba..fd6d04c346 100644 --- a/auto_round_extension/ark/test/test_moe.py +++ b/auto_round_extension/ark/test/test_moe.py @@ -872,6 +872,30 @@ def test_decode_validation_errors(self): asym=True, ) + def test_routing_sum_validation_is_opt_in(self, monkeypatch): + """A mismatched routing table is only rejected when asked for. + + Summing ``num_tokens_per_expert`` means pulling a device tensor back to + the host, which stalls the whole queue -- far too expensive to pay once + per generated token. The check is therefore opt-in via + ``ARK_MOE_VALIDATE_ROUTING``; otherwise the sum being + ``activations.shape[0]`` is a caller contract. + """ + activations = torch.randn(2, 128, dtype=torch.float16, device="xpu") + weights = torch.randn(2, 32, 128, dtype=torch.float16, device="xpu") + # Sums to 3, not to activations.shape[0] == 2. + bad_ntpe = torch.tensor([2, 1], dtype=torch.int32, device="xpu") + + monkeypatch.setenv("ARK_MOE_VALIDATE_ROUTING", "1") + assert ark.moe_routing_validation_enabled() is True + with pytest.raises(ValueError, match="num_tokens_per_expert"): + ark.moe_gemm_decode(activations, weights, bad_ntpe, weight_bits=16) + + monkeypatch.delenv("ARK_MOE_VALIDATE_ROUTING", raising=False) + assert ark.moe_routing_validation_enabled() is False + monkeypatch.setenv("ARK_MOE_VALIDATE_ROUTING", "0") + assert ark.moe_routing_validation_enabled() is False + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("group_size", [32, 128]) def test_decode_int8_sym(self, dtype, group_size): @@ -1237,6 +1261,98 @@ def test_decode_fp8_ksplit_non_pow2_group_falls_back(self, monkeypatch, dtype, g assert out.shape == (total_tokens, N) torch.testing.assert_close(out, ref, rtol=5e-2, atol=5e-2) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + @pytest.mark.parametrize("group_size", [32, 128]) + def test_decode_fp8_ksplit_ncols_match(self, monkeypatch, dtype, group_size): + """FP8 decode: every N-blocking factor must produce the same result. + + ``ARK_MOE_DECODE_FP8_KSPLIT_NCOLS`` sets how many consecutive output + columns one sub-group owns. The factor only changes which columns share + an activation load -- the per-output arithmetic (lane -> K chunk + mapping, per-chunk scale fold, final ``reduce_over_group``) is + unchanged -- so all factors must agree with each other to within + fp-contraction noise, and with the dequant reference. + + The N chosen here (256) is divisible by ``16 * 4``, so no factor is + silently reduced by the host-side tiling fallback, and K is not a + multiple of the 256-element sub-group step so the remainder loop is + exercised for every factor. + """ + num_experts = 4 + tokens_per_expert = [1, 0, 2, 1] + total_tokens = sum(tokens_per_expert) + N, K = 256, 640 # 256 % (16*4) == 0; K = 2*256 + 128 + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, torch.float8_e4m3fn) + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "1") + + outs = {} + for ncols in (1, 2, 4): + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT_NCOLS", str(ncols)) + outs[ncols] = ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + group_size=group_size, + asym=False, + ) + + dequant = _dequant_fp8(packed, scales, group_size, dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + # One ulp of the output dtype at these magnitudes; any indexing bug + # (wrong column, dropped or double-counted K chunk) is orders of + # magnitude larger than this. + tight = 1e-3 if dtype == torch.float16 else 8e-3 + for ncols, out in outs.items(): + assert out.shape == (total_tokens, N), f"ncols={ncols}" + torch.testing.assert_close(out, outs[1], rtol=tight, atol=tight, msg=f"ncols={ncols} != ncols=1") + torch.testing.assert_close(out, ref, rtol=5e-2, atol=5e-2) + + @pytest.mark.parametrize("dtype", [torch.float16]) + def test_decode_fp8_ksplit_ncols_falls_back_on_untileable_n(self, monkeypatch, dtype): + """FP8 decode: an N that a blocking factor cannot tile must still work. + + A work-group covers ``16 * NCOLS`` columns, so ``N = 48`` only tiles + with ``NCOLS == 1`` and ``N = 160`` only with ``NCOLS <= 2``. The host + selector must shrink the requested factor instead of launching a grid + that walks past the last column. + """ + num_experts = 3 + tokens_per_expert = [2, 0, 1] + total_tokens = sum(tokens_per_expert) + group_size = 32 + for N in (48, 160): + K = 512 + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(num_experts, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(num_experts, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, torch.float8_e4m3fn) + num_tokens_per_expert = torch.tensor(tokens_per_expert, dtype=torch.int32, device="xpu") + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "1") + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT_NCOLS", "4") + out = ark.moe_gemm_decode( + activations, + packed, + num_tokens_per_expert, + scales=scales, + group_size=group_size, + asym=False, + ) + + dequant = _dequant_fp8(packed, scales, group_size, dtype) + ref = _moe_decode_reference(activations, dequant, num_tokens_per_expert) + assert out.shape == (total_tokens, N), f"N={N}" + torch.testing.assert_close(out, ref, rtol=5e-2, atol=5e-2) + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index a9b6b0723c..ad348e3b78 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -804,6 +804,69 @@ def _run(): monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8", raising=False) _print_row(label, N, K, total_tokens, strided_ms, ksplit_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + def test_perf_fp8_ksplit_ncols_sweep(self, monkeypatch, dtype): + """FP8 K-split GEMV: sweep the N-blocking factor. + + ``ARK_MOE_DECODE_FP8_KSPLIT_NCOLS`` sets how many consecutive output + columns one sub-group owns. With one column per sub-group, half of what + a thread requests is the activation row -- which every column of that + token re-reads -- and only two weight loads are ever in flight. Owning + ``NCOLS`` columns loads the activation chunk once for all of them and + puts ``2 * NCOLS`` independent weight loads in flight, which is what a + latency-bound streaming GEMV needs; the cost is ``NCOLS`` times the live + weight registers, so past some point the kernel spills. + + The ``ark`` column is the best factor found and ``baseline`` is + ``NCOLS=1`` (the pre-blocking kernel), so ``speedup`` is the win from + blocking alone. The per-factor timings are printed underneath so the + default (``KSPLIT_NCOLS_DEFAULT`` in ``sycl_tla_moe_decode.hpp``) can be + set from measured data rather than from the register-pressure estimate + it currently reflects. + """ + group_size = 128 + fp8_dtype = torch.float8_e4m3fn + factors = (1, 2, 4) + _print_header( + f"FP8 {str(fp8_dtype).split('.')[-1]} K-split N-blocking sweep " + f"(group_size={group_size}, act={str(dtype).split('.')[-1]}) " + f"-- NCOLS=1 (baseline) vs best NCOLS (ark)" + ) + for label, E, tpe, N, K in DECODE_SHAPES: + if K % group_size != 0: + continue + total_tokens = sum(tpe) + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_fp8(w_float, scales, group_size, fp8_dtype) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_FP8", "0") + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT", "1") + per_factor = {} + for ncols in factors: + monkeypatch.setenv("ARK_MOE_DECODE_FP8_KSPLIT_NCOLS", str(ncols)) + per_factor[ncols] = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_MOE_DECODE_FP8_KSPLIT_NCOLS", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_FP8_KSPLIT", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_FP8", raising=False) + + best = min(per_factor, key=per_factor.get) + _print_row(label, N, K, total_tokens, per_factor[1], per_factor[best]) + detail = " ".join(f"NCOLS={n}: {per_factor[n]:.4f}ms" for n in factors) + print(f"{'':<18}{'':>7}{'':>7}{'':>8} {detail} best=NCOLS={best}") + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("fp8_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) def test_perf_fp8_dpas_vs_scalar(self, monkeypatch, dtype, fp8_dtype): From 03ca877400e26ec8f5ef140424a862c0b330341c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:32:40 +0000 Subject: [PATCH 047/112] perf: raise the MoE auto-dispatch decode cutoff from 32 to 128 tokens Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 23 ++++++----- .../ark/test/README_MOE_PREFILL_PERF.md | 26 ++++++++++++- .../ark/test/README_MOE_PREFILL_PERF_CN.md | 22 +++++++++-- .../ark/test/test_moe_unified.py | 38 ++++++++++++++++++- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index 9ead9ff265..f9f147583a 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -2405,15 +2405,18 @@ def _native_fp8_prefill_enabled() -> bool: # is hardware-dependent and can be overridden via # `ARK_MOE_AUTO_DECODE_MAX_TOKENS`. # -# Empirically (see `test_perf_int4_sym_dpas_vs_scalar`) the shared S4 DPAS -# grouped-GEMM that the prefill path uses already beats the scalar-GEMV decode -# kernel by ~2x once total tokens reach 256 (bs32), while it loses (~0.5x) only -# at the single-stream bs1 extreme (8 tokens). The crossover therefore sits far -# below 256, so the default is kept small: the scalar decode kernel is only -# preferred for the tiny single-/few-stream case where every expert sees well -# under one DPAS tile row. Mirrors vLLM-xpu-kernels' `w4a16` dispatch, which -# buckets on average tokens-per-expert rather than a large total-token cutoff. -_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS = 32 +# The cutoff used to be 32, a deliberately conservative value picked while the +# decode GEMV was still the bottleneck: back then only the tiny single-/few- +# stream case (every expert well under one DPAS tile row) was worth keeping off +# the prefill grouped-GEMM. The decode GEMV has since reached its bandwidth +# target for FP8 as well as int4-sym (K-split lane mapping + N-blocking inside +# the K-split kernel, and no per-call routing sync), so it now stays ahead of +# the grouped-GEMM over the whole small-batch range rather than only at the +# bs1 extreme, and the cutoff moves up to 128 total tokens accordingly. +# Batches above that still hand enough rows to each expert to fill the DPAS M +# tile, which is where the prefill path wins. Mirrors vLLM-xpu-kernels' `w4a16` +# dispatch, which likewise keeps the GEMV for the low tokens-per-expert regime. +_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS = 128 _MOE_VALID_PHASES = ("auto", "decode", "prefill") @@ -2482,7 +2485,7 @@ def moe( Use when the model knows it is in the prefill phase. decode_threshold: Total-token threshold for ``"auto"`` mode. If not provided, uses ``ARK_MOE_AUTO_DECODE_MAX_TOKENS`` when set to a - valid positive integer, otherwise defaults to 32. Explicit + valid positive integer, otherwise defaults to 128. Explicit argument values take precedence over the environment variable. Returns: diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index 8655e53a63..fce2ebcb14 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -471,6 +471,11 @@ int4-sym decode is now at target, and the same two levers that got it there apply to FP8: get the dequant off the byte-typed datapath, and stop paying setup cost per decode call. On top of that, the FP8 MoE dispatch from vllm-xpu-kernels is mirrored into a decode-specialised entry point. +Both levers have since landed and **FP8 decode is at target too** — the +word-native dequant, the K-split lane mapping with its N-blocking, and the +removal of the per-call routing sync. That is what moved the unified +`ark.moe(phase="auto")` cutoff from 32 to 128 total tokens (see +*Auto-dispatch cutoff* below). **Word-native FP8 decode (`ARK_FP8_DECODE_MODE`, default `word`).** The decode GEMV does roughly one multiply-add per weight byte, so the dequant @@ -553,7 +558,8 @@ requires a power-of-two `group_size ≥ 16` (every shipped FP8 config — 32 / every lane of the sub-group owns at least one chunk); anything else keeps the legacy GEMV, which handles arbitrary group sizes. All three `ARK_FP8_DECODE_MODE` decoders run under both mappings, so the mode A/B -stays apples-to-apples. **Status: NEEDS-HARDWARE-VALIDATION.** +stays apples-to-apples. **Status: hardware-validated** — this mapping is +what put FP8 decode at target. **N-blocking inside the K-split kernel (`ARK_MOE_DECODE_FP8_KSPLIT_NCOLS`, default 2).** With one output column per sub-group the hot loop issues, per @@ -582,7 +588,7 @@ scale fold and the final `reduce_over_group` are untouched, so the arithmetic per output element is unchanged and `NCOLS=1` reproduces the previous kernel exactly. `test_perf_fp8_ksplit_ncols_sweep` prints all three factors per shape so the default can be set from measured data. -**Status: NEEDS-HARDWARE-VALIDATION.** +**Status: hardware-validated at the shipped default (`NCOLS=2`).** **Routing-table validation (`ARK_MOE_VALIDATE_ROUTING`, default OFF).** The Python entry point used to check `sum(num_tokens_per_expert) == total_tokens` @@ -645,10 +651,26 @@ gate, which is what the parity and A/B perf tests set. Shapes that fail the per-group shape gate (`N%64==0`, `K%32==0`, `K%group_size==0`, `group_size ∈ {32,64,128,256}`) always fall back to the scalar GEMV. +**Auto-dispatch cutoff (`ARK_MOE_AUTO_DECODE_MAX_TOKENS`, default 128).** +`ark.moe(phase="auto")` routes to `moe_gemm_decode` when +`activations.shape[0] <= cutoff` and to `moe_gemm_prefill` otherwise. The +cutoff was 32 while the decode GEMV was still the bottleneck: only the tiny +single-/few-stream case was worth keeping off the prefill grouped GEMM. Now +that the FP8 decode GEMV is at target (as int4-sym already was) the GEMV +stays ahead across the whole small-batch range rather than just at the bs1 +extreme, so the cutoff is 128 total tokens; above that each expert receives +enough rows to fill the DPAS M tile, which is where the grouped GEMM wins. +The `decode_threshold=` keyword overrides it per call and takes precedence +over the env var, and `phase="decode"` / `phase="prefill"` bypass the +heuristic entirely. Dispatch parity is covered by +`test_moe_unified.py::TestMoeUnifiedDispatch`, which pins both the cutoff +boundary (128 tokens still decode) and the overrides. + | Env var | Default | Effect | | ------- | ------- | ------ | | `ARK_FP8_DECODE_MODE` | `word` | FP8 decode implementation for the scalar GEMV: `word` (bit-field move + folded scale bias), `lut` (128-entry magnitude table), `bits` (inline bit manipulation). | | `ARK_FP8_DECODE_USE_LUT` | unset | Legacy selector, still honoured when set explicitly and when `ARK_FP8_DECODE_MODE` is unset/unrecognised: truthy → `lut`, falsy → `bits`. Also still drives the mixed-input prefill path. | +| `ARK_MOE_AUTO_DECODE_MAX_TOKENS` | `128` | Total-token cutoff used by `ark.moe(phase="auto")`: at or below it the call goes to `moe_gemm_decode`, above it to `moe_gemm_prefill`. Non-positive/unparsable values fall back to the default; the `decode_threshold=` keyword wins over both. | | `ARK_MOE_DECODE_DPAS_FP8` | ON | Route FP8 decode to the per-group DPAS grouped GEMM when the shape and occupancy gates pass; `0` forces the scalar GEMV. | | `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | Minimum tokens per expert before the DPAS path is taken; `0` disables the gate (what the parity/A-B tests set). | | `ARK_MOE_DECODE_FP8_KSPLIT` | ON | Scalar-GEMV lane mapping: one sub-group per output element with the lanes splitting K (coalesced weight loads, 16× the threads); `0` forces the legacy one-work-item-per-output-element GEMV. Shapes outside the gate (power-of-two `group_size ≥ 16`, `N%16==0`, `K%group_size==0`, `K ≥ 256`) always use the legacy mapping. | diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index c33590c6a5..d0633117a7 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -371,7 +371,10 @@ token 数(16–128)都远低于该门控(8 × 192 个专家 == 1536 个 token) int4-sym decode 的性能已经达标,把它推到达标的两个手段同样适用于 FP8: 让 dequant 离开按字节的数据通路,以及不要在每次 decode 调用里重复付出 启动开销。在此之上,还把 vllm-xpu-kernels 的 FP8 MoE dispatch 镜像成一个 -decode 专用入口。 +decode 专用入口。这两个手段现已全部落地,**FP8 decode 的性能同样达标** —— +word-native dequant、带 N 分块的 K-split lane 映射,以及去掉每次调用的路由表 +同步。正因如此,统一入口 `ark.moe(phase="auto")` 的分发阈值才从 32 提高到 +128 个 token(见下文*自动分发阈值*)。 **Word-native FP8 解码 (`ARK_FP8_DECODE_MODE`, 默认 `word`)。** decode GEMV 每读一个权重字节大约只做一次乘加,所以 dequant *就是* kernel 本身。 @@ -442,7 +445,7 @@ scratch 显存。FP8 权重每元素一个字节、本来就是 K 连续的,所 `N%16==0`、`K%group_size==0` 以及 `K ≥ 256`(保证 sub-group 的每个 lane 至少 分到一个 chunk);其余情况继续走老的 GEMV,它支持任意 group size。三种 `ARK_FP8_DECODE_MODE` 解码器在两种映射下都能运行,所以 decode mode 的 A/B 依然是同口径对比。 -**状态:NEEDS-HARDWARE-VALIDATION。** +**状态:已通过硬件验证** —— 正是这个映射把 FP8 decode 推到达标。 **K-split kernel 内的 N 分块(`ARK_MOE_DECODE_FP8_KSPLIT_NCOLS`,默认 2)。** 当一个 sub-group 只负责一个输出列时,热循环中每读一个 16 字节权重 chunk, @@ -468,7 +471,7 @@ DRAM 峰值带宽的流式 GEMV,后者才是真正的瓶颈。代价是活跃的 单个输出元素的算术完全不变,`NCOLS=1` 与改动前的 kernel 完全一致。 `test_perf_fp8_ksplit_ncols_sweep` 会逐形状打印全部三个因子的耗时,便于用 实测数据确定默认值。 -**状态:NEEDS-HARDWARE-VALIDATION。** +**状态:已在发布默认值(`NCOLS=2`)下通过硬件验证。** **路由表校验(`ARK_MOE_VALIDATE_ROUTING`,默认 OFF)。** Python 入口原先 在每次调用时都会检查 `sum(num_tokens_per_expert) == total_tokens`。当路由表 @@ -524,10 +527,23 @@ decode dispatch 改用每队列常驻的一个 slot(`get_persistent_atomic_buffe (`N%64==0`、`K%32==0`、`K%group_size==0`、 `group_size ∈ {32,64,128,256}`)的形状始终回退到 scalar GEMV。 +**自动分发阈值(`ARK_MOE_AUTO_DECODE_MAX_TOKENS`,默认 128)。** +`ark.moe(phase="auto")` 在 `activations.shape[0] <= 阈值` 时分发到 +`moe_gemm_decode`,否则分发到 `moe_gemm_prefill`。该阈值原先是 32 —— 那时 +decode GEMV 仍是瓶颈,只有极小的单流/少流场景才值得不走 prefill grouped +GEMM。如今 FP8 decode GEMV 也已达标(int4-sym 此前就已达标),GEMV 在整个 +小 batch 区间都保持领先,而不再只是在 bs1 这一极端上占优,因此阈值提高到 +128 个 token;超过之后每个专家分到的行数足以填满 DPAS 的 M tile,那正是 +grouped GEMM 占优的区间。`decode_threshold=` 关键字可按调用覆盖该阈值, +优先级高于环境变量;`phase="decode"` / `phase="prefill"` 则完全跳过该启发式。 +分发行为由 `test_moe_unified.py::TestMoeUnifiedDispatch` 覆盖,其中同时锁定了 +阈值边界(128 个 token 仍走 decode)与两种覆盖方式。 + | Env 变量 | 默认值 | 作用 | | -------- | ------ | ---- | | `ARK_FP8_DECODE_MODE` | `word` | scalar GEMV 的 FP8 解码实现:`word`(位域搬移 + 折叠 scale bias)、`lut`(128 项幅值表)、`bits`(内联位运算)。 | | `ARK_FP8_DECODE_USE_LUT` | 未设置 | 旧的选择开关;当它被显式设置、且 `ARK_FP8_DECODE_MODE` 未设置或取值无法识别时仍然生效:truthy → `lut`,falsy → `bits`。它同时仍然驱动 mixed-input prefill 路径。 | +| `ARK_MOE_AUTO_DECODE_MAX_TOKENS` | `128` | `ark.moe(phase="auto")` 使用的总 token 阈值:小于等于它走 `moe_gemm_decode`,大于它走 `moe_gemm_prefill`。非正数或无法解析的取值会回退到默认值;`decode_threshold=` 关键字优先级高于两者。 | | `ARK_MOE_DECODE_DPAS_FP8` | ON | 形状与占用率门控都通过时,把 FP8 decode 路由到 per-group DPAS grouped GEMM;`0` 强制走 scalar GEMV。 | | `ARK_MOE_DECODE_DPAS_FP8_MIN_TPE` | `8` | 走 DPAS 路径所需的最小每专家 token 数;`0` 关闭门控(对齐/A-B 用例所设)。 | | `ARK_MOE_DECODE_FP8_KSPLIT` | ON | scalar GEMV 的 lane 映射:一个 sub-group 负责一个输出元素、由 lane 切分 K(访存合并,线程数 ×16);`0` 强制走老的「一个 work-item 一个输出元素」GEMV。未通过门控(`group_size` 为 ≥ 16 的 2 的幂、`N%16==0`、`K%group_size==0`、`K ≥ 256`)的形状始终使用老映射。 | diff --git a/auto_round_extension/ark/test/test_moe_unified.py b/auto_round_extension/ark/test/test_moe_unified.py index 2d5eb232fe..ad29f11589 100644 --- a/auto_round_extension/ark/test/test_moe_unified.py +++ b/auto_round_extension/ark/test/test_moe_unified.py @@ -93,6 +93,9 @@ def _unified_skip_reason() -> str: # --------------------------------------------------------------------------- _AUTO_DECODE_SHAPE = dict(num_experts=4, tokens_per_expert=[4, 4, 4, 4], N=128, K=256) # total_tokens=16 +# Sits exactly on the default cutoff (128 total tokens), so it pins the +# boundary of `_MOE_AUTO_DECODE_MAX_TOTAL_TOKENS` (dispatch is `<=`). +_AUTO_DECODE_BOUNDARY_SHAPE = dict(num_experts=4, tokens_per_expert=[32, 32, 32, 32], N=128, K=256) # total=128 _AUTO_PREFILL_SHAPE = dict(num_experts=4, tokens_per_expert=[80, 80, 80, 80], N=128, K=256) # total_tokens=320 @@ -193,10 +196,43 @@ def test_auto_picks_decode_for_small_total_tokens(self): group_size=group_size, asym=False, ) - # total tokens = 16 (<= default threshold 32) -> dispatched to decode + # total tokens = 16 (<= default threshold 128) -> dispatched to decode # -> output must be bit-identical to moe_gemm_decode. torch.testing.assert_close(out_auto, out_decode, rtol=0, atol=0) + def test_auto_picks_decode_at_default_threshold(self): + shape = _AUTO_DECODE_BOUNDARY_SHAPE + total_tokens = sum(shape["tokens_per_expert"]) + E, N, K = shape["num_experts"], shape["N"], shape["K"] + group_size = 128 + dtype = torch.float16 + + activations, packed, scales, _ = _make_int4_sym(E, N, K, group_size, dtype, total_tokens) + ntpe = torch.tensor(shape["tokens_per_expert"], dtype=torch.int32, device="xpu") + + out_auto = ark.moe( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + phase="auto", + ) + out_decode = ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + # total tokens = 128 == default threshold, and the dispatch is `<=`, + # so this still routes to decode. + torch.testing.assert_close(out_auto, out_decode, rtol=0, atol=0) + def test_auto_picks_prefill_for_large_total_tokens(self): shape = _AUTO_PREFILL_SHAPE total_tokens = sum(shape["tokens_per_expert"]) From 353543da0459f7226742f3cceb659b46b473fc0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:13:27 +0000 Subject: [PATCH 048/112] test: add qwen3-moe int4-sym prefill and decode perf benchmarks Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/test_moe_decode_perf.py | 172 +++++++++++++++++ .../ark/test/test_moe_prefill_perf.py | 178 +++++++++++++++++- 2 files changed, 349 insertions(+), 1 deletion(-) diff --git a/auto_round_extension/ark/test/test_moe_decode_perf.py b/auto_round_extension/ark/test/test_moe_decode_perf.py index ad348e3b78..ce32f765f1 100644 --- a/auto_round_extension/ark/test/test_moe_decode_perf.py +++ b/auto_round_extension/ark/test/test_moe_decode_perf.py @@ -35,6 +35,12 @@ pytest -v -s auto_round_extension/ark/test/test_moe_decode_perf.py \ --all-shapes + +``test_perf_int4_sym_qwen3_moe`` and +``test_perf_int4_sym_qwen3_moe_dpas_vs_scalar`` benchmark a separate +Qwen3-MoE shape group (``hidden=2048``, ``intermediate=768``, ``E=128``, +``top_k=8``, ``group_size=32``); ``--all-shapes`` widens their +model-token batch sweep instead. """ import auto_round_kernel @@ -260,6 +266,52 @@ def _spread_tokens(total_tokens: int, num_experts: int = 192) -> list: ] +# --------------------------------------------------------------------------- +# Qwen3-MoE shapes (issue repro) +# +# Captured from a fused-MoE layer that reported the ARK grouped GEMM losing +# to the native backend at small batch (``native_over_ark=0.604x``): +# +# hidden_size = 2048, intermediate_size = 768, num_local_experts = 128, +# num_experts_per_tok = 8, int4-sym weights, group_size = 32 +# +# w13 (gate/up-proj) [128, 1536, 1024] -> N = 2 * 768 = 1536, K = 2048 +# w2 (down-proj) [128, 2048, 384] -> N = 2048, K = 768 +# +# The trailing weight dim is the *packed* nibble count (``K // 2``); the +# reported scale tensors ``[128, 1536, 64]`` and ``[128, 2048, 24]`` pin +# ``group_size`` to 32 (2048/64 == 768/24 == 32) rather than the 128 used +# by the MiniMax rows above. +# +# Routed expert-token rows = ``batch * top_k`` spread round-robin over the +# 128 experts, so the reported batch of 2 model tokens reproduces the +# ``rows_per_expert_sum=16`` of the issue. Mirrors the identically named +# block in ``test_moe_prefill_perf.py``. +# --------------------------------------------------------------------------- + +_QWEN3_E = 128 +_QWEN3_HIDDEN = 2048 +_QWEN3_INTER = 768 +_QWEN3_TOPK = 8 +_QWEN3_GROUP_SIZE = 32 + +# (label, N, K) for the two grouped GEMMs of one Qwen3-MoE layer. +_QWEN3_NK = [ + ("qwen3 up ", 2 * _QWEN3_INTER, _QWEN3_HIDDEN), # gemm1: gate/up-proj + ("qwen3 down", _QWEN3_HIDDEN, _QWEN3_INTER), # gemm2: down-proj +] + +# Model-token batches; routed expert-token count is ``batch * top_k``. +# ``2`` is the batch from the issue report (16 routed rows / 16 active experts). +_QWEN3_BATCHES = [2] + +# Appended when ``--all-shapes`` is passed. The default +# ``ARK_MOE_DECODE_DPAS_S4_MIN_TPE`` occupancy gate is 8 tokens per expert, +# i.e. ``batch * 8 >= 128 * 8`` -> ``batch >= 128``, so these bracket the +# gate from both sides. +_QWEN3_BATCHES_EXTENDED = [8, 32, 128, 512] + + @pytest.fixture(autouse=True) def _maybe_restrict_shapes(request, monkeypatch): """Optionally restrict ``DECODE_SHAPES`` to the smallest shape group. @@ -579,6 +631,126 @@ def _run(): monkeypatch.delenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", raising=False) _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + def test_perf_int4_sym_qwen3_moe(self, request, dtype): + """INT4-sym decode at the Qwen3-MoE shape from the issue report. + + ``hidden=2048``, ``intermediate=768``, ``E=128``, ``top_k=8``, + ``group_size=32`` -- the configuration where a fused-MoE layer + measured ``native_over_ark=0.604x`` (ARK ~1.66x slower than the + native backend) at batch 2. Distinct from :meth:`test_perf_int4`, + which sweeps the MiniMax-M2 shapes at ``group_size=128``. + + ``ark(ms)`` is ``moe_gemm_decode`` under its **default** dispatch, + i.e. whatever the S4 occupancy gate picks for this batch, so the + row reports the number a model actually gets. See + :meth:`test_perf_int4_sym_qwen3_moe_dpas_vs_scalar` for the + path-level A/B behind that choice. + + Only the reported batch of 2 model tokens runs by default so a CI + pass stays short; pass ``--all-shapes`` to sweep larger batches. + """ + group_size = _QWEN3_GROUP_SIZE + E = _QWEN3_E + batches = list(_QWEN3_BATCHES) + if request.config.getoption("--all-shapes", default=False): + batches += _QWEN3_BATCHES_EXTENDED + _print_header( + f"INT4 sym qwen3-moe (E={E}, group_size={group_size}, act={str(dtype).split('.')[-1]}) " + f"-- ark.moe_gemm_decode vs dequant + per-expert A @ W.T" + ) + for nk_label, N, K in _QWEN3_NK: + for batch in batches: + total_tokens = batch * _QWEN3_TOPK + tpe = _spread_tokens(total_tokens, E) + label = f"{nk_label} bs{batch}" + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_sym(w_float, scales, group_size) + dequant = _dequant_int4_sym(packed, scales, group_size).to(dtype) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + base_ms = _xpu_time_ms(lambda: _default_moe_decode(activations, dequant, ntpe)) + ark_ms = _xpu_time_ms( + lambda: ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + ) + _print_row(label, N, K, total_tokens, base_ms, ark_ms) + + activations = w_float = scales = packed = dequant = ntpe = None + _release_xpu_memory() + + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + def test_perf_int4_sym_qwen3_moe_dpas_vs_scalar(self, request, monkeypatch, dtype): + """Qwen3-MoE int4-sym decode: S4 DPAS vs the scalar GEMV fallback. + + Same shapes and batches as + :meth:`test_perf_int4_sym_qwen3_moe`, but ``speedup`` here is + ``scalar / dpas`` (the DPAS path is the "ark" column) so the + occupancy gate's routing decision can be checked directly against + the measurement. ``ARK_MOE_DECODE_DPAS_S4_MIN_TPE=0`` disables the + tokens-per-expert gate so the DPAS column really runs DPAS -- at + the reported batch of 2 (16 routed rows over 128 experts, i.e. + 0.125 tokens per expert) the default gate otherwise routes to the + scalar GEMV, which is the same kernel int4-asym uses. + """ + group_size = _QWEN3_GROUP_SIZE + E = _QWEN3_E + batches = list(_QWEN3_BATCHES) + if request.config.getoption("--all-shapes", default=False): + batches += _QWEN3_BATCHES_EXTENDED + _print_header( + f"INT4 sym qwen3-moe DPAS vs scalar (E={E}, group_size={group_size}, " + f"act={str(dtype).split('.')[-1]}) -- scalar GEMV (baseline) vs S4 DPAS (ark)" + ) + for nk_label, N, K in _QWEN3_NK: + # Both Qwen3 GEMMs must clear the S4 DPAS shape gate + # (``moe_prefill_dpas_s4_pergroup_shape_ok``, shared with decode) + # or the dpas column would silently re-measure the scalar GEMV. + assert N % 64 == 0 and K % 32 == 0 and K % group_size == 0, f"{nk_label}: N={N} K={K} misses the DPAS gate" + for batch in batches: + total_tokens = batch * _QWEN3_TOPK + tpe = _spread_tokens(total_tokens, E) + label = f"{nk_label} bs{batch}" + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_sym(w_float, scales, group_size) + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + + def _run(): + return ark.moe_gemm_decode( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "0") + scalar_ms = _xpu_time_ms(_run) + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4", "1") + monkeypatch.setenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", "0") + dpas_ms = _xpu_time_ms(_run) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_S4_MIN_TPE", raising=False) + monkeypatch.delenv("ARK_MOE_DECODE_DPAS_S4", raising=False) + _print_row(label, N, K, total_tokens, scalar_ms, dpas_ms) + + activations = w_float = scales = packed = ntpe = None + _release_xpu_memory() + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("asym", [False, True]) def test_perf_int8(self, dtype, asym): diff --git a/auto_round_extension/ark/test/test_moe_prefill_perf.py b/auto_round_extension/ark/test/test_moe_prefill_perf.py index c322cb66bf..5a61d816ce 100644 --- a/auto_round_extension/ark/test/test_moe_prefill_perf.py +++ b/auto_round_extension/ark/test/test_moe_prefill_perf.py @@ -63,6 +63,11 @@ pytest -v -s auto_round_extension/ark/test/test_moe_prefill_perf.py \ --all-shapes + +``test_perf_int4_sym_qwen3_moe`` benchmarks a separate Qwen3-MoE shape +group (``hidden=2048``, ``intermediate=768``, ``E=128``, ``top_k=8``, +``group_size=32``) and is unaffected by ``--minimax-real-only``; +``--all-shapes`` widens its model-token batch sweep. """ import os @@ -428,6 +433,67 @@ def _minimax_real_tpe(total: int, max_ratio: float | None = None) -> list[int]: ] +# --------------------------------------------------------------------------- +# Qwen3-MoE shapes (issue repro) +# +# Captured from a fused-MoE layer that reported the ARK grouped GEMM losing +# to the native backend at small batch (``native_over_ark=0.604x``): +# +# hidden_size = 2048, intermediate_size = 768, num_local_experts = 128, +# num_experts_per_tok = 8, int4-sym weights, group_size = 32 +# +# w13 (gate/up-proj) [128, 1536, 1024] -> N = 2 * 768 = 1536, K = 2048 +# w2 (down-proj) [128, 2048, 384] -> N = 2048, K = 768 +# +# The trailing weight dim is the *packed* nibble count (``K // 2``); the +# reported scale tensors ``[128, 1536, 64]`` and ``[128, 2048, 24]`` pin +# ``group_size`` to 32 (2048/64 == 768/24 == 32) rather than the 128 used +# by the MiniMax rows above. +# +# Routed expert-token rows = ``batch * top_k`` spread round-robin over the +# 128 experts, so the reported batch of 2 model tokens reproduces the +# ``rows_per_expert_sum=16`` of the issue. +# --------------------------------------------------------------------------- + +_QWEN3_E = 128 +_QWEN3_HIDDEN = 2048 +_QWEN3_INTER = 768 +_QWEN3_TOPK = 8 +_QWEN3_GROUP_SIZE = 32 + +# (label, N, K) for the two grouped GEMMs of one Qwen3-MoE layer. +_QWEN3_NK = [ + ("qwen3 up ", 2 * _QWEN3_INTER, _QWEN3_HIDDEN), # gemm1: gate/up-proj + ("qwen3 down", _QWEN3_HIDDEN, _QWEN3_INTER), # gemm2: down-proj +] + +# Model-token batches; routed expert-token count is ``batch * top_k``. +# ``2`` is the batch from the issue report (16 routed rows / 16 active experts). +_QWEN3_BATCHES = [2] + +# Appended when ``--all-shapes`` is passed. The smallest DPAS M tile +# (``dpas_w4a16_policy_m_8``) wants >= 8 token rows per expert, i.e. +# ``batch * 8 >= 128 * 8`` -> ``batch >= 128``, so these bracket the tile +# occupancy point from both sides. +_QWEN3_BATCHES_EXTENDED = [32, 128, 512, 2048] + + +def _spread_tokens(total_tokens: int, num_experts: int) -> list[int]: + """Distribute ``total_tokens`` across ``num_experts`` round-robin. + + Returns a ``[num_experts]`` histogram summing exactly to + ``total_tokens`` where expert ``i`` receives a token before ``i+1`` + receives its second. This stripes the load across the expert range the + way a real top-k router does, instead of clustering every token onto + the first few experts. Used by the Qwen3-MoE rows to synthesise a + routing histogram of an exact size. + """ + tpe = [0] * num_experts + for i in range(total_tokens): + tpe[i % num_experts] += 1 + return tpe + + @pytest.fixture(autouse=True) def _maybe_restrict_shapes(request, monkeypatch): """Optionally restrict ``PREFILL_SHAPES``. @@ -504,11 +570,15 @@ def _print_header(title: str) -> None: * ``native(ms)`` / ``native TFLOPS``: FP8 rows only. ARK path with ``ARK_MOE_PREFILL_NATIVE_FP8=1`` — the fused scalar native-FP8 GEMM that skips the ``[E, K, N]`` bf16/fp16 workspace and folds the - per-K-group scale into the accumulator. ``--`` for non-FP8 rows. + per-K-group scale into the accumulator. ``--`` for non-FP8 rows, + except ``test_perf_int4_sym_qwen3_moe`` which reuses the column for + the two-pass S4->S8 upcast + shared INT8 DPAS mainloop. * ``dpas(ms)`` / ``dpas TFLOPS``: FP8 rows only. Variant B mixed-input DPAS grouped GEMM (default-on branch behind ``ARK_MOE_PREFILL_DPAS_FP8``). Prints ``--`` for non-FP8 rows and for builds where ``moe_gemm_prefill_fp8_dpas`` is not linked in. + The INT4 rows reuse the column for the S4 DPAS path behind + ``ARK_MOE_PREFILL_DPAS_S4``. * ``speedup``: ``baseline / ark``. * ``dpas speedup``: ``baseline / dpas`` -- the fused DPAS path's speedup over the same ``baseline`` denominator used for @@ -741,6 +811,112 @@ def test_perf_int4(self, dtype, asym): activations = ntpe = act_padded = w_float = scales = zeros = packed = dequant = None _release_xpu_memory() + @pytest.mark.skipif(bool(_QUANT_PREFILL_SKIP), reason=_QUANT_PREFILL_SKIP or "ok") + @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) + def test_perf_int4_sym_qwen3_moe(self, request, monkeypatch, dtype): + """INT4-sym prefill at the Qwen3-MoE shape from the issue report. + + ``hidden=2048``, ``intermediate=768``, ``E=128``, ``top_k=8``, + ``group_size=32`` -- the configuration where a fused-MoE layer + measured ``native_over_ark=0.604x`` (ARK ~1.66x slower than the + native backend) at batch 2. Distinct from :meth:`test_perf_int4`, + which sweeps the MiniMax-M2 shapes at ``group_size=128``. + + All three INT4-sym branches of ``moe_gemm_prefill`` are timed on + the same packed weights so the dispatcher's default choice can be + checked against the measurement: + + * ``ark(ms)`` -- legacy dequant into a bf16/fp16 ``[E, K, N]`` + workspace + the stock grouped GEMM (both DPAS gates forced off). + * ``native(ms)`` -- two-pass S4->S8 upcast into the workspace plus + the shared INT8 DPAS mainloop (``ARK_MOE_PREFILL_DPAS_INT8=1``, + ``ARK_MOE_PREFILL_DPAS_S4=0``). + * ``dpas(ms)`` -- single-pass S4 DPAS reading the packed nibbles + directly (``ARK_MOE_PREFILL_DPAS_S4=1``, the shipped default). + + Only the reported batch of 2 model tokens runs by default so a CI + pass stays short; pass ``--all-shapes`` to sweep larger batches + across the DPAS M-tile occupancy point. + """ + group_size = _QWEN3_GROUP_SIZE + E = _QWEN3_E + batches = list(_QWEN3_BATCHES) + if request.config.getoption("--all-shapes", default=False): + batches += _QWEN3_BATCHES_EXTENDED + _print_header( + f"INT4 sym qwen3-moe (E={E}, group_size={group_size}, act={str(dtype).split('.')[-1]}) " + f"-- ark.moe_gemm_prefill (prefill) vs single torch.bmm (weights pre-dequantized)", + ) + for nk_label, N, K in _QWEN3_NK: + # Both Qwen3 GEMMs must clear the S4 DPAS shape gate + # (``moe_prefill_dpas_s4_pergroup_shape_ok``) or the dpas column + # would silently measure the dequant fallback instead. + assert N % 64 == 0 and K % 32 == 0 and K % group_size == 0, f"{nk_label}: N={N} K={K} misses the DPAS gate" + for batch in batches: + total_tokens = batch * _QWEN3_TOPK + tpe = _spread_tokens(total_tokens, E) + label = f"{nk_label} bs{batch}" + + activations = torch.randn(total_tokens, K, dtype=dtype, device="xpu") + ntpe = torch.tensor(tpe, dtype=torch.int32, device="xpu") + act_padded = _build_bmm_pad_layout(activations, ntpe, E) + # Pack helpers expect weights in [E, N, K] layout. + w_float = (torch.randn(E, N, K, dtype=torch.float32, device="xpu") * 0.1).to(dtype) + scales = torch.empty(E, N, K // group_size, dtype=dtype, device="xpu") + packed = _pack_int4_sym(w_float, scales, group_size) + dequant = _dequant_int4_sym(packed, scales, group_size).to(dtype) + + deq_ms = _xpu_time_ms(lambda: _dequant_int4_sym(packed, scales, group_size).to(dtype)) + base_ms = _xpu_time_ms(lambda: _default_moe_prefill(act_padded, dequant)) + + def _run(): + return ark.moe_gemm_prefill( + activations, + packed, + ntpe, + scales=scales, + weight_bits=4, + group_size=group_size, + asym=False, + ) + + # Both gates are re-read from the env on every call, so the + # three paths can be A/B'd in-process. ``monkeypatch`` + # restores any pre-existing values at teardown. + monkeypatch.setenv("ARK_MOE_PREFILL_DPAS_S4", "0") + monkeypatch.setenv("ARK_MOE_PREFILL_DPAS_INT8", "0") + ark_ms = _xpu_time_ms(_run) + + monkeypatch.setenv("ARK_MOE_PREFILL_DPAS_INT8", "1") + upcast_ms = _xpu_time_ms(_run) + + monkeypatch.setenv("ARK_MOE_PREFILL_DPAS_S4", "1") + monkeypatch.setenv("ARK_MOE_PREFILL_DPAS_INT8", "0") + dpas_ms = _xpu_time_ms(_run) + + monkeypatch.delenv("ARK_MOE_PREFILL_DPAS_S4", raising=False) + monkeypatch.delenv("ARK_MOE_PREFILL_DPAS_INT8", raising=False) + + flops = _compute_moe_flops(total_tokens, K, N, E) + _print_row( + label, + E, + N, + K, + total_tokens, + base_ms, + deq_ms, + ark_ms, + flops / (ark_ms * 1e-3) / 1e12, + native_ms=upcast_ms, + native_tflops=flops / (upcast_ms * 1e-3) / 1e12, + dpas_ms=dpas_ms, + dpas_tflops=flops / (dpas_ms * 1e-3) / 1e12, + ) + + activations = ntpe = act_padded = w_float = scales = packed = dequant = None + _release_xpu_memory() + @pytest.mark.skipif(bool(_QUANT_PREFILL_SKIP), reason=_QUANT_PREFILL_SKIP or "ok") @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) @pytest.mark.parametrize("asym", [False, True]) From 42949b74a65b752a97aed7dd577f47663ea93a19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:15:38 +0000 Subject: [PATCH 049/112] docs: document Qwen3-MoE int4-sym prefill/decode perf tests Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_PREFILL_PERF.md | 48 +++++++++++++++++++ .../ark/test/README_MOE_PREFILL_PERF_CN.md | 46 ++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md index fce2ebcb14..085faa3a07 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF.md @@ -62,6 +62,9 @@ pytest -v -s test_moe_prefill_perf.py::TestMoEGemmPrefillPerf::test_perf_int4 # INT8 symmetric quantization with bfloat16 activations pytest -v -s test_moe_prefill_perf.py::TestMoEGemmPrefillPerf::test_perf_int8 -k "bfloat16 and not asym" + +# INT4-sym on the Qwen3-MoE shapes (hidden=2048, inter=768, E=128, top_k=8) +pytest -v -s test_moe_prefill_perf.py -k test_perf_int4_sym_qwen3_moe --run-moe-prefill-perf ``` **Note**: The `-s` flag is required to see the printed timing tables and TFLOPS output. @@ -106,9 +109,12 @@ test_moe_prefill_perf.py │ └── Single `torch.bmm` over [E, M_max, K] padded activations ├── Test shapes (PREFILL_SHAPES) │ └── Various realistic MoE configurations +├── Qwen3-MoE shapes (_QWEN3_NK / _QWEN3_BATCHES) +│ └── hidden=2048, inter=768, E=128, top_k=8, group_size=32 (issue repro) └── Test cases (TestMoEGemmPrefillPerf) ├── test_perf_fp (FP16/BF16) ├── test_perf_int4 (INT4 sym/asym) + ├── test_perf_int4_sym_qwen3_moe (INT4 sym, Qwen3-MoE shapes) ├── test_perf_int8 (INT8 sym/asym) ├── test_perf_int2 (INT2 sym/asym) └── test_perf_fp8 (FP8 e4m3fn/e5m2) @@ -271,6 +277,48 @@ carries a mixed-input **DPAS S4** column (`dpas(ms)` / `dpas TFLOPS`). + GEMM path) and re-enables `ARK_MOE_PREFILL_DPAS_S4=1` for the `dpas(ms)` column (single-pass packed-nibble mainloop). +**Qwen3-MoE shape rows (issue repro).** `test_perf_int4_sym_qwen3_moe` +benchmarks a second, independent shape group, captured from a fused-MoE +layer where ARK lost to the native backend (`native_over_ark=0.604x`) at +small batch: `hidden_size=2048`, `intermediate_size=768`, +`num_local_experts=128`, `num_experts_per_tok=8`, `group_size=32` — i.e. +`w13 [128, 1536, 1024]` (gemm1 `N=1536`, `K=2048`) and +`w2 [128, 2048, 384]` (gemm2 `N=2048`, `K=768`), where the trailing dim +is the packed nibble count `K/2` and the scale tensors +`[128, 1536, 64]` / `[128, 2048, 24]` pin `group_size` to 32. Unlike +`test_perf_int4` (MiniMax-M2 shapes at `group_size=128`) it fills **all +three** ARK columns from the same packed weights, so the dispatcher's +default choice can be checked against the measurement: + +| Column | Path | Env | +| ------------ | --------------------------------------------------------------- | -------------------------------------- | +| `ark(ms)` | legacy dequant into `[E, K, N]` + stock grouped GEMM | `DPAS_S4=0`, `DPAS_INT8=0` | +| `native(ms)` | two-pass S4→S8 upcast + shared INT8 DPAS mainloop | `DPAS_S4=0`, `DPAS_INT8=1` | +| `dpas(ms)` | single-pass S4 DPAS (packed-nibble read) — the shipped default | `DPAS_S4=1` | + +Routed rows are `batch * top_k` spread round-robin over the 128 experts, +so the reported batch of 2 model tokens reproduces the issue's +`rows_per_expert_sum=16`. Only that batch runs by default; `--all-shapes` +extends the sweep to 32/128/512/2048 model tokens, bracketing the +8-rows-per-expert DPAS tile occupancy point (`batch >= 128`). + +```bash +pytest -v -s test_moe_prefill_perf.py -k test_perf_int4_sym_qwen3_moe \ + --run-moe-prefill-perf +``` + +The matching decode benchmarks live in `test_moe_decode_perf.py`: +`test_perf_int4_sym_qwen3_moe` (default dispatch vs. the dequant + +per-expert `A @ W.T` baseline) and +`test_perf_int4_sym_qwen3_moe_dpas_vs_scalar` (scalar GEMV vs. S4 DPAS +with `ARK_MOE_DECODE_DPAS_S4_MIN_TPE=0`, isolating the occupancy gate's +routing decision — at batch 2 this shape sits at 0.125 tokens per +expert, far below the 8-tokens-per-expert gate). + +```bash +pytest -v -s test_moe_decode_perf.py -k qwen3_moe +``` + Two independent DPAS paths are available for S4-sym; asym S4 always falls through to the dequant path. diff --git a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md index d0633117a7..3f24ee67dc 100644 --- a/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md +++ b/auto_round_extension/ark/test/README_MOE_PREFILL_PERF_CN.md @@ -56,6 +56,9 @@ pytest -v -s test_moe_prefill_perf.py ```bash # 仅 FP16 测试 pytest -v -s test_moe_prefill_perf.py::TestMoEGemmPrefillPerf::test_perf_fp + +# 仅 INT4-sym 的 Qwen3-MoE 形状 (hidden=2048、inter=768、E=128、top_k=8) +pytest -v -s test_moe_prefill_perf.py -k test_perf_int4_sym_qwen3_moe --run-moe-prefill-perf ``` ## 代码结构 @@ -70,9 +73,12 @@ test_moe_prefill_perf.py │ └── 单个 `torch.bmm`,输入 [E, M_max, K] padding 后的激活 ├── 测试形状 (PREFILL_SHAPES) │ └── 多种真实 MoE 配置 +├── Qwen3-MoE 形状 (_QWEN3_NK / _QWEN3_BATCHES) +│ └── hidden=2048、inter=768、E=128、top_k=8、group_size=32 (issue 复现) └── 测试用例 (TestMoEGemmPrefillPerf) ├── test_perf_fp (FP16/BF16) ├── test_perf_int4 (INT4 sym/asym) + ├── test_perf_int4_sym_qwen3_moe (INT4 sym, Qwen3-MoE 形状) ├── test_perf_int8 (INT8 sym/asym) ├── test_perf_int2 (INT2 sym/asym) └── test_perf_fp8 (FP8 e4m3fn/e5m2) @@ -204,6 +210,46 @@ INT4 sym prefill 性能测试(`test_perf_int4`,`asym=False`)带一个 `dpas(ms)` 列启用 `ARK_MOE_PREFILL_DPAS_S4=1`(单遍 packed-nibble mainloop)。 +**Qwen3-MoE 形状(issue 复现)。** `test_perf_int4_sym_qwen3_moe` +测试另一组独立的形状,来自一个 fused-MoE 层的实测:小 batch 下 ARK +慢于 native 后端(`native_over_ark=0.604x`)。配置为 +`hidden_size=2048`、`intermediate_size=768`、`num_local_experts=128`、 +`num_experts_per_tok=8`、`group_size=32`,即 +`w13 [128, 1536, 1024]`(gemm1 `N=1536`、`K=2048`)与 +`w2 [128, 2048, 384]`(gemm2 `N=2048`、`K=768`);最后一维是 packed +nibble 数 `K/2`,scale 张量 `[128, 1536, 64]` / `[128, 2048, 24]` 确定 +`group_size` 为 32。与 `test_perf_int4`(MiniMax-M2 形状, +`group_size=128`)不同,它在同一份 packed 权重上填满**全部三列** +ARK 结果,便于把分发器的默认选择与实测对照: + +| 列 | 路径 | Env | +| ------------ | ------------------------------------------------------- | -------------------------- | +| `ark(ms)` | 传统 dequant 到 `[E, K, N]` + 标准 grouped GEMM | `DPAS_S4=0`、`DPAS_INT8=0` | +| `native(ms)` | 两遍 S4→S8 上转 + 共享 INT8 DPAS mainloop | `DPAS_S4=0`、`DPAS_INT8=1` | +| `dpas(ms)` | 单遍 S4 DPAS(直接读 packed nibble)—— 出厂默认路径 | `DPAS_S4=1` | + +路由行数为 `batch * top_k`,按 round-robin 分布到 128 个专家上,因此 +报告中的 2 个 token 的 batch 正好复现 issue 里的 +`rows_per_expert_sum=16`。默认只跑该 batch;`--all-shapes` 会把扫描 +扩展到 32/128/512/2048 个 token,从两侧覆盖「每专家 8 行」的 DPAS +tile 占用临界点(`batch >= 128`)。 + +```bash +pytest -v -s test_moe_prefill_perf.py -k test_perf_int4_sym_qwen3_moe \ + --run-moe-prefill-perf +``` + +对应的 decode 性能测试位于 `test_moe_decode_perf.py`: +`test_perf_int4_sym_qwen3_moe`(默认分发 vs. dequant + 逐专家 +`A @ W.T` 基线)与 `test_perf_int4_sym_qwen3_moe_dpas_vs_scalar` +(标量 GEMV vs. S4 DPAS,设置 `ARK_MOE_DECODE_DPAS_S4_MIN_TPE=0`, +用于单独观察占用率门控的路由决策 —— batch=2 时该形状仅有每专家 +0.125 个 token,远低于「每专家 8 个 token」的门控阈值)。 + +```bash +pytest -v -s test_moe_decode_perf.py -k qwen3_moe +``` + S4-sym 有两条独立的 DPAS 路径;asym S4 始终回退到 dequant 路径。 | 优先级 | Env 开关 | Kernel | From ef97754e7029f460bfa6dcee28930a8684bda12d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:36:26 +0000 Subject: [PATCH 050/112] feat: add W4A8 (int4 weight / int8 compute) ARK XPU MoE kernel Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/ark.cpp | 30 +- .../wrapper/include/sycl_tla_common.hpp | 58 ++ .../wrapper/include/sycl_tla_moe_w4a8.hpp | 856 ++++++++++++++++++ 3 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp diff --git a/auto_round_extension/ark/auto_round_kernel/ark.cpp b/auto_round_extension/ark/auto_round_kernel/ark.cpp index dc701e0dad..2860f9d14f 100755 --- a/auto_round_extension/ark/auto_round_kernel/ark.cpp +++ b/auto_round_extension/ark/auto_round_kernel/ark.cpp @@ -28,6 +28,7 @@ typedef uintptr_t torch_ptr; #include "sycl_tla_moe.hpp" #include "sycl_tla_moe_decode.hpp" #include "sycl_tla_moe_mixed.hpp" +#include "sycl_tla_moe_w4a8.hpp" #include "sycl_tla_sdpa.hpp" #include "sycl_tla_dense_gemm.hpp" #endif @@ -531,8 +532,29 @@ static void moe_gemm_prefill_int_dpas_wrapper(torch_ptr stream, torch_ptr activa (int*)num_tokens_per_expert, num_experts, total_tokens); } -static void sage_dynamic_quant(torch_ptr stream, torch_ptr input, torch_ptr bias, torch_ptr output, torch_ptr scale_out, - int num_rows, int head_dim, int block_size) { +// W4A8 MoE: int4 weights re-scaled to int8 (AUTO_S8), int8 DPAS compute, +// per-token dynamically quantized int8 activations. `weights_s8` is +// [E, N, K] int8 and `wscales` is [E, N, K/rescale_block] FP32, both produced +// by `moe_w4a8_prepack`. STATUS: NEEDS-HARDWARE-VALIDATION. +static void moe_w4a8_prepack_wrapper(torch_ptr stream, torch_ptr weights_s4, torch_ptr scales, + torch_ptr weights_s8, torch_ptr wscales, int act_dtype, int num_experts, + int N, int K, int group_size, int rescale_group_size) { + ark::moe_w4a8_prepack((sycl::queue*)stream, (void*)weights_s4, (void*)scales, (void*)weights_s8, + (void*)wscales, (BTLA_DTYPE)(act_dtype), num_experts, N, K, group_size, + rescale_group_size); +} + +static void moe_gemm_w4a8_wrapper(torch_ptr stream, torch_ptr activations, torch_ptr weights_s8, + torch_ptr wscales, torch_ptr outputs, int act_dtype, int N, int K, + int rescale_block_size, torch_ptr num_tokens_per_expert, int num_experts, + int total_tokens, int phase) { + ark::moe_gemm_w4a8((sycl::queue*)stream, (void*)activations, (void*)weights_s8, (void*)wscales, (void*)outputs, + (BTLA_DTYPE)(act_dtype), N, K, rescale_block_size, (int*)num_tokens_per_expert, num_experts, + total_tokens, phase); +} + +static void sage_dynamic_quant(torch_ptr stream, torch_ptr input, torch_ptr bias, torch_ptr output, + torch_ptr scale_out, int num_rows, int head_dim, int block_size) { auto* q = (sycl::queue*)stream; auto* in_ptr = (sycl::half*)input; auto* bias_ptr = bias ? (sycl::half*)bias : nullptr; @@ -804,6 +826,10 @@ PYBIND11_MODULE(PY_NAME, m) { m.def("moe_gemm_prefill", &ark::moe_gemm_prefill_wrapper); m.def("moe_gemm_prefill_fp8_dpas", &ark::moe_gemm_prefill_fp8_dpas_wrapper); m.def("moe_gemm_prefill_int_dpas", &ark::moe_gemm_prefill_int_dpas_wrapper); + m.def("moe_w4a8_prepack", &ark::moe_w4a8_prepack_wrapper); + m.def("moe_gemm_w4a8", &ark::moe_gemm_w4a8_wrapper); + m.def("moe_w4a8_rescale_block_size", &ark::moe_w4a8_rescale_block_size); + m.def("moe_w4a8_release_scratch", &ark::moe_w4a8_release_scratch); m.def("matmul_sycl_tla", &ark::matmul_sycl_tla); #endif // ARK_SYCL_TLA } diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp index ee417c114f..bd229b03a4 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp @@ -139,6 +139,64 @@ void moe_gemm_prefill_int_dpas(sycl::queue* q, void* activations, void* weights, BTLA_DTYPE act_dtype, BTLA_DTYPE weight_dtype, int N, int K, int* num_tokens_per_expert, int num_experts, int total_tokens); +/** + * @brief W4A8 MoE -- one-shot AUTO_S8 weight prepack. + * + * Converts auto-round's packed int4-sym MoE weights `[E, N, K/2]` (uint8, two + * nibbles per byte) plus per-group scales `[E, N, K/group_size]` (act dtype) + * into int8 weights `[E, N, K]` and FP32 block scales + * `[E, N, K/rescale_block]`, using ARK's `AUTO_S8` re-scale formula + * (`xpu_wrapper.hpp`): `sxt = max|s| * 8 / 127`, `w8 = round(w4 * s / sxt)`. + * + * `rescale_group_size <= 0` (the `group=-1` spelling) selects one scale per + * output channel, which lets the GEMM run a single full-K int32 accumulation. + * Use `moe_w4a8_rescale_block_size` to resolve the effective block size (and + * therefore the `wscales` shape) before allocating. + * + * STATUS: NEEDS-HARDWARE-VALIDATION. Implementation is header-only in + * `sycl_tla_moe_w4a8.hpp`. + */ +void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, + BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, + int rescale_group_size); + +/** + * @brief W4A8 MoE GEMM -- int4 weights, int8 compute, dynamically quantized + * int8 activations. Covers both the prefill (grouped GEMM, `s8 x s8 -> s32` + * DPAS) and decode (GEMV) phases. + * + * Activations `[total_tokens, K]` in `act_dtype` are quantized per token to + * int8 on entry; `weights_s8` / `wscales` come from `moe_w4a8_prepack`. + * + * - activations : [total_tokens, K] act dtype (sorted by expert) + * - weights_s8 : [num_experts, N, K] int8 + * - wscales : [num_experts, N, K/rescale_block] float + * - outputs : [total_tokens, N] act dtype + * + * @param rescale_block_size Effective AUTO_S8 block size (see + * `moe_w4a8_rescale_block_size`); must be a multiple of 64 dividing K. + * @param phase 0 = auto (decode when the batch is small), 1 = force decode, + * 2 = force prefill. + * + * STATUS: NEEDS-HARDWARE-VALIDATION. Implementation is header-only in + * `sycl_tla_moe_w4a8.hpp`. + */ +void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, + BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, int* num_tokens_per_expert, + int num_experts, int total_tokens, int phase); + +/** + * @brief Resolve the effective W4A8 AUTO_S8 re-scale block size for a given + * K / group_size, honouring `ARK_MOE_W4A8_AUTO_S8`. Returns K (one scale per + * output channel) for `rescale_group_size <= 0` or any unusable value. + */ +int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size); + +/** + * @brief Release the W4A8 activation-quantization / expert-map scratch slabs. + */ +void moe_w4a8_release_scratch(); + // ======================================================================== // Public API // ======================================================================== diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp new file mode 100644 index 0000000000..49b502b2cf --- /dev/null +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -0,0 +1,856 @@ +// SYCL MoE W4A8 -- INT4 weights / INT8 compute (prefill + decode) +// +// STATUS: NEEDS-HARDWARE-VALIDATION -- this header has not been compiled or +// run on an Intel GPU yet (the authoring environment has no XPU and no SYCL +// compiler). It follows the same porting conventions as its siblings +// `sycl_tla_moe_prefill_int_dpas.hpp` / `sycl_tla_moe_prefill_fp8_dpas.hpp`. +// --------------------------------------------------------------------------- +// +// What this file implements +// ------------------------- +// A W4A8 MoE path: **weights are stored as int4** (auto-round's packed +// `[E, N, K/2]` nibble layout with `[E, N, K/group_size]` per-group scales), +// **the DPAS compute dtype is int8**, and **activations are dynamically +// quantized to int8** (per-token absmax) on the fly. +// +// It covers both MoE phases: +// * prefill -- persistent grouped GEMM over experts, `XE_DPAS_TT<8, int32_t, +// int8_t, int8_t>` (`s8 x s8 -> s32`), modelled on the W4A8 weight-only +// GEMM in `sycl_tla_s8_gemm.hpp` (`sycl_tla_igemm_s8s8_dequant`) and the +// grouped scheduler in `sycl_tla_moe_prefill_int_dpas.hpp`. +// * decode -- int8 GEMV, one sub-group lane per output column, modelled on +// `moe_decode_detail::launch_int8`. +// +// The AUTO_S8 re-scale trick +// -------------------------- +// ARK's weight-only GEMM has an `AUTO_S8` option (`ARK_AUTO_S8` / +// `env_params::auto_s8`, see `xpu_wrapper.hpp`): rather than feeding the int8 +// mainloop a per-K-group scale (which forces a partial-accumulator fold at +// every group boundary), it *re-scales* the int4 weights into int8 with a +// coarser block size -- typically `group=-1`, i.e. one scale per output +// channel spanning the whole K axis. The int8 GEMM then runs a single +// full-K int32 accumulation with one scalar multiply in the epilogue, which +// is the most efficient shape for DPAS. +// +// The conversion is exactly the one `packscale` + `unpackq(S8, ...)` perform +// in `xpu_wrapper.hpp`: +// +// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * fullrange / 127 +// w8[e][n][k] = round(w4[e][n][k] * s[e][n][k/group_size] / sxt[e][n][j]) +// +// with `fullrange = 2^(bits-1) = 8` for int4. Because `|w4| <= 8` and +// `s <= sxt * 127 / 8` inside the block, `|w8| <= 127`: the re-scaled weight +// always fits in int8 without clipping, and the dequantized value +// `w8 * sxt` reproduces `w4 * s` up to the int8 rounding step. +// +// The block size is `rescale_group_size` (`-1` / `K` == per output channel == +// the `group=-1` maximum-efficiency case). It can be overridden per-process +// with `ARK_MOE_W4A8_AUTO_S8` (`-1` or a multiple of both `group_size` and 64 +// that divides K). Any invalid value falls back to per-channel. +// +// Because the conversion only depends on the checkpoint it is exposed as a +// separate one-shot entry point (`moe_w4a8_prepack`) so callers can run it at +// load time and keep the int8 weights + FP32 block scales resident, instead of +// paying for it on every forward. +// +// Numerics +// -------- +// out[t][n] = (Σ_j sxt[e][n][j] * Σ_{k in block j} qa[t][k] * w8[e][n][k]) +// * sa[t] +// with `qa = round(a / sa)`, `sa = max_k |a[t][k]| / 127`. The activation +// scale is per token (row), the weight scale is per (output channel, block), +// mirroring `sycl_tla_igemm_s8s8_dequant`'s `scale_a[row] * scale_b[col]` +// epilogue. +// +// Layout convention (identical to `moe_gemm_decode` / `moe_gemm_prefill`) +// ---------------------------------------------------------------------- +// activations : [total_tokens, K] act dtype (tokens pre-sorted by expert) +// weights_s4 : [E, N, K/2] uint8, two nibbles per byte (sym) +// scales : [E, N, K/group_size] act dtype +// weights_s8 : [E, N, K] int8 (prepack output) +// wscales : [E, N, K/rescale_block] float (prepack output) +// outputs : [total_tokens, N] act dtype +// +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include + +#ifdef ARK_XPU +#include +#endif + +#if defined(ARK_XPU) && defined(ARK_SYCL_TLA) + +// Pulls in the cutlass-sycl / CuTe include set, the `dpas_policy_base` policy +// root, `make_moe_tensor`, and `get_persistent_atomic_buffer` (via the FP8 +// header it includes). +#include "sycl_tla_moe_prefill_int_dpas.hpp" +// `DeviceScratchPool`, `env_flag_enabled`, `fill_expert_id_per_token`, +// `SG_SIZE` / `N_TILE`, and the shared nibble decoders. +#include "sycl_tla_moe_decode.hpp" + +namespace ark { +namespace moe_w4a8 { + +using namespace cute; + +using moe_decode_detail::DeviceScratchPool; +using moe_decode_detail::N_TILE; +using moe_decode_detail::SG_SIZE; +using moe_dequant::decode_int4_octet; + +// Symmetric int4 full range: 2^(bits-1). Matches `fullrange` in +// `xpu_wrapper.hpp`'s `packscale` rescale kernel. +constexpr float kInt4FullRange = 8.0f; +constexpr float kInt8Max = 127.0f; + +// K elements decoded per work-item in the prepack kernel (one 32-bit word of +// packed nibbles). Requires `K % 8 == 0`, which the shape gate enforces. +constexpr int kPrepackOctet = 8; + +// --------------------------------------------------------------------------- +// Kernel name tags (one per specialization, required for SYCL kernel naming) +// --------------------------------------------------------------------------- +template +class MoEW4A8ActQuant; + +template +class MoEW4A8ScaleReduce; + +template +class MoEW4A8Repack; + +template +class MoEW4A8DecodeGemv; + +template +class MoEW4A8GemmName; + +// --------------------------------------------------------------------------- +// Scratch pools. +// +// The activation-quantization buffers (`[total_tokens, K]` int8 + +// `[total_tokens]` fp32) and the decode expert map (`[total_tokens]` int32) +// are recomputed on every call, so they come from persistent per-queue slabs +// instead of a hot-path `malloc_device`. Same lifetime contract as +// `moe_decode_detail::int4_repack_pool` -- released explicitly through +// `moe_w4a8_release_scratch`, never from a static destructor. +// --------------------------------------------------------------------------- +inline DeviceScratchPool& qact_pool() { + static DeviceScratchPool pool; + return pool; +} + +inline DeviceScratchPool& expert_map_pool() { + static DeviceScratchPool pool; + return pool; +} + +// --------------------------------------------------------------------------- +// Per-token dynamic activation quantization: act dtype -> int8 + fp32 scale. +// +// One sub-group per token: lanes stride the K axis (coalesced), reduce the +// absmax with `reduce_over_group`, then write back the quantized row. A row +// that is entirely zero gets `scale = 0` and quantizes to all zeros (the +// reciprocal is forced to 0 instead of inf). +// --------------------------------------------------------------------------- +template +void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + if (total_tokens == 0) return; + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ScalarT* row = activations + static_cast(token) * K; + int8_t* out = qact + static_cast(token) * K; + + float local_max = 0.0f; + for (int k = lane; k < K; k += SG_SIZE) { + local_max = sycl::fmax(local_max, sycl::fabs(static_cast(row[k]))); + } + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) ascale[token] = scale; + + for (int k = lane; k < K; k += SG_SIZE) { + const float v = sycl::rint(static_cast(row[k]) * inv); + out[k] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); + } + }); +} + +// --------------------------------------------------------------------------- +// AUTO_S8 stage 1: per-(expert, output channel, block) re-scale factor. +// +// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * (8 / 127) +// +// Verbatim port of the rescale branch of `packscale` in `xpu_wrapper.hpp` +// (with `fullrange = 8` for int4). An all-zero block yields `sxt = 0`; stage 2 +// turns that into all-zero int8 weights, so the (equally zero) product is +// still exact. +// --------------------------------------------------------------------------- +template +void launch_weight_scale_reduce(sycl::queue* q, const ScalarT* scales, float* wscale_out, int E, int N, int K, + int group_size, int rescale_block, int nblk) { + const int groups_k = K / group_size; + const int groups_per_block = rescale_block / group_size; + + q->parallel_for>( + sycl::range<1>(static_cast(E) * static_cast(N) * static_cast(nblk)), + [=](sycl::id<1> id) { + const size_t idx = id[0]; + const int blk = static_cast(idx % static_cast(nblk)); + const size_t row = idx / static_cast(nblk); // e * N + n + const ScalarT* s_row = + scales + row * static_cast(groups_k) + static_cast(blk) * groups_per_block; + + float absmax = 0.0f; + for (int g = 0; g < groups_per_block; ++g) { + absmax = sycl::fmax(absmax, sycl::fabs(static_cast(s_row[g]))); + } + wscale_out[idx] = absmax * (kInt4FullRange / kInt8Max); + }); +} + +// --------------------------------------------------------------------------- +// AUTO_S8 stage 2: int4 -> int8 re-scale. +// +// w8[k] = round(w4[k] * s[k / group_size] / sxt[k / rescale_block]) +// +// Verbatim port of the `CfgDequantS8Rescale` branch of `unpackq` in +// `xpu_wrapper.hpp`. One work-item decodes one 32-bit word (8 nibbles); the +// shape gate guarantees `group_size % 8 == 0` and `rescale_block % 8 == 0`, so +// all 8 K indices of a word share the same group scale and the same block +// scale and both loads hoist out of the inner loop. +// --------------------------------------------------------------------------- +template +void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, const ScalarT* scales, + const float* wscale, int8_t* w8_out, int E, int N, int K, int group_size, + int rescale_block, int nblk) { + const int groups_k = K / group_size; + const int octets = K / kPrepackOctet; + + q->parallel_for>( + sycl::range<2>(static_cast(E) * static_cast(N), static_cast(octets)), + [=](sycl::id<2> id) { + const size_t row = id[0]; // e * N + n + const int oct = static_cast(id[1]); + const int k_base = oct * kPrepackOctet; + + const uint8_t* w_ptr = weights + row * static_cast(K / 2) + static_cast(oct) * 4; + const uint32_t word = *reinterpret_cast(w_ptr); + int q4[kPrepackOctet]; + decode_int4_octet(word, q4); + + const float s = static_cast(scales[row * static_cast(groups_k) + k_base / group_size]); + const float sx = wscale[row * static_cast(nblk) + k_base / rescale_block]; + const float f = sx > 0.0f ? s / sx : 0.0f; + + int8_t* out = w8_out + row * static_cast(K) + k_base; +#pragma unroll + for (int j = 0; j < kPrepackOctet; ++j) { + const float v = sycl::rint(static_cast(q4[j]) * f); + out[j] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); + } + }); +} + +// --------------------------------------------------------------------------- +// Tile policies. +// +// `WGTile`'s K extent is 64 -- the int8 DPAS atom's K granularity, matching +// `sycl_tla_s8_gemm.hpp`'s `Shape, Int, _64>`. The M/N +// shapes and sub-group layouts are the reference GEMM's tile ladder +// (`SmallTileSG` / `SmallMidTileSG` / `MediumTileSG` / `LargeTileSG`), which +// keeps `size(mma)` at 64 / 128 / 256 / 512 threads -- all divisors of the 512 +// threads-per-SM budget the persistent scheduler assumes. +// --------------------------------------------------------------------------- +class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_8, _128, _64>; + using SGLayout = Layout, Stride<_0, _1, _0>>; +}; + +class w4a8_policy_m_64 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_64, _128, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +class w4a8_policy_m_128 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_128, _128, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_256, _128, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +// --------------------------------------------------------------------------- +// Single-tile int8 x int8 -> int32 mainloop with a per-block weight scale and +// a per-row activation scale. +// +// Structure is `igemm_kblock_device_impl` from `sycl_tla_s8_gemm.hpp` (the +// W4A8 weight-only GEMM), with two changes for the grouped/MoE case: +// * the tile coordinate is passed in by the persistent scheduler instead of +// being derived from the work-item's group id, and +// * A/B/D base pointers are the per-expert slices. +// +// `blks == 1` (the AUTO_S8 `group=-1` default) collapses the outer loop to a +// single full-K int32 accumulation -- the maximum-efficiency shape. +// +// The epilogue writes through the raw `[m, n]` row-major output pointer using +// the coordinates of `thr_mma.partition_C(...)`, exactly like the reference, +// because the int32 accumulator has to be converted and scaled per element +// anyway. Bounds are always checked: a grouped GEMM's per-expert M is +// arbitrary, so tiles at the M edge are partial. +// --------------------------------------------------------------------------- +template +CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, const float* scale_a, + const float* scale_b, int m, int n, int k, int blocksize, int blks, int m_coord, + int n_coord, TiledMMA const& mma) { + auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); + const int local_id = static_cast(item.get_local_linear_id()); + + auto wg_tile = mma.tile_mnk(); + auto wg_coord = make_coord(m_coord, n_coord, 0); + + auto A = make_tensor(make_gmem_ptr(const_cast(a)), make_shape(m, k), make_stride(k, _1{})); + auto B = make_tensor(make_gmem_ptr(const_cast(b)), make_shape(n, k), make_stride(k, _1{})); + + Tensor cA = make_identity_tensor(A.shape()); + Tensor cB = make_identity_tensor(B.shape()); + Tensor cC = make_identity_tensor(make_shape(m, n)); + + Tensor gA = local_tile(cA, select<0, 2>(wg_tile), make_coord(m_coord, _)); + Tensor gB = local_tile(cB, select<1, 2>(wg_tile), make_coord(n_coord, _)); + Tensor gC = local_tile(cC, wg_tile, wg_coord, Step<_1, _1, X>{}); + + auto copy_a = get_block_2d_copy_A(mma, A); + auto copy_b = get_block_2d_copy_B(mma, B); + + auto thr_mma = mma.get_slice(local_id); + auto thr_copy_a = copy_a.get_slice(local_id); + auto thr_copy_b = copy_b.get_slice(local_id); + + auto tCrA = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); + auto tCrB = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); + + auto tArA = thr_copy_a.partition_sg_fragment_D(gA(_, _, 0)); + auto tBrB = thr_copy_b.partition_sg_fragment_D(gB(_, _, 0)); + + Tensor tAgA = thr_copy_a.partition_S(gA); + Tensor tBgB = thr_copy_b.partition_S(gB); + + Tensor tCrC = partition_fragment_C(mma, select<0, 1>(wg_tile)); + Tensor tFrC = make_tensor_like(tCrC); + Tensor tCgC = thr_mma.partition_C(gC); + + auto prefetch_a = make_block_2d_prefetch(copy_a); + auto prefetch_b = make_block_2d_prefetch(copy_b); + + auto pAgA = prefetch_a.get_slice(local_id).partition_S(gA); + auto pBgB = prefetch_b.get_slice(local_id).partition_S(gB); + + constexpr auto barrier_scope = ScopeWorkgroup; + constexpr int prefetch_dist = 3; + + const int k_tile_size = static_cast(get<2>(wg_tile)); + const int k_tiles_per_block = blocksize / k_tile_size; + const int k_tile_count = blks * k_tiles_per_block; + int k_tile_prefetch = 0; + + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + tFrC(i) = 0.0f; + } + + CUTE_UNROLL + for (; k_tile_prefetch < prefetch_dist && k_tile_prefetch < k_tile_count; ++k_tile_prefetch) { + prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); + prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); + } + + for (int ib = 0; ib < blks; ++ib) { + clear(tCrC); + + for (int bk = 0; bk < k_tiles_per_block; ++bk) { + const int k_tile = ib * k_tiles_per_block + bk; + + barrier_arrive(barrier_scope); + + copy(copy_a, tAgA(_, _, _, k_tile), tArA); + copy(copy_b, tBgB(_, _, _, k_tile), tBrB); + + if (k_tile_prefetch < k_tile_count) { + prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); + prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); + } + ++k_tile_prefetch; + + reorder(tArA, tCrA); + reorder(tBrB, tCrB); + cute::gemm(mma, tCrA, tCrB, tCrC); + + barrier_wait(barrier_scope); + } + + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + if (row >= m || col >= n) continue; + tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col) * blks + ib]; + } + } + + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + if (row >= m || col >= n) continue; + c[static_cast(row) * n + col] = static_cast(tFrC(i) * scale_a[row]); + } +} + +// --------------------------------------------------------------------------- +// Persistent atomic scheduler over `rows_per_expert`. +// +// Structurally identical to `moe_dpas_int::MoEGEMM_int` (which is itself the +// vllm-xpu-kernels grouped-GEMM scheduler); only the per-expert pointer +// arithmetic and the mainloop call differ: +// * A / D advance by the expert's token offset (`pre_rows`), and so does the +// per-token activation scale. +// * B advances by `expert * N * K` int8 elements, the block scales by +// `expert * N * blks` floats. +// --------------------------------------------------------------------------- +template +CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const float* ScaleA, + const float* ScaleB, ElementD* Outputs, TiledMMA const& mma, + const int* rows_per_expert, const int32_t num_experts, const int32_t gemm_n, + const int32_t gemm_k, const int32_t blocksize, const int32_t blks, + int32_t* atomic_buffer, const sycl::local_accessor& slm_mem_const) { + auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); + auto wg_tile = mma.tile_mnk(); + auto wg_tile_m = get<0>(wg_tile); + auto wg_tile_n = get<1>(wg_tile); + + int group_id = item.get_group_linear_id(); + int gemm_n_pad = (gemm_n + wg_tile_n - 1) / wg_tile_n * wg_tile_n; + int group_m_id = (group_id * wg_tile_n) / gemm_n_pad; + int group_range = item.get_group_range(1); + int local_id = item.get_local_linear_id(); + + if (group_id == 0 && local_id == 0) { + auto atm = sycl::atomic_ref(atomic_buffer[0]); + atm.store(0); + } + + int pre_rows = 0; + int pre_tiles = 0; + + int32_t* slm_mem = + static_cast(slm_mem_const.template get_multi_ptr().get()); + + for (int i = 0; i < num_experts; ++i) { + int gemm_m = rows_per_expert[i]; + int cumsum_rows_for_experts = pre_rows + gemm_m; + int cumsum_tiles_for_experts = (gemm_m + wg_tile_m - 1) / wg_tile_m + pre_tiles; + + if (group_m_id >= cumsum_tiles_for_experts) { + pre_rows = cumsum_rows_for_experts; + pre_tiles = cumsum_tiles_for_experts; + continue; + } + + const int expert_id = i; + const int64_t B_offset = + static_cast(expert_id) * static_cast(gemm_n) * static_cast(gemm_k); + const int64_t SB_offset = + static_cast(expert_id) * static_cast(gemm_n) * static_cast(blks); + + const int8_t* ptr_A_curr_batch = Activations + static_cast(pre_rows) * gemm_k; + const int8_t* ptr_B_curr_batch = Weights + B_offset; + const float* ptr_SA_curr_batch = ScaleA + pre_rows; + const float* ptr_SB_curr_batch = ScaleB + SB_offset; + ElementD* ptr_D_curr_batch = Outputs + static_cast(pre_rows) * gemm_n; + + while (group_m_id < cumsum_tiles_for_experts) { + const int n_coord = (group_id * wg_tile_n) % gemm_n_pad / wg_tile_n; + const int m_coord = (group_m_id - pre_tiles); + + xe_gemm_w4a8(ptr_A_curr_batch, ptr_B_curr_batch, ptr_D_curr_batch, + ptr_SA_curr_batch, ptr_SB_curr_batch, gemm_m, gemm_n, gemm_k, + blocksize, blks, m_coord, n_coord, mma); + + if (local_id == 0) { + slm_mem[0] = cutlass::atomicAdd(atomic_buffer, 1); + } + item.barrier(sycl::access::fence_space::local_space); + group_id = group_range + slm_mem[0]; + group_m_id = (group_id * wg_tile_n) / gemm_n_pad; + } + pre_rows = cumsum_rows_for_experts; + pre_tiles = cumsum_tiles_for_experts; + } +} + +// --------------------------------------------------------------------------- +// Grouped-GEMM launcher (fork of `moe_dpas_int::MoEGEMMLauncher_int`, with the +// int8 DPAS atom of `sycl_tla_s8_gemm.hpp`). +// --------------------------------------------------------------------------- +template +void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const int8_t* weights, + const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, + const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, + const int blks, int32_t* atomic_buffer) { + using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; + using WGTile = typename Policy::WGTile; + using SGLayout = typename Policy::SGLayout; + using MMA = typename TiledMMAHelper, Layout, SGLayout>::TiledMMA; + auto mma = MMA{}; + + int sm_count = cutlass::KernelHardwareInfo::query_device_multiprocessor_count(0); + auto MaxThreadsPerWorkgroup = size(mma); + + static constexpr int MaxThreadsPerSM = 512; + if (MaxThreadsPerSM % MaxThreadsPerWorkgroup != 0) { + throw std::runtime_error("moe_gemm_w4a8: MaxThreadsPerSM must be divisible by MaxThreadsPerWorkgroup"); + } + + sycl::range<3> local(1, 1, MaxThreadsPerWorkgroup); + sycl::range<3> global(1, sm_count * MaxThreadsPerSM / MaxThreadsPerWorkgroup, 1); + + namespace syclex = sycl::ext::oneapi::experimental; + namespace intelex = sycl::ext::intel::experimental; + + syclex::properties kernel_props{syclex::sub_group_size<16>, intelex::grf_size<256>}; + + using GmemTiledCopyA = typename Policy::GmemTiledCopyA; + using GmemTiledCopyB = typename Policy::GmemTiledCopyB; + + auto event = stream.submit([&](sycl::handler& cgh) { + sycl::local_accessor local_mem(sycl::range<1>(1), cgh); + cgh.parallel_for>( + sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { + MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, + rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, + blks, atomic_buffer, local_mem); + }); + }); + + EventManager::getInstance().addEvent(event); + event.wait(); +} + +// --------------------------------------------------------------------------- +// Prefill driver: policy selection on the average per-expert M, matching the +// tile ladder of `launch_igemm_kblock` in `sycl_tla_s8_gemm.hpp`. +// --------------------------------------------------------------------------- +template +void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* num_tokens_per_expert, int E, + int N, int K, int blocksize, int blks, int total_tokens) { + if (E == 0 || N == 0 || K == 0 || total_tokens == 0) return; + + compat::set_default_queue(*q); + + const int A_avg_M = total_tokens / E; + int32_t* atomic_buffer = moe_dpas_fp8::get_persistent_atomic_buffer(q); + +#define ARK_MOE_W4A8_LAUNCH(policy) \ + MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ + num_tokens_per_expert, E, blocksize, blks, atomic_buffer); + + if (A_avg_M < 16) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) + } else if (A_avg_M < 128) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) + } else if (A_avg_M <= 1024) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) + } else { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) + } +#undef ARK_MOE_W4A8_LAUNCH +} + +// --------------------------------------------------------------------------- +// Decode GEMV: int8 x int8 -> int32, one output column per sub-group lane. +// +// Same work decomposition as `moe_decode_detail::launch_int8` (work-group = +// one sub-group covering 16 consecutive N columns of one token), with the +// per-K-group float dequantization replaced by a per-block int32 dot product. +// Two accumulators hide the multiply-add latency; int32 cannot overflow here +// (|a|,|w| <= 127 gives < 2^14 per product, so K would have to exceed 130k). +// --------------------------------------------------------------------------- +template +void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, int total_tokens, + int N, int K, int blocksize, int blks) { + if (N % N_TILE != 0) { + throw std::invalid_argument("moe_gemm_w4a8(decode): N must be a multiple of 16"); + } + if (total_tokens == 0) return; + + const int n_tiles = N / N_TILE; + sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int n_tile = static_cast(it.get_group(1)); + const int lane = static_cast(it.get_local_id(1)); + const int n_global = n_tile * N_TILE + lane; + + const int expert = expert_id_per_token[token]; + const int8_t* act_row = qact + static_cast(token) * K; + const int8_t* w_row = weights + (static_cast(expert) * N + static_cast(n_global)) * K; + const float* s_row = + wscale + (static_cast(expert) * N + static_cast(n_global)) * blks; + + constexpr int SUB = 16; + using QVec = sycl::vec; + + float accf = 0.0f; + for (int ib = 0; ib < blks; ++ib) { + const int k_base = ib * blocksize; + int acc0 = 0; + int acc1 = 0; + int kk = 0; + const int end = (blocksize / SUB) * SUB; + for (; kk < end; kk += SUB) { + const QVec av = *reinterpret_cast(act_row + k_base + kk); + const QVec wv = *reinterpret_cast(w_row + k_base + kk); +#pragma unroll + for (int u = 0; u < SUB; u += 2) { + acc0 += static_cast(av[u]) * static_cast(wv[u]); + acc1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); + } + } + for (; kk < blocksize; ++kk) { + acc0 += static_cast(act_row[k_base + kk]) * static_cast(w_row[k_base + kk]); + } + accf += static_cast(acc0 + acc1) * s_row[ib]; + } + + outputs[static_cast(token) * N + n_global] = static_cast(accf * ascale[token]); + }); +} + +// --------------------------------------------------------------------------- +// Host-side helpers +// --------------------------------------------------------------------------- + +// Resolve the effective AUTO_S8 re-scale block size. +// +// `requested <= 0` (the `group=-1` spelling) or any value that is not a valid +// block size falls back to `K`, i.e. one scale per output channel -- the +// maximum-efficiency shape. `ARK_MOE_W4A8_AUTO_S8` overrides the argument so +// benchmarks can sweep the block size without touching the caller. +inline int moe_w4a8_rescale_block_size(int K, int group_size, int requested) { + int v = requested; + const char* env = std::getenv("ARK_MOE_W4A8_AUTO_S8"); + if (env != nullptr) { + char* end = nullptr; + const long parsed = std::strtol(env, &end, 10); + if (end != env) v = static_cast(parsed); + } + if (K <= 0) return K; + if (v <= 0 || v >= K) return K; + if (group_size > 0 && (v < group_size || v % group_size != 0)) return K; + if (K % v != 0) return K; + // The mainloop slices each block into 64-wide DPAS K tiles. + if (v % 64 != 0) return K; + return v; +} + +// Shape preconditions shared by the prepack, prefill and decode paths. +inline bool moe_w4a8_shape_ok(int N, int K, int group_size) { + if (N <= 0 || K <= 0 || group_size <= 0) return false; + if (N % N_TILE != 0) return false; + if (K % 64 != 0) return false; + if (group_size % kPrepackOctet != 0) return false; + if (K % group_size != 0) return false; + return true; +} + +// Token count at or below which the auto phase selection picks the decode +// GEMV. Mirrors `ARK_MOE_AUTO_DECODE_MAX_TOKENS` used by the Python `moe()` +// dispatcher; overridable with `ARK_MOE_W4A8_DECODE_MAX_TOKENS`. +inline int moe_w4a8_decode_max_tokens() { + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_MAX_TOKENS"); + if (env == nullptr) return 128; + char* end = nullptr; + const long parsed = std::strtol(env, &end, 10); + if (end == env || parsed < 0) return 128; + return static_cast(parsed); +} + +inline void moe_w4a8_release_scratch() { + qact_pool().release_all(); + expert_map_pool().release_all(); +} + +} // namespace moe_w4a8 + +// --------------------------------------------------------------------------- +// Public entry point 1 -- one-shot AUTO_S8 prepack. +// +// Converts auto-round's packed int4-sym weights + per-group scales into the +// int8 weights + FP32 block scales the W4A8 kernels consume. Callers are +// expected to run this once per checkpoint and keep the results resident. +// --------------------------------------------------------------------------- +inline void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, + BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, + int rescale_group_size) { + if (num_experts <= 0) return; + if (!moe_w4a8::moe_w4a8_shape_ok(N, K, group_size)) { + throw std::invalid_argument( + "moe_w4a8_prepack: unsupported shape (need N % 16 == 0, K % 64 == 0, " + "group_size % 8 == 0 and K % group_size == 0)"); + } + if (weights_s4 == nullptr || scales == nullptr || weights_s8 == nullptr || wscales == nullptr) { + throw std::invalid_argument("moe_w4a8_prepack: null buffer"); + } + + const int blocksize = moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); + const int blks = K / blocksize; + + if (act_dtype == BTLA_DTYPE::F16) { + using ScalarT = sycl::half; + moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), + static_cast(wscales), num_experts, N, K, group_size, + blocksize, blks); + moe_w4a8::launch_weight_rescale_s4_to_s8( + q, static_cast(weights_s4), static_cast(scales), + static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, + blocksize, blks); + } else if (act_dtype == BTLA_DTYPE::BF16) { + using ScalarT = sycl::ext::oneapi::bfloat16; + moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), + static_cast(wscales), num_experts, N, K, group_size, + blocksize, blks); + moe_w4a8::launch_weight_rescale_s4_to_s8( + q, static_cast(weights_s4), static_cast(scales), + static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, + blocksize, blks); + } else { + throw std::invalid_argument("moe_w4a8_prepack: act_dtype must be F16 or BF16"); + } +} + +// --------------------------------------------------------------------------- +// Public entry point 2 -- W4A8 MoE GEMM (prefill + decode). +// +// `phase`: 0 = auto (decode when `total_tokens <= +// ARK_MOE_W4A8_DECODE_MAX_TOKENS`), 1 = force decode GEMV, 2 = force prefill +// grouped GEMM. +// --------------------------------------------------------------------------- +inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, + BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, + int* num_tokens_per_expert, int num_experts, int total_tokens, int phase) { + if (total_tokens == 0 || num_experts <= 0) return; + if (N % moe_w4a8::N_TILE != 0) { + throw std::invalid_argument("moe_gemm_w4a8: N must be a multiple of 16"); + } + if (K % 64 != 0) { + throw std::invalid_argument("moe_gemm_w4a8: K must be a multiple of 64"); + } + if (rescale_block_size <= 0 || rescale_block_size > K || K % rescale_block_size != 0 || + rescale_block_size % 64 != 0) { + throw std::invalid_argument( + "moe_gemm_w4a8: rescale_block_size must be a multiple of 64 that divides K " + "(use moe_w4a8_rescale_block_size to resolve it)"); + } + if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { + throw std::invalid_argument("moe_gemm_w4a8: act_dtype must be F16 or BF16"); + } + + const int blocksize = rescale_block_size; + const int blks = K / blocksize; + + // Quantized activations + per-token scales share one slab: `[T, K]` int8 + // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because + // K is a multiple of 64). + const size_t qact_bytes = static_cast(total_tokens) * static_cast(K); + const size_t scale_offset = (qact_bytes + sizeof(float) - 1) / sizeof(float) * sizeof(float); + const size_t slab_bytes = scale_offset + static_cast(total_tokens) * sizeof(float); + uint8_t* slab = moe_w4a8::qact_pool().acquire(q, slab_bytes); + int8_t* qact = reinterpret_cast(slab); + float* ascale = reinterpret_cast(slab + scale_offset); + + if (act_dtype == BTLA_DTYPE::F16) { + moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact, ascale, + total_tokens, K); + } else { + using BF = sycl::ext::oneapi::bfloat16; + moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact, ascale, total_tokens, K); + } + + const bool use_decode = + phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); + + const auto* weights = static_cast(weights_s8); + const auto* wscale = static_cast(wscales); + + if (use_decode) { + int* expert_map = reinterpret_cast( + moe_w4a8::expert_map_pool().acquire(q, static_cast(total_tokens) * sizeof(int))); + moe_decode_detail::fill_expert_id_per_token(q, expert_map, num_tokens_per_expert, num_experts, total_tokens); + if (act_dtype == BTLA_DTYPE::F16) { + moe_w4a8::launch_w4a8_decode(q, qact, ascale, weights, wscale, + static_cast(outputs), expert_map, total_tokens, N, K, + blocksize, blks); + } else { + using BF = sycl::ext::oneapi::bfloat16; + moe_w4a8::launch_w4a8_decode(q, qact, ascale, weights, wscale, static_cast(outputs), expert_map, + total_tokens, N, K, blocksize, blks); + } + return; + } + + if (act_dtype == BTLA_DTYPE::F16) { + moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, + static_cast(outputs), num_tokens_per_expert, + num_experts, N, K, blocksize, blks, total_tokens); + } else { + using BF = sycl::ext::oneapi::bfloat16; + moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), + num_tokens_per_expert, num_experts, N, K, blocksize, blks, + total_tokens); + } +} + +// Resolve the effective AUTO_S8 block size (host helper, also exported to +// Python so callers can size the `wscales` tensor consistently). +inline int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size) { + return moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); +} + +// Free the W4A8 activation-quantization / expert-map scratch slabs. +inline void moe_w4a8_release_scratch() { moe_w4a8::moe_w4a8_release_scratch(); } + +} // namespace ark + +#endif // ARK_XPU && ARK_SYCL_TLA From f789819732283fcc283e42e58c43b0cd9a0e3210 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:42:47 +0000 Subject: [PATCH 051/112] feat: add W4A8 MoE Python API and perf/accuracy benchmark script Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 334 ++++++++ .../ark/test/test_moe_w4a8_perf.py | 786 ++++++++++++++++++ 2 files changed, 1120 insertions(+) create mode 100644 auto_round_extension/ark/test/test_moe_w4a8_perf.py diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index f9f147583a..61be23bf67 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -2353,6 +2353,340 @@ def clear_moe_prefill_workspace_cache() -> None: _MOE_PREFILL_WORKSPACE_CACHE.clear() +# --------------------------------------------------------------------------- +# W4A8 MoE: int4 weights, int8 DPAS compute, dynamically quantized int8 +# activations. +# +# The int4 weights are converted ONCE into int8 using ARK's `AUTO_S8` re-scale +# rule (see `sycl_tla_moe_w4a8.hpp`): +# +# sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * 8 / 127 +# w8[e][n][k] = round(w4[e][n][k] * s[e][n][k // group_size] / sxt[...]) +# +# With the default block (`rescale_group_size=-1`, one scale per output +# channel) the GEMM runs a single full-K `s8 x s8 -> s32` accumulation with one +# scalar multiply in the epilogue -- the shape the DPAS pipeline is happiest +# with. Coarser-than-group blocks are what makes this profitable: an int4 +# group=32 checkpoint would otherwise force an accumulator fold every 32 K +# elements. +# +# `moe_w4a8` keeps the conversion result in a module-level cache keyed on the +# weight/scale tensor identity so repeated forward passes over the same expert +# weights pay for it only once. Callers that manage their own storage should +# use `moe_w4a8_prepack` + `moe_gemm_w4a8` directly. +# --------------------------------------------------------------------------- + +_MOE_W4A8_PREPACK_CACHE: "dict[tuple, tuple[torch.Tensor, torch.Tensor, int]]" = {} + + +def moe_w4a8_rescale_block_size(K: int, group_size: int, rescale_group_size: int = -1) -> int: + """Return the effective AUTO_S8 re-scale block size used by the W4A8 path. + + ``rescale_group_size <= 0`` (the ``group=-1`` spelling) selects one scale + per output channel, i.e. a block spanning the whole K axis. Any value that + is not a multiple of both ``group_size`` and 64, or that does not divide + ``K``, also falls back to ``K``. ``ARK_MOE_W4A8_AUTO_S8`` overrides the + argument. + + The result is the divisor of the ``wscales`` last dimension + (``K // block``), so callers must use it to size that tensor. + """ + lib = xpu_lib + if lib is not None and hasattr(lib, "moe_w4a8_rescale_block_size"): + return int(lib.moe_w4a8_rescale_block_size(int(K), int(group_size), int(rescale_group_size))) + # Pure-Python mirror of `ark::moe_w4a8::moe_w4a8_rescale_block_size` so the + # shape math is available without a loaded extension (tests, docs, CPU). + env = os.environ.get("ARK_MOE_W4A8_AUTO_S8") + value = int(rescale_group_size) + if env is not None: + try: + value = int(env.strip()) + except ValueError: + pass + if K <= 0: + return K + if value <= 0 or value >= K: + return K + if group_size > 0 and (value < group_size or value % group_size != 0): + return K + if K % value != 0 or value % 64 != 0: + return K + return value + + +def moe_w4a8_release_scratch() -> None: + """Release the device scratch slabs held by the W4A8 MoE path. + + :func:`moe_gemm_w4a8` serves its quantized-activation and expert-map + buffers from grow-on-demand per-queue slabs kept for the lifetime of the + process. A no-op when the XPU extension is not loaded. + """ + lib = xpu_lib + if lib is None or not hasattr(lib, "moe_w4a8_release_scratch"): + return + lib.moe_w4a8_release_scratch() + + +def clear_moe_w4a8_prepack_cache() -> None: + """Release every int8 weight/scale pair cached by :func:`moe_w4a8`.""" + _MOE_W4A8_PREPACK_CACHE.clear() + + +def _validate_moe_w4a8_shape(N: int, K: int, group_size: int, api_name: str) -> None: + if N % 16 != 0: + raise ValueError(f"{api_name}: N must be a multiple of 16 (got {N})") + if K % 64 != 0: + raise ValueError(f"{api_name}: K must be a multiple of 64 (got {K})") + if group_size <= 0 or group_size % 8 != 0: + raise ValueError(f"{api_name}: group_size must be a positive multiple of 8 (got {group_size})") + if K % group_size != 0: + raise ValueError(f"{api_name}: K must be a multiple of group_size") + + +def moe_w4a8_prepack( + weights: torch.Tensor, + scales: torch.Tensor, + *, + group_size: int = 128, + rescale_group_size: int = -1, +) -> "tuple[torch.Tensor, torch.Tensor, int]": + """Convert packed int4-sym MoE weights into the W4A8 int8 representation. + + Applies ARK's ``AUTO_S8`` re-scale on device, producing weights the int8 + DPAS grouped GEMM / GEMV can consume without any per-K-group fold. + + Args: + weights: ``[E, N, K // 2]`` ``torch.uint8``, two signed nibbles per + byte (auto-round's int4-sym packing: K index ``2i`` in the low + nibble, ``2i + 1`` in the high nibble). + scales: ``[E, N, K // group_size]`` in fp16 or bf16. + group_size: quantization group along K of the int4 weights. + rescale_group_size: AUTO_S8 block size; ``-1`` (default) means one + scale per output channel -- the fastest configuration. + + Returns: + ``(weights_s8, wscales, rescale_block_size)`` where ``weights_s8`` is + ``[E, N, K]`` ``torch.int8``, ``wscales`` is + ``[E, N, K // rescale_block_size]`` ``torch.float32``, and + ``rescale_block_size`` is the resolved block size (pass it to + :func:`moe_gemm_w4a8`). + """ + if weights.device.type != "xpu": + raise NotImplementedError("moe_w4a8_prepack is only supported on XPU") + if weights.dtype != torch.uint8: + raise ValueError(f"moe_w4a8_prepack: weights must be torch.uint8, got {weights.dtype}") + if weights.ndim != 3: + raise ValueError("moe_w4a8_prepack: weights must be 3D [E, N, K // 2]") + if scales.ndim != 3: + raise ValueError("moe_w4a8_prepack: scales must be 3D [E, N, K // group_size]") + if scales.dtype not in (torch.float16, torch.bfloat16): + raise ValueError(f"moe_w4a8_prepack: scales must be fp16/bf16, got {scales.dtype}") + if scales.device != weights.device: + raise ValueError("moe_w4a8_prepack: weights and scales must be on the same device") + + E, N, k_packed = weights.shape + K = k_packed * 2 + _validate_moe_w4a8_shape(N, K, group_size, "moe_w4a8_prepack") + expected_scale_shape = (E, N, K // group_size) + if tuple(scales.shape) != expected_scale_shape: + raise ValueError(f"moe_w4a8_prepack: scales shape {tuple(scales.shape)} != expected {expected_scale_shape}") + + weights = weights.contiguous() + scales = scales.contiguous() + + block = moe_w4a8_rescale_block_size(K, group_size, rescale_group_size) + nblk = K // block + + weights_s8 = torch.empty((E, N, K), device=weights.device, dtype=torch.int8) + wscales = torch.empty((E, N, nblk), device=weights.device, dtype=torch.float32) + + lib = get_lib(weights) + stream = get_stream(weights) + lib.moe_w4a8_prepack( + stream, + weights.data_ptr(), + scales.data_ptr(), + weights_s8.data_ptr(), + wscales.data_ptr(), + cvt_dtype(scales.dtype), + E, + N, + K, + group_size, + rescale_group_size, + ) + return weights_s8, wscales, block + + +def moe_gemm_w4a8( + activations: torch.Tensor, + weights_s8: torch.Tensor, + wscales: torch.Tensor, + num_tokens_per_expert: torch.Tensor, + *, + rescale_block_size: Optional[int] = None, + phase: str = "auto", +) -> torch.Tensor: + """W4A8 MoE GEMM: int4 weights (pre-converted to int8), int8 compute. + + Activations are dynamically quantized to int8 per token inside the kernel + (per-row absmax), then multiplied against the ``AUTO_S8`` int8 weights with + the ``s8 x s8 -> s32`` DPAS atom. The output is + ``acc * act_scale[token] * weight_scale[n, block]``. + + Args: + activations: ``[total_tokens, K]`` fp16/bf16, rows sorted by expert. + weights_s8: ``[E, N, K]`` ``torch.int8`` from :func:`moe_w4a8_prepack`. + wscales: ``[E, N, K // rescale_block_size]`` fp32 from + :func:`moe_w4a8_prepack`. + num_tokens_per_expert: ``[E]`` int32; sum must equal ``total_tokens``. + rescale_block_size: block size the scales were built with. Defaults to + ``K // wscales.shape[2]``. + phase: ``"auto"`` (GEMV for small batches, grouped GEMM otherwise), + ``"decode"`` (force GEMV) or ``"prefill"`` (force grouped GEMM). + + Returns: + ``[total_tokens, N]`` in the activations dtype. + """ + if phase not in _MOE_VALID_PHASES: + raise ValueError(f"phase must be one of {_MOE_VALID_PHASES}, got {phase!r}") + if activations.device.type != "xpu": + raise NotImplementedError("moe_gemm_w4a8 is only supported on XPU") + if activations.dtype not in (torch.float16, torch.bfloat16): + raise ValueError(f"activations must be fp16/bf16, got {activations.dtype}") + if activations.ndim != 2: + raise ValueError("activations must be 2D [total_tokens, K]") + if weights_s8.ndim != 3 or weights_s8.dtype != torch.int8: + raise ValueError("weights_s8 must be a 3D torch.int8 tensor [E, N, K]") + if wscales.ndim != 3 or wscales.dtype != torch.float32: + raise ValueError("wscales must be a 3D torch.float32 tensor [E, N, K // block]") + + activations = activations.contiguous() + weights_s8 = weights_s8.contiguous() + wscales = wscales.contiguous() + + total_tokens, K = activations.shape + num_experts, N, weight_K = weights_s8.shape + if weight_K != K: + raise ValueError(f"weights_s8 K dim {weight_K} != activations K {K}") + if wscales.shape[0] != num_experts or wscales.shape[1] != N: + raise ValueError(f"wscales shape {tuple(wscales.shape)} incompatible with weights {tuple(weights_s8.shape)}") + if N % 16 != 0: + raise ValueError(f"N must be a multiple of 16 (got {N})") + if K % 64 != 0: + raise ValueError(f"K must be a multiple of 64 (got {K})") + + nblk = wscales.shape[2] + if nblk <= 0 or K % nblk != 0: + raise ValueError(f"wscales last dim {nblk} must divide K ({K})") + block = K // nblk if rescale_block_size is None else int(rescale_block_size) + if block * nblk != K: + raise ValueError(f"rescale_block_size {block} is inconsistent with wscales last dim {nblk} and K {K}") + + if num_tokens_per_expert.dtype != torch.int32: + num_tokens_per_expert = num_tokens_per_expert.to(torch.int32) + if not num_tokens_per_expert.is_contiguous(): + num_tokens_per_expert = num_tokens_per_expert.contiguous() + if num_tokens_per_expert.shape[0] != num_experts: + raise ValueError(f"num_tokens_per_expert length {num_tokens_per_expert.shape[0]} != num_experts {num_experts}") + _check_routing_total(num_tokens_per_expert, total_tokens) + + outputs = torch.empty((total_tokens, N), device=activations.device, dtype=activations.dtype) + if total_tokens == 0: + return outputs + + lib = get_lib(activations) + stream = get_stream(activations) + lib.moe_gemm_w4a8( + stream, + activations.data_ptr(), + weights_s8.data_ptr(), + wscales.data_ptr(), + outputs.data_ptr(), + cvt_dtype(activations.dtype), + N, + K, + block, + num_tokens_per_expert.data_ptr(), + num_experts, + total_tokens, + _MOE_VALID_PHASES.index(phase), + ) + return outputs + + +def moe_w4a8( + activations: torch.Tensor, + weights: torch.Tensor, + num_tokens_per_expert: torch.Tensor, + *, + scales: Optional[torch.Tensor] = None, + group_size: int = 128, + rescale_group_size: int = -1, + phase: str = "auto", + cache_prepack: bool = True, +) -> torch.Tensor: + """W4A8 MoE from auto-round's packed int4-sym weights (prefill + decode). + + Convenience wrapper that runs :func:`moe_w4a8_prepack` (cached on the + weight/scale tensor identity) and then :func:`moe_gemm_w4a8`. It is a + drop-in replacement for :func:`moe` on int4-sym weights, trading a small + amount of extra quantization error on the activations for the int8 DPAS + throughput. + + Args: + activations: ``[total_tokens, K]`` fp16/bf16, rows sorted by expert. + weights: ``[E, N, K // 2]`` ``torch.uint8`` packed int4-sym weights. + num_tokens_per_expert: ``[E]`` int32. + scales: ``[E, N, K // group_size]`` in the activations dtype. + group_size: quantization group along K (default 128). + rescale_group_size: AUTO_S8 block size, ``-1`` = per output channel. + phase: ``"auto"``, ``"decode"`` or ``"prefill"``. + cache_prepack: keep the converted int8 weights in a module-level cache + keyed on ``(weights, scales)`` identity. Set to ``False`` for + one-shot use so the (large) int8 copy is released immediately. + + Returns: + ``[total_tokens, N]`` in the activations dtype. + """ + if scales is None: + raise ValueError("moe_w4a8: scales is required for int4 weights") + if scales.dtype != activations.dtype: + raise ValueError("moe_w4a8: scales dtype must match activations dtype") + + key = None + entry = None + if cache_prepack: + key = ( + weights.data_ptr(), + scales.data_ptr(), + tuple(weights.shape), + int(group_size), + int(rescale_group_size), + str(scales.dtype), + ) + entry = _MOE_W4A8_PREPACK_CACHE.get(key) + if entry is None: + entry = moe_w4a8_prepack( + weights, + scales, + group_size=group_size, + rescale_group_size=rescale_group_size, + ) + if cache_prepack: + _MOE_W4A8_PREPACK_CACHE[key] = entry + + weights_s8, wscales, block = entry + return moe_gemm_w4a8( + activations, + weights_s8, + wscales, + num_tokens_per_expert, + rescale_block_size=block, + phase=phase, + ) + + # --------------------------------------------------------------------------- # Native FP8 prefill opt-in. # diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py new file mode 100644 index 0000000000..630d53f194 --- /dev/null +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -0,0 +1,786 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2026 Intel Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Performance + accuracy benchmark for the W4A8 ARK XPU MoE kernel. + +W4A8 = int4 weights, **int8 compute**. The int4 weights are converted once +into int8 with ARK's ``AUTO_S8`` re-scale (int4 ``group=32`` -> int8 +``group=-1``, i.e. one scale per output channel) and the activations are +dynamically quantized to int8 per token inside the kernel, so the mainloop is +a pure ``s8 x s8 -> s32`` DPAS -- the highest-throughput shape on Xe. + +This script covers **both phases**: + +* **prefill** -- grouped GEMM over expert-sorted tokens + (``ark.moe_w4a8(..., phase="prefill")``). +* **decode** -- GEMV for one/few tokens per expert + (``ark.moe_w4a8(..., phase="decode")``). + +and reports, per shape: + +* accuracy of W4A8 against (a) an fp32 reference computed from the + dequantized int4 weights and (b) the existing W4A16 ARK kernel, as SNR + (dB), cosine similarity and max relative error; +* latency, TFLOPS and effective weight bandwidth of W4A8 vs the W4A16 ARK + path and vs a PyTorch matmul baseline on dequantized weights. + +How to run (pytest):: + + pytest -v -s auto_round_extension/ark/test/test_moe_w4a8_perf.py + +The ``-s`` flag is required to see the printed tables. By default only the +smallest batch group is run; pass ``--all-shapes`` for the full sweep:: + + pytest -v -s auto_round_extension/ark/test/test_moe_w4a8_perf.py --all-shapes + +It also runs standalone, without pytest:: + + python auto_round_extension/ark/test/test_moe_w4a8_perf.py --all-shapes + python auto_round_extension/ark/test/test_moe_w4a8_perf.py --phase decode + python auto_round_extension/ark/test/test_moe_w4a8_perf.py --rescale-group-size 256 + +Useful environment variables (read by the kernel itself): + +* ``ARK_MOE_W4A8_AUTO_S8`` -- override the AUTO_S8 re-scale block size. + Unset / ``-1`` keeps the default (one scale per output channel). +* ``ARK_MOE_W4A8_DECODE_MAX_TOKENS`` -- token count at or below which + ``phase="auto"`` picks the GEMV (default 128). + +.. note:: + + The W4A8 kernel is a new SYCL/CuTe port; this script is the intended + on-hardware validation vehicle for it (the kernel header is marked + ``STATUS: NEEDS-HARDWARE-VALIDATION``). +""" + +import argparse +import math +import os +import sys + +import torch + +try: # pytest is optional when the script is run directly + import pytest +except ImportError: # pragma: no cover - standalone execution without pytest + pytest = None + +import auto_round_kernel + +# Reuse the pack/dequant helpers validated by the correctness tests. +from test_moe import _dequant_int4_sym, _pack_int4_sym # noqa: E402 + +ark = auto_round_kernel + + +# --------------------------------------------------------------------------- +# Skip reasons +# --------------------------------------------------------------------------- + + +def _xpu_available() -> bool: + return hasattr(torch, "xpu") and torch.xpu.is_available() + + +def _xpu_skip_reason() -> str: + if not hasattr(torch, "xpu"): + return "torch has no xpu submodule (need an Intel XPU build of torch)" + if not torch.xpu.is_available(): + return "torch.xpu.is_available() == False (no XPU device or driver visible)" + return "" + + +def _w4a8_skip_reason() -> str: + """Return a non-empty string if the W4A8 MoE kernel can't be exercised.""" + reason = _xpu_skip_reason() + if reason: + return reason + if ark.xpu_lib is None: + return ( + "ark.xpu_lib is None -- the XPU extension module " + "(auto_round_kernel_xpu) failed to import; check that auto_round_kernel " + "was installed for THIS Python env with XPU support enabled" + ) + for symbol in ("moe_w4a8_prepack", "moe_gemm_w4a8"): + if not hasattr(ark.xpu_lib, symbol): + return ( + f"ark.xpu_lib loaded but has no {symbol} symbol -- " + "rebuild with ARK_SYCL_TLA=ON to compile the W4A8 MoE kernel" + ) + return "" + + +_W4A8_SKIP = _w4a8_skip_reason() + +print( + "[moe-w4a8-perf] xpu_available=%s xpu_lib=%s has_moe_gemm_w4a8=%s" + % ( + _xpu_available(), + "loaded" if ark.xpu_lib is not None else "None", + hasattr(ark.xpu_lib, "moe_gemm_w4a8") if ark.xpu_lib is not None else False, + ) +) +if _W4A8_SKIP: + print("[moe-w4a8-perf] suite will SKIP. reason: %s" % _W4A8_SKIP) + + +# --------------------------------------------------------------------------- +# Timing utilities +# --------------------------------------------------------------------------- + +WARMUP = 5 +ITERS = 30 + + +def _release_xpu_memory() -> None: + """Free cached XPU memory and synchronize between shapes.""" + if _xpu_available(): + torch.xpu.synchronize() + if hasattr(torch.xpu, "empty_cache"): + torch.xpu.empty_cache() + + +def _xpu_time_ms(fn, warmup: int = None, iters: int = None) -> float: + """Time ``fn`` on XPU using device events; returns the median ms per call. + + ``warmup`` / ``iters`` default to the module-level ``WARMUP`` / ``ITERS`` + at *call* time (not at definition time) so the standalone CLI's + ``--warmup`` / ``--iters`` flags take effect. + """ + warmup = WARMUP if warmup is None else warmup + iters = ITERS if iters is None else iters + for _ in range(warmup): + fn() + torch.xpu.synchronize() + + timings = [] + for _ in range(iters): + start = torch.xpu.Event(enable_timing=True) + end = torch.xpu.Event(enable_timing=True) + start.record() + fn() + end.record() + end.synchronize() + timings.append(start.elapsed_time(end)) + timings.sort() + return timings[len(timings) // 2] + + +# --------------------------------------------------------------------------- +# Accuracy metrics +# --------------------------------------------------------------------------- + + +def _snr_db(reference: torch.Tensor, actual: torch.Tensor) -> float: + """Signal-to-noise ratio in dB: ``10 log10(||ref||^2 / ||ref - act||^2)``. + + Higher is better. ``inf`` means bit-exact. As a rule of thumb an int8 + activation path lands around 25-40 dB against an fp32 reference; below + ~15 dB something is structurally wrong (wrong scale block, wrong layout) + rather than merely lossy. + """ + ref = reference.to(torch.float32).flatten() + act = actual.to(torch.float32).flatten() + noise = torch.sum((ref - act) ** 2).item() + signal = torch.sum(ref**2).item() + if noise == 0.0: + return float("inf") + if signal == 0.0: + return float("-inf") + return 10.0 * math.log10(signal / noise) + + +def _cosine(reference: torch.Tensor, actual: torch.Tensor) -> float: + ref = reference.to(torch.float32).flatten() + act = actual.to(torch.float32).flatten() + denom = ref.norm().item() * act.norm().item() + if denom == 0.0: + return float("nan") + return float(torch.dot(ref, act).item() / denom) + + +def _max_rel_err(reference: torch.Tensor, actual: torch.Tensor) -> float: + """Max relative error, normalized by the reference magnitude scale. + + Elements are normalized by ``max(|ref_elem|, 0.01 * max|ref|)`` so that + near-zero outputs (where the relative error is meaningless and explodes) + do not dominate the metric. + """ + ref = reference.to(torch.float32) + act = actual.to(torch.float32) + scale = ref.abs().max().item() + if scale == 0.0: + return 0.0 + denom = torch.clamp(ref.abs(), min=0.01 * scale) + return float(((ref - act).abs() / denom).max().item()) + + +# --------------------------------------------------------------------------- +# Shapes +# +# Qwen3-MoE (the shape group the int4 MoE work targets): +# hidden_size = 2048, intermediate_size = 768, num_local_experts = 128, +# num_experts_per_tok = 8, int4-sym weights, group_size = 32 +# +# w13 (gate/up-proj): N = 2 * 768 = 1536, K = 2048 +# w2 (down-proj) : N = 2048, K = 768 +# +# Routed expert-token rows = ``batch * top_k``, spread round-robin over the +# 128 experts. +# +# NOTE: the W4A8 kernel requires ``K % 64 == 0``; the down-proj K of 768 +# satisfies it, and the AUTO_S8 default block (= K) keeps a single full-K +# accumulation for both GEMMs. +# --------------------------------------------------------------------------- + +_QWEN3_E = 128 +_QWEN3_HIDDEN = 2048 +_QWEN3_INTER = 768 +_QWEN3_TOPK = 8 +_QWEN3_GROUP_SIZE = 32 + +# (label, N, K) for the two grouped GEMMs of one Qwen3-MoE layer. +_QWEN3_NK = [ + ("qwen3 up ", 2 * _QWEN3_INTER, _QWEN3_HIDDEN), + ("qwen3 down", _QWEN3_HIDDEN, _QWEN3_INTER), +] + +# Model-token batches (routed rows = batch * top_k). +_DECODE_BATCHES = [1] +_DECODE_BATCHES_EXTENDED = [1, 2, 8, 16] +_PREFILL_BATCHES = [128] +_PREFILL_BATCHES_EXTENDED = [128, 512, 2048, 8192] + + +def _spread_tokens(total_tokens: int, num_experts: int) -> list: + """Distribute ``total_tokens`` across ``num_experts`` round-robin.""" + tpe = [0] * num_experts + for i in range(total_tokens): + tpe[i % num_experts] += 1 + return tpe + + +def _decode_batches(all_shapes: bool) -> list: + return list(_DECODE_BATCHES_EXTENDED if all_shapes else _DECODE_BATCHES) + + +def _prefill_batches(all_shapes: bool) -> list: + return list(_PREFILL_BATCHES_EXTENDED if all_shapes else _PREFILL_BATCHES) + + +# --------------------------------------------------------------------------- +# Workload construction +# --------------------------------------------------------------------------- + + +def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0): + """Build one W4A8 MoE test case. + + Returns a dict with the packed int4 weights + scales, the activations, + the routing histogram, and the fp32 reference output computed from the + dequantized weights (so quantization error of the *weights* is excluded + from the W4A8-vs-reference comparison, isolating the extra error the int8 + activation path introduces). + """ + generator = torch.Generator(device="cpu").manual_seed(seed) + w_float = torch.randn(E, N, K, generator=generator, dtype=torch.float32) * 0.05 + scales = torch.empty(E, N, K // group_size, dtype=dtype) + packed = _pack_int4_sym(w_float, scales, group_size) + + activations = (torch.randn(total_tokens, K, generator=generator, dtype=torch.float32) * 0.5).to(dtype) + tpe = _spread_tokens(total_tokens, E) + ntpe = torch.tensor(tpe, dtype=torch.int32) + + packed = packed.to(device) + scales = scales.to(device) + activations = activations.to(device) + ntpe = ntpe.to(device) + + dequant = _dequant_int4_sym(packed, scales, group_size) + + # fp32 per-expert reference on the dequantized weights. + reference = torch.empty(total_tokens, N, device=device, dtype=torch.float32) + offset = 0 + for e, n_e in enumerate(tpe): + if n_e == 0: + continue + a = activations[offset : offset + n_e].to(torch.float32) + reference[offset : offset + n_e] = a @ dequant[e].to(torch.float32).t() + offset += n_e + + return { + "packed": packed, + "scales": scales, + "dequant": dequant, + "activations": activations, + "ntpe": ntpe, + "tpe": tpe, + "reference": reference, + "N": N, + "K": K, + "E": E, + "total_tokens": total_tokens, + "group_size": group_size, + "dtype": dtype, + } + + +def _torch_baseline(case): + """Per-expert ``A @ W.T`` on pre-dequantized weights (matmul-only cost).""" + activations = case["activations"] + dequant = case["dequant"] + out = torch.empty(case["total_tokens"], case["N"], device=activations.device, dtype=activations.dtype) + offset = 0 + for e, n_e in enumerate(case["tpe"]): + if n_e == 0: + continue + out[offset : offset + n_e] = activations[offset : offset + n_e] @ dequant[e].t() + offset += n_e + return out + + +def _w4a16(case, phase): + """The existing W4A16 ARK path (int4 weights, fp16/bf16 compute).""" + if phase == "decode": + return ark.moe_gemm_decode( + case["activations"], + case["packed"], + case["ntpe"], + scales=case["scales"], + weight_bits=4, + group_size=case["group_size"], + asym=False, + ) + return ark.moe_gemm_prefill( + case["activations"], + case["packed"], + case["ntpe"], + scales=case["scales"], + weight_bits=4, + group_size=case["group_size"], + asym=False, + ) + + +def _w4a8(case, weights_s8, wscales, block, phase): + return ark.moe_gemm_w4a8( + case["activations"], + weights_s8, + wscales, + case["ntpe"], + rescale_block_size=block, + phase=phase, + ) + + +def _flops(total_tokens, N, K) -> float: + """MoE grouped GEMM FLOPs: each token does one ``[K] x [K, N]`` product.""" + return float(total_tokens) * N * K * 2.0 + + +def _weight_bytes(E, N, K, bits) -> float: + return float(E) * N * K * bits / 8.0 + + +# --------------------------------------------------------------------------- +# Printing +# --------------------------------------------------------------------------- + +_ACC_WIDTH = 150 +_PERF_WIDTH = 168 + + +def _print_acc_header(title: str) -> None: + """Accuracy table. + + * ``SNR ref`` / ``cos ref`` / ``maxrel ref``: W4A8 against the fp32 + reference computed from the *dequantized* int4 weights. This isolates + the error added by the int8 activation quantization and the AUTO_S8 + weight re-scale. + * ``SNR w4a16``: W4A8 against the existing W4A16 ARK kernel -- the + quality delta a caller sees when switching paths. + """ + print() + print("=" * _ACC_WIDTH) + print(title) + print( + f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'block':>8}" + f"{'SNR ref(dB)':>14}{'cos ref':>12}{'maxrel ref':>13}" + f"{'SNR w4a16(dB)':>16}{'cos w4a16':>12}{'w4a16 SNR ref':>16}" + ) + print("-" * _ACC_WIDTH) + + +def _print_acc_row(label, E, N, K, tokens, block, snr_ref, cos_ref, maxrel_ref, snr_w4a16, cos_w4a16, w4a16_snr_ref): + def _fmt(v, digits=3): + if v is None: + return "--" + if isinstance(v, float) and (math.isinf(v) or math.isnan(v)): + return "inf" if v > 0 else ("nan" if math.isnan(v) else "-inf") + return f"{v:.{digits}f}" + + print( + f"{label:<14}{E:>5}{N:>7}{K:>7}{tokens:>8}{block:>8}" + f"{_fmt(snr_ref, 2):>14}{_fmt(cos_ref, 5):>12}{_fmt(maxrel_ref, 4):>13}" + f"{_fmt(snr_w4a16, 2):>16}{_fmt(cos_w4a16, 5):>12}{_fmt(w4a16_snr_ref, 2):>16}" + ) + + +def _print_perf_header(title: str) -> None: + """Perf table. + + * ``torch(ms)``: per-expert ``A @ W.T`` on pre-dequantized weights + (matmul-only; the dequant is outside the timed region). + * ``w4a16(ms)``: the existing ARK int4 kernel for the same phase. + * ``w4a8(ms)`` / ``TFLOPS`` / ``W GB/s``: the new int8-compute path. + ``W GB/s`` counts only the expert weight traffic actually touched by + the routed tokens, which is what a memory-bound decode is limited by. + * ``vs torch`` / ``vs w4a16``: speedups (``other / w4a8``). + """ + print() + print("=" * _PERF_WIDTH) + print(title) + print( + f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}" + f"{'torch(ms)':>12}{'w4a16(ms)':>12}{'w4a8(ms)':>12}" + f"{'TFLOPS':>10}{'W GB/s':>10}{'vs torch':>11}{'vs w4a16':>11}{'prepack(ms)':>13}" + ) + print("-" * _PERF_WIDTH) + + +def _print_perf_row(label, E, N, K, tokens, torch_ms, w4a16_ms, w4a8_ms, tflops, gbps, prepack_ms): + def _fmt(v, digits=3): + return "--" if v is None else f"{v:.{digits}f}" + + vs_torch = None if (torch_ms is None or not w4a8_ms) else torch_ms / w4a8_ms + vs_w4a16 = None if (w4a16_ms is None or not w4a8_ms) else w4a16_ms / w4a8_ms + print( + f"{label:<14}{E:>5}{N:>7}{K:>7}{tokens:>8}" + f"{_fmt(torch_ms):>12}{_fmt(w4a16_ms):>12}{_fmt(w4a8_ms):>12}" + f"{_fmt(tflops, 2):>10}{_fmt(gbps, 1):>10}" + f"{(_fmt(vs_torch, 2) + 'x') if vs_torch else '--':>11}" + f"{(_fmt(vs_w4a16, 2) + 'x') if vs_w4a16 else '--':>11}" + f"{_fmt(prepack_ms, 2):>13}" + ) + + +# --------------------------------------------------------------------------- +# Core sweeps (shared by pytest and the standalone CLI) +# --------------------------------------------------------------------------- + + +def run_accuracy(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbose=True): + """Run the W4A8 accuracy sweep. Returns a list of per-row metric dicts.""" + rows = [] + if verbose: + _print_acc_header( + f"W4A8 accuracy [{phase}] (E={_QWEN3_E}, group_size={_QWEN3_GROUP_SIZE}, " + f"act={str(dtype).split('.')[-1]}, rescale_group_size={rescale_group_size}) " + f"-- ark.moe_w4a8 vs fp32(dequant int4) and vs W4A16" + ) + for nk_label, N, K in _QWEN3_NK: + for batch in batches: + total_tokens = batch * _QWEN3_TOPK + case = _build_case(N, K, _QWEN3_E, total_tokens, _QWEN3_GROUP_SIZE, dtype) + + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], + case["scales"], + group_size=_QWEN3_GROUP_SIZE, + rescale_group_size=rescale_group_size, + ) + out_w4a8 = _w4a8(case, weights_s8, wscales, block, phase) + + try: + out_w4a16 = _w4a16(case, phase) + except Exception as exc: # pragma: no cover - depends on build + print(f"[moe-w4a8-perf] W4A16 reference unavailable for {nk_label}: {exc}") + out_w4a16 = None + + reference = case["reference"] + row = { + "label": nk_label, + "phase": phase, + "E": _QWEN3_E, + "N": N, + "K": K, + "tokens": total_tokens, + "block": block, + "snr_ref": _snr_db(reference, out_w4a8), + "cos_ref": _cosine(reference, out_w4a8), + "maxrel_ref": _max_rel_err(reference, out_w4a8), + "snr_w4a16": None if out_w4a16 is None else _snr_db(out_w4a16, out_w4a8), + "cos_w4a16": None if out_w4a16 is None else _cosine(out_w4a16, out_w4a8), + "w4a16_snr_ref": None if out_w4a16 is None else _snr_db(reference, out_w4a16), + } + rows.append(row) + if verbose: + _print_acc_row( + nk_label, + _QWEN3_E, + N, + K, + total_tokens, + block, + row["snr_ref"], + row["cos_ref"], + row["maxrel_ref"], + row["snr_w4a16"], + row["cos_w4a16"], + row["w4a16_snr_ref"], + ) + + # Drop the (large) int8 weights before the next shape allocates. + case = weights_s8 = wscales = out_w4a8 = out_w4a16 = None + ark.clear_moe_w4a8_prepack_cache() + _release_xpu_memory() + return rows + + +def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbose=True): + """Run the W4A8 perf sweep. Returns a list of per-row metric dicts.""" + rows = [] + if verbose: + _print_perf_header( + f"W4A8 perf [{phase}] (E={_QWEN3_E}, group_size={_QWEN3_GROUP_SIZE}, " + f"act={str(dtype).split('.')[-1]}, rescale_group_size={rescale_group_size}) " + f"-- ark.moe_gemm_w4a8 vs W4A16 vs torch" + ) + for nk_label, N, K in _QWEN3_NK: + for batch in batches: + total_tokens = batch * _QWEN3_TOPK + case = _build_case(N, K, _QWEN3_E, total_tokens, _QWEN3_GROUP_SIZE, dtype) + + # One-shot int4 -> int8 AUTO_S8 conversion. Timed separately: it + # happens once at model load, not per forward. + prepack_ms = _xpu_time_ms( + lambda: ark.moe_w4a8_prepack( + case["packed"], + case["scales"], + group_size=_QWEN3_GROUP_SIZE, + rescale_group_size=rescale_group_size, + ), + warmup=1, + iters=3, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], + case["scales"], + group_size=_QWEN3_GROUP_SIZE, + rescale_group_size=rescale_group_size, + ) + + w4a8_ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase)) + torch_ms = _xpu_time_ms(lambda: _torch_baseline(case)) + try: + w4a16_ms = _xpu_time_ms(lambda: _w4a16(case, phase)) + except Exception as exc: # pragma: no cover - depends on build + print(f"[moe-w4a8-perf] W4A16 timing unavailable for {nk_label}: {exc}") + w4a16_ms = None + + active_experts = sum(1 for n_e in case["tpe"] if n_e > 0) + tflops = _flops(total_tokens, N, K) / (w4a8_ms * 1e-3) / 1e12 + # W4A8 streams int8 weights: 1 byte per element, only for the + # experts that actually received tokens. + gbps = _weight_bytes(active_experts, N, K, 8) / (w4a8_ms * 1e-3) / 1e9 + + row = { + "label": nk_label, + "phase": phase, + "E": _QWEN3_E, + "N": N, + "K": K, + "tokens": total_tokens, + "torch_ms": torch_ms, + "w4a16_ms": w4a16_ms, + "w4a8_ms": w4a8_ms, + "tflops": tflops, + "gbps": gbps, + "prepack_ms": prepack_ms, + } + rows.append(row) + if verbose: + _print_perf_row( + nk_label, _QWEN3_E, N, K, total_tokens, torch_ms, w4a16_ms, w4a8_ms, tflops, gbps, prepack_ms + ) + + # Drop the (large) int8 weights before the next shape allocates. + case = weights_s8 = wscales = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + return rows + + +# --------------------------------------------------------------------------- +# pytest entry points +# --------------------------------------------------------------------------- + +if pytest is not None: + + @pytest.fixture(autouse=True) + def _xpu_cleanup_between_tests(): + """Release the XPU allocator cache and W4A8 scratch around every test. + + The prepacked int8 weights are ``E * N * K`` bytes (200+ MB for the + Qwen3 up-proj shape), so a test that aborts mid-sweep would otherwise + leave the allocator holding them and starve the next parametrization. + """ + _release_xpu_memory() + try: + yield + finally: + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + + @pytest.mark.skipif(bool(_W4A8_SKIP), reason=_W4A8_SKIP or "W4A8 MoE kernel unavailable") + class TestMoEW4A8: + """W4A8 MoE: accuracy + performance for prefill and decode.""" + + @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) + def test_accuracy_decode(self, request, dtype): + all_shapes = request.config.getoption("--all-shapes", default=False) + rows = run_accuracy("decode", _decode_batches(all_shapes), dtype=dtype) + _assert_accuracy(rows) + + @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) + def test_accuracy_prefill(self, request, dtype): + all_shapes = request.config.getoption("--all-shapes", default=False) + rows = run_accuracy("prefill", _prefill_batches(all_shapes), dtype=dtype) + _assert_accuracy(rows) + + def test_accuracy_rescale_group_size(self, request): + """AUTO_S8 with an explicit sub-K block must stay at least as accurate. + + A smaller re-scale block tracks the per-group int4 scales more + closely, so it can only reduce the weight re-quantization error -- + at the cost of an accumulator fold per block in the mainloop. + """ + all_shapes = request.config.getoption("--all-shapes", default=False) + batches = _prefill_batches(all_shapes)[:1] + coarse = run_accuracy("prefill", batches, rescale_group_size=-1) + fine = run_accuracy("prefill", batches, rescale_group_size=256) + _assert_accuracy(coarse) + _assert_accuracy(fine) + for c, f in zip(coarse, fine): + assert f["block"] <= c["block"] + + def test_perf_decode(self, request): + all_shapes = request.config.getoption("--all-shapes", default=False) + rows = run_perf("decode", _decode_batches(all_shapes)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + + def test_perf_prefill(self, request): + all_shapes = request.config.getoption("--all-shapes", default=False) + rows = run_perf("prefill", _prefill_batches(all_shapes)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + + +# Minimum quality gate for the int8 activation path against an fp32 reference +# built from the same dequantized int4 weights. Per-token absmax int8 +# activations lose ~7 bits of mantissa, which empirically lands well above +# 20 dB / 0.999 cosine; anything below indicates a structural bug (wrong +# scale block, transposed layout, wrong expert offset) rather than lossiness. +_MIN_SNR_DB = 20.0 +_MIN_COSINE = 0.99 + + +def _assert_accuracy(rows) -> None: + assert rows, "no accuracy rows were produced" + for row in rows: + label = f"{row['label']} phase={row['phase']} N={row['N']} K={row['K']} tokens={row['tokens']}" + assert row["snr_ref"] >= _MIN_SNR_DB, f"{label}: SNR vs fp32 reference {row['snr_ref']:.2f} dB is too low" + assert row["cos_ref"] >= _MIN_COSINE, f"{label}: cosine vs fp32 reference {row['cos_ref']:.5f} is too low" + + +# --------------------------------------------------------------------------- +# Standalone CLI +# --------------------------------------------------------------------------- + + +def _parse_args(argv): + parser = argparse.ArgumentParser(description="W4A8 ARK XPU MoE performance / accuracy benchmark") + parser.add_argument( + "--phase", + choices=("decode", "prefill", "both"), + default="both", + help="Which phase(s) to benchmark (default: both).", + ) + parser.add_argument( + "--all-shapes", + action="store_true", + help="Sweep the full batch matrix instead of the single smallest batch.", + ) + parser.add_argument( + "--dtype", + choices=("bf16", "fp16"), + default="bf16", + help="Activation dtype (default: bf16).", + ) + parser.add_argument( + "--rescale-group-size", + type=int, + default=-1, + help="AUTO_S8 re-scale block size; -1 (default) = one scale per output channel.", + ) + parser.add_argument("--skip-accuracy", action="store_true", help="Only run the perf sweep.") + parser.add_argument("--skip-perf", action="store_true", help="Only run the accuracy sweep.") + parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") + parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") + return parser.parse_args(argv) + + +def main(argv=None) -> int: + global WARMUP, ITERS + args = _parse_args(sys.argv[1:] if argv is None else argv) + if _W4A8_SKIP: + print(f"[moe-w4a8-perf] cannot run: {_W4A8_SKIP}") + return 1 + + WARMUP = args.warmup + ITERS = args.iters + dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16 + phases = ("decode", "prefill") if args.phase == "both" else (args.phase,) + + env_block = os.environ.get("ARK_MOE_W4A8_AUTO_S8") + if env_block is not None: + print(f"[moe-w4a8-perf] ARK_MOE_W4A8_AUTO_S8={env_block} overrides --rescale-group-size") + + failures = [] + for phase in phases: + batches = _decode_batches(args.all_shapes) if phase == "decode" else _prefill_batches(args.all_shapes) + if not args.skip_accuracy: + rows = run_accuracy(phase, batches, dtype=dtype, rescale_group_size=args.rescale_group_size) + try: + _assert_accuracy(rows) + except AssertionError as exc: + failures.append(str(exc)) + if not args.skip_perf: + run_perf(phase, batches, dtype=dtype, rescale_group_size=args.rescale_group_size) + + if failures: + print() + print("ACCURACY FAILURES:") + for message in failures: + print(f" - {message}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From dc5ac50209a7243002193651446c4261deb1f6a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:46:22 +0000 Subject: [PATCH 052/112] docs: add EN + CN README for the W4A8 MoE benchmark script Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 205 ++++++++++++++++++ .../ark/test/README_MOE_W4A8_CN.md | 198 +++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 auto_round_extension/ark/test/README_MOE_W4A8.md create mode 100644 auto_round_extension/ark/test/README_MOE_W4A8_CN.md diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md new file mode 100644 index 0000000000..4e15f945f6 --- /dev/null +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -0,0 +1,205 @@ +# W4A8 MoE Kernel (int4 weight / int8 compute) — Performance & Accuracy + +## Overview + +`test_moe_w4a8_perf.py` benchmarks the **W4A8** ARK XPU MoE kernel for both the +**prefill** and **decode** phases, and checks its numerical accuracy against an +fp32 reference and against the existing W4A16 ARK path. + +**W4A8** means: + +| Component | Format | +|---|---| +| Weights on disk / in the checkpoint | int4 symmetric, `group_size = 32` (auto-round packing) | +| Weights in the GEMM mainloop | **int8** (`group = -1`, one scale per output channel) | +| Activations | dynamically quantized to **int8** per token (absmax) inside the kernel | +| Accumulator | int32 (`s8 × s8 → s32` DPAS) | +| Output | fp16 / bf16 | + +## Why int8 compute is faster than int4 weight-only + +The Xe DPAS pipeline has a native `s8 × s8 → s32` atom. A weight-only int4 path +must first widen the nibbles into the activation dtype, then run an fp16/bf16 +matmul, and — because the int4 scales are per K-group of 32 — must fold the +accumulator every 32 K elements. That fold destroys the long-K accumulation the +DPAS pipeline needs to run at peak. + +ARK already solves this for dense GEMM with its **`AUTO_S8`** option, which +re-scales int4 `group=32` weights into int8 `group=-1` weights: + +``` +sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * 8 / 127 # 8 = 2^(4-1), the int4 full range +w8[e][n][k] = round( w4[e][n][k] * s[e][n][k / group_size] / sxt[e][n][j] ) +``` + +Because `|w4| <= 8`, the re-scaled value satisfies `|w8| <= 127` — the +conversion never clips. With the default block (the whole K axis) the mainloop +becomes a **single full-K int32 accumulation** followed by one scalar multiply +in the epilogue, which is the highest-throughput configuration. + +This kernel applies the same idea to the MoE grouped GEMM. The conversion runs +**once** (at model load), not per forward pass. + +## What the script measures + +### Accuracy table + +| Column | Meaning | +|---|---| +| `block` | Resolved `AUTO_S8` re-scale block size (K = one scale per output channel) | +| `SNR ref(dB)` | W4A8 vs. an fp32 reference built from the **dequantized** int4 weights. Isolates the error added by int8 activations + the AUTO_S8 re-scale, excluding the int4 weight-quantization error itself. | +| `cos ref` | Cosine similarity against the same reference | +| `maxrel ref` | Max relative error, normalized by `max(|ref|, 0.01 · max|ref|)` so near-zero outputs don't dominate | +| `SNR w4a16(dB)` / `cos w4a16` | W4A8 vs. the existing W4A16 ARK kernel — the quality delta a caller sees when switching paths | +| `w4a16 SNR ref` | W4A16 vs. the same fp32 reference, so the two paths can be compared on equal footing | + +The pytest cases assert `SNR ref >= 20 dB` and `cosine >= 0.99`. Per-token +absmax int8 activations lose roughly 7 bits of mantissa, so healthy runs land +comfortably above that; anything below indicates a *structural* bug (wrong scale +block, transposed layout, wrong expert offset) rather than mere lossiness. + +### Performance table + +| Column | Meaning | +|---|---| +| `torch(ms)` | Per-expert `A @ W.T` on **pre-dequantized** weights (the dequant is outside the timed region) — the matmul-only PyTorch ceiling | +| `w4a16(ms)` | The existing ARK int4 kernel for the same phase (`moe_gemm_decode` / `moe_gemm_prefill`) | +| `w4a8(ms)` | The new int8-compute path (`ark.moe_gemm_w4a8`) | +| `TFLOPS` | `total_tokens × N × K × 2 / time` | +| `W GB/s` | Expert-weight bandwidth actually touched by the routed tokens (`active_experts × N × K × 1 byte / time`) — the limiter for memory-bound decode | +| `vs torch` / `vs w4a16` | Speedups (`other / w4a8`) | +| `prepack(ms)` | One-shot int4 → int8 AUTO_S8 conversion cost. Paid once at model load, **not** per forward. | + +## Shapes + +Qwen3-MoE, matching the shape group the int4 MoE work targets: + +``` +hidden_size = 2048, intermediate_size = 768 +num_local_experts = 128, num_experts_per_tok = 8 +int4-sym weights, group_size = 32 + +qwen3 up (gate/up-proj): N = 2 × 768 = 1536, K = 2048 +qwen3 down (down-proj) : N = 2048, K = 768 +``` + +Routed expert-token rows are `batch × top_k`, spread round-robin over the 128 +experts. Default batches: `128` for prefill and `1` for decode; `--all-shapes` +widens them to `{128, 512, 2048, 8192}` and `{1, 2, 8, 16}` respectively. + +## How to run + +### As a pytest suite + +```bash +cd /path/to/auto_round_extension/ark/test + +# Everything (accuracy + perf, both phases), smallest batch only +pytest -v -s test_moe_w4a8_perf.py + +# Full batch sweep +pytest -v -s test_moe_w4a8_perf.py --all-shapes + +# Accuracy only / perf only +pytest -v -s test_moe_w4a8_perf.py -k accuracy +pytest -v -s test_moe_w4a8_perf.py -k perf + +# One phase +pytest -v -s test_moe_w4a8_perf.py -k decode +``` + +The `-s` flag is required to see the printed tables. + +### As a standalone script (no pytest) + +```bash +python test_moe_w4a8_perf.py # both phases, smallest batch +python test_moe_w4a8_perf.py --all-shapes # full sweep +python test_moe_w4a8_perf.py --phase decode # decode only +python test_moe_w4a8_perf.py --skip-accuracy # perf only +python test_moe_w4a8_perf.py --dtype fp16 # fp16 activations +python test_moe_w4a8_perf.py --rescale-group-size 256 +python test_moe_w4a8_perf.py --warmup 10 --iters 100 +``` + +The script exits non-zero if any accuracy gate fails. + +## Python API + +```python +import auto_round_kernel as ark + +# 1) One-shot conversion at model load. +# weights : [E, N, K // 2] uint8 (packed int4-sym) +# scales : [E, N, K // group_size] fp16/bf16 +weights_s8, wscales, block = ark.moe_w4a8_prepack(weights, scales, group_size=32) + +# 2) Per forward pass (prefill or decode). +out = ark.moe_gemm_w4a8( + activations, # [total_tokens, K] fp16/bf16, rows sorted by expert + weights_s8, # [E, N, K] int8 + wscales, # [E, N, K // block] fp32 + num_tokens_per_expert, # [E] int32 + rescale_block_size=block, + phase="auto", # "auto" | "decode" | "prefill" +) +``` + +A convenience wrapper does both, caching the conversion on the weight/scale +tensor identity: + +```python +out = ark.moe_w4a8( + activations, weights, num_tokens_per_expert, + scales=scales, group_size=32, phase="auto", +) + +ark.clear_moe_w4a8_prepack_cache() # drop the cached int8 weights +ark.moe_w4a8_release_scratch() # hand back the device scratch slabs +``` + +Helper: `ark.moe_w4a8_rescale_block_size(K, group_size, rescale_group_size)` +resolves the effective block size (and therefore the `wscales` shape) without +allocating anything. + +## Memory cost + +The prepacked weights are `E × N × K` **bytes** (int8), i.e. **2× the packed +int4 weights**: + +| Shape | int4 packed | int8 prepacked | +|---|---|---| +| qwen3 up (E=128, N=1536, K=2048) | 201 MB | 402 MB | +| qwen3 down (E=128, N=2048, K=768) | 100 MB | 201 MB | + +Because they are kept for the process lifetime, W4A8 trades memory for compute +throughput. Use `cache_prepack=False` on `ark.moe_w4a8` (or +`clear_moe_w4a8_prepack_cache()`) if that trade isn't worth it for a given +deployment. + +## Environment variables + +| Variable | Effect | +|---|---| +| `ARK_MOE_W4A8_AUTO_S8` | Override the AUTO_S8 re-scale block size. Unset / `-1` = one scale per output channel (fastest). Values that aren't a multiple of both `group_size` and 64, or that don't divide K, silently fall back to K. | +| `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | Token count at or below which `phase="auto"` picks the GEMV (default `128`). | + +## Shape constraints + +The kernel requires: + +* `N % 16 == 0` (the GEMV N tile and the DPAS N tile) +* `K % 64 == 0` (the DPAS K tile) +* `group_size % 8 == 0` and `K % group_size == 0` +* the resolved re-scale block must be a multiple of 64 and divide K + +Both Qwen3-MoE GEMMs satisfy these (`K = 2048` and `K = 768`). + +## Status + +The W4A8 kernel is a new SYCL/CuTe port and is marked +`STATUS: NEEDS-HARDWARE-VALIDATION` in +`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. This script is the +intended on-hardware validation vehicle: run the accuracy sweep first (it will +catch layout/scale bugs immediately), then the perf sweep to tune the tile +ladder and the decode threshold. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md new file mode 100644 index 0000000000..a77b2fffe2 --- /dev/null +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -0,0 +1,198 @@ +# W4A8 MoE Kernel (int4 权重 / int8 计算) — 性能与精度 + +## 概览 + +`test_moe_w4a8_perf.py` 对 **W4A8** ARK XPU MoE kernel 的 **prefill** 与 +**decode** 两个阶段进行性能基准测试,并将其数值精度与 fp32 参考实现以及现有的 +W4A16 ARK 路径进行对比。 + +**W4A8** 的含义: + +| 组成部分 | 格式 | +|---|---| +| 权重 (checkpoint 中) | int4 对称量化, `group_size = 32` (auto-round 的打包格式) | +| GEMM 主循环中的权重 | **int8** (`group = -1`, 每个输出通道一个 scale) | +| 激活值 | 在 kernel 内部按 token 动态量化 (absmax) 到 **int8** | +| 累加器 | int32 (`s8 × s8 → s32` DPAS) | +| 输出 | fp16 / bf16 | + +## 为什么 int8 计算比 int4 weight-only 更快 + +Xe DPAS 流水线原生支持 `s8 × s8 → s32` 指令。而 weight-only int4 路径必须先把 +nibble 展宽到激活值 dtype,再执行 fp16/bf16 matmul;并且由于 int4 的 scale 是按 +K 方向每 32 个元素一组的,累加器每 32 个 K 元素就要折叠一次。这种折叠破坏了 +DPAS 流水线达到峰值所需的长 K 累加。 + +ARK 在 dense GEMM 中已经用 **`AUTO_S8`** 选项解决了这个问题:它把 int4 +`group=32` 的权重重新缩放成 int8 `group=-1` 的权重: + +``` +sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * 8 / 127 # 8 = 2^(4-1), int4 的满量程 +w8[e][n][k] = round( w4[e][n][k] * s[e][n][k / group_size] / sxt[e][n][j] ) +``` + +由于 `|w4| <= 8`,重新缩放后的值满足 `|w8| <= 127` — 转换过程永远不会截断。使用 +默认的 block (整个 K 轴) 时,主循环变成**一次完整 K 长度的 int32 累加**,尾部只 +需一次标量乘法,这是吞吐最高的配置。 + +本 kernel 把同样的思路应用到 MoE grouped GEMM 上。该转换只在**模型加载时执行一 +次**,而不是每次前向都执行。 + +## 脚本测量的内容 + +### 精度表 + +| 列 | 含义 | +|---|---| +| `block` | 解析出的 `AUTO_S8` 重缩放 block 大小 (K 表示每个输出通道一个 scale) | +| `SNR ref(dB)` | W4A8 与由**反量化后**的 int4 权重构建的 fp32 参考实现对比。它隔离出 int8 激活量化 + AUTO_S8 重缩放引入的误差,不包含 int4 权重量化本身的误差。 | +| `cos ref` | 与同一参考实现的余弦相似度 | +| `maxrel ref` | 最大相对误差,用 `max(|ref|, 0.01 · max|ref|)` 归一化,避免接近 0 的输出主导该指标 | +| `SNR w4a16(dB)` / `cos w4a16` | W4A8 与现有 W4A16 ARK kernel 的对比 — 即调用方切换路径时看到的质量差异 | +| `w4a16 SNR ref` | W4A16 与同一 fp32 参考实现的对比,便于两条路径在同一基准上比较 | + +pytest 用例断言 `SNR ref >= 20 dB` 且 `cosine >= 0.99`。按 token 做 absmax 的 +int8 激活大约损失 7 bit 尾数,正常情况下会明显高于该门限;低于该门限说明存在 +*结构性* bug (scale block 错误、layout 转置错误、expert 偏移错误),而不仅仅是精 +度损失。 + +### 性能表 + +| 列 | 含义 | +|---|---| +| `torch(ms)` | 在**预先反量化**的权重上按 expert 执行 `A @ W.T` (反量化在计时区间之外) — 纯 matmul 的 PyTorch 上限 | +| `w4a16(ms)` | 同一阶段现有的 ARK int4 kernel (`moe_gemm_decode` / `moe_gemm_prefill`) | +| `w4a8(ms)` | 新的 int8 计算路径 (`ark.moe_gemm_w4a8`) | +| `TFLOPS` | `total_tokens × N × K × 2 / time` | +| `W GB/s` | 被路由 token 实际访问到的专家权重带宽 (`active_experts × N × K × 1 byte / time`) — decode 访存瓶颈的衡量指标 | +| `vs torch` / `vs w4a16` | 加速比 (`other / w4a8`) | +| `prepack(ms)` | 一次性的 int4 → int8 AUTO_S8 转换开销。只在模型加载时支付,**不是**每次前向都支付。 | + +## 测试形状 + +Qwen3-MoE,与 int4 MoE 工作所针对的形状组一致: + +``` +hidden_size = 2048, intermediate_size = 768 +num_local_experts = 128, num_experts_per_tok = 8 +int4 对称量化权重, group_size = 32 + +qwen3 up (gate/up-proj): N = 2 × 768 = 1536, K = 2048 +qwen3 down (down-proj) : N = 2048, K = 768 +``` + +被路由的 expert-token 行数为 `batch × top_k`,以 round-robin 方式分布到 128 个专 +家上。默认 batch:prefill 为 `128`,decode 为 `1`;`--all-shapes` 会分别扩展为 +`{128, 512, 2048, 8192}` 和 `{1, 2, 8, 16}`。 + +## 如何运行 + +### 作为 pytest 测试套件 + +```bash +cd /path/to/auto_round_extension/ark/test + +# 全部 (精度 + 性能,两个阶段),仅最小 batch +pytest -v -s test_moe_w4a8_perf.py + +# 完整 batch 扫描 +pytest -v -s test_moe_w4a8_perf.py --all-shapes + +# 仅精度 / 仅性能 +pytest -v -s test_moe_w4a8_perf.py -k accuracy +pytest -v -s test_moe_w4a8_perf.py -k perf + +# 单个阶段 +pytest -v -s test_moe_w4a8_perf.py -k decode +``` + +需要加 `-s` 才能看到打印出的表格。 + +### 作为独立脚本运行 (不依赖 pytest) + +```bash +python test_moe_w4a8_perf.py # 两个阶段,最小 batch +python test_moe_w4a8_perf.py --all-shapes # 完整扫描 +python test_moe_w4a8_perf.py --phase decode # 仅 decode +python test_moe_w4a8_perf.py --skip-accuracy # 仅性能 +python test_moe_w4a8_perf.py --dtype fp16 # fp16 激活 +python test_moe_w4a8_perf.py --rescale-group-size 256 +python test_moe_w4a8_perf.py --warmup 10 --iters 100 +``` + +任何精度门限未通过时,脚本以非 0 状态码退出。 + +## Python API + +```python +import auto_round_kernel as ark + +# 1) 模型加载时的一次性转换。 +# weights : [E, N, K // 2] uint8 (打包的 int4 对称量化权重) +# scales : [E, N, K // group_size] fp16/bf16 +weights_s8, wscales, block = ark.moe_w4a8_prepack(weights, scales, group_size=32) + +# 2) 每次前向 (prefill 或 decode)。 +out = ark.moe_gemm_w4a8( + activations, # [total_tokens, K] fp16/bf16, 按 expert 排序 + weights_s8, # [E, N, K] int8 + wscales, # [E, N, K // block] fp32 + num_tokens_per_expert, # [E] int32 + rescale_block_size=block, + phase="auto", # "auto" | "decode" | "prefill" +) +``` + +下面的便捷封装会同时完成两步,并按权重/scale 张量的标识缓存转换结果: + +```python +out = ark.moe_w4a8( + activations, weights, num_tokens_per_expert, + scales=scales, group_size=32, phase="auto", +) + +ark.clear_moe_w4a8_prepack_cache() # 释放缓存的 int8 权重 +ark.moe_w4a8_release_scratch() # 归还设备端 scratch 内存 +``` + +辅助函数:`ark.moe_w4a8_rescale_block_size(K, group_size, rescale_group_size)` +可以在不做任何分配的情况下解析出有效的 block 大小 (即 `wscales` 的形状)。 + +## 内存开销 + +预处理后的权重为 `E × N × K` **字节** (int8),即打包 int4 权重的 **2 倍**: + +| 形状 | int4 打包 | int8 预处理后 | +|---|---|---| +| qwen3 up (E=128, N=1536, K=2048) | 201 MB | 402 MB | +| qwen3 down (E=128, N=2048, K=768) | 100 MB | 201 MB | + +由于它们会在进程生命周期内一直保留,W4A8 是用内存换计算吞吐。如果在某个部署场景 +下这个权衡不划算,可以在 `ark.moe_w4a8` 上传 `cache_prepack=False` (或调用 +`clear_moe_w4a8_prepack_cache()`)。 + +## 环境变量 + +| 变量 | 作用 | +|---|---| +| `ARK_MOE_W4A8_AUTO_S8` | 覆盖 AUTO_S8 重缩放 block 大小。未设置 / `-1` 表示每个输出通道一个 scale (最快)。如果取值不是 `group_size` 和 64 的公倍数,或不能整除 K,则静默回退为 K。 | +| `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | `phase="auto"` 时选择 GEMV 的 token 数上限 (默认 `128`)。 | + +## 形状约束 + +kernel 要求: + +* `N % 16 == 0` (GEMV 的 N tile 与 DPAS 的 N tile) +* `K % 64 == 0` (DPAS 的 K tile) +* `group_size % 8 == 0` 且 `K % group_size == 0` +* 解析出的重缩放 block 必须是 64 的倍数并且能整除 K + +Qwen3-MoE 的两个 GEMM 都满足以上条件 (`K = 2048` 和 `K = 768`)。 + +## 状态 + +W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 +`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp` 中被标记为 +`STATUS: NEEDS-HARDWARE-VALIDATION`。本脚本正是为在真实硬件上验证它而设计的:请 +先运行精度扫描 (它能立刻暴露 layout / scale 相关的 bug),再运行性能扫描来调优 +tile 阶梯与 decode 阈值。 From 3e969ce1ed142dc6bb5f9c255e8814056e5c2c93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:03:02 +0000 Subject: [PATCH 053/112] fix: pin source tensors in the W4A8 prepack cache to prevent address-reuse collisions Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 29 +++++++++++++++---- .../ark/test/README_MOE_W4A8.md | 8 +++-- .../ark/test/README_MOE_W4A8_CN.md | 6 ++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index 61be23bf67..87528b46bc 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -2374,9 +2374,15 @@ def clear_moe_prefill_workspace_cache() -> None: # weight/scale tensor identity so repeated forward passes over the same expert # weights pay for it only once. Callers that manage their own storage should # use `moe_w4a8_prepack` + `moe_gemm_w4a8` directly. +# +# Cache entries are `(weights_s8, wscales, block, weights, scales)`: the source +# tensors are pinned in the entry because the address half of the key is +# pointer identity, and a freed-then-reallocated buffer can otherwise land on +# the same address. Every MoE layer's expert weights share shape / dtype / +# group_size, so no other key component would catch such a collision. # --------------------------------------------------------------------------- -_MOE_W4A8_PREPACK_CACHE: "dict[tuple, tuple[torch.Tensor, torch.Tensor, int]]" = {} +_MOE_W4A8_PREPACK_CACHE: "dict[tuple, tuple]" = {} def moe_w4a8_rescale_block_size(K: int, group_size: int, rescale_group_size: int = -1) -> int: @@ -2643,8 +2649,11 @@ def moe_w4a8( rescale_group_size: AUTO_S8 block size, ``-1`` = per output channel. phase: ``"auto"``, ``"decode"`` or ``"prefill"``. cache_prepack: keep the converted int8 weights in a module-level cache - keyed on ``(weights, scales)`` identity. Set to ``False`` for - one-shot use so the (large) int8 copy is released immediately. + keyed on ``(weights, scales)`` identity. The cache entry holds + strong references to ``weights`` / ``scales`` so their addresses + cannot be recycled by another tensor while the entry lives (see + :data:`_MOE_W4A8_PREPACK_CACHE`). Set to ``False`` for one-shot use + so the (large) int8 copy is released immediately. Returns: ``[total_tokens, N]`` in the activations dtype. @@ -2657,7 +2666,10 @@ def moe_w4a8( key = None entry = None if cache_prepack: + device = weights.device key = ( + device.type, + device.index, weights.data_ptr(), scales.data_ptr(), tuple(weights.shape), @@ -2667,16 +2679,23 @@ def moe_w4a8( ) entry = _MOE_W4A8_PREPACK_CACHE.get(key) if entry is None: - entry = moe_w4a8_prepack( + weights_s8, wscales, block = moe_w4a8_prepack( weights, scales, group_size=group_size, rescale_group_size=rescale_group_size, ) + # Pin the source tensors in the entry: the address half of the key is + # pointer identity, and a freed-then-reallocated buffer can land on the + # same address (every MoE layer's expert weights share shape/dtype/ + # group_size, so nothing else in the key would discriminate). Holding a + # reference keeps the allocator from handing the address to a different + # tensor for as long as the entry is cached. + entry = (weights_s8, wscales, block, weights, scales) if cache_prepack: _MOE_W4A8_PREPACK_CACHE[key] = entry - weights_s8, wscales, block = entry + weights_s8, wscales, block = entry[0], entry[1], entry[2] return moe_gemm_w4a8( activations, weights_s8, diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 4e15f945f6..7e907dfc5a 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -173,9 +173,11 @@ int4 weights**: | qwen3 down (E=128, N=2048, K=768) | 100 MB | 201 MB | Because they are kept for the process lifetime, W4A8 trades memory for compute -throughput. Use `cache_prepack=False` on `ark.moe_w4a8` (or -`clear_moe_w4a8_prepack_cache()`) if that trade isn't worth it for a given -deployment. +throughput. The cache entry also pins the source int4 `weights` / `scales` +tensors (its key is pointer identity, so a freed-and-reallocated buffer could +otherwise collide with another layer's weights). Use `cache_prepack=False` on +`ark.moe_w4a8` (or `clear_moe_w4a8_prepack_cache()`) if that trade isn't worth +it for a given deployment. ## Environment variables diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index a77b2fffe2..4ecff97989 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -167,8 +167,10 @@ ark.moe_w4a8_release_scratch() # 归还设备端 scratch 内存 | qwen3 up (E=128, N=1536, K=2048) | 201 MB | 402 MB | | qwen3 down (E=128, N=2048, K=768) | 100 MB | 201 MB | -由于它们会在进程生命周期内一直保留,W4A8 是用内存换计算吞吐。如果在某个部署场景 -下这个权衡不划算,可以在 `ark.moe_w4a8` 上传 `cache_prepack=False` (或调用 +由于它们会在进程生命周期内一直保留,W4A8 是用内存换计算吞吐。缓存条目同时会持有源 +int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否则被释放后又重新分 +配的显存可能与其他层的权重发生地址碰撞)。如果在某个部署场景下这个权衡不划算,可以 +在 `ark.moe_w4a8` 上传 `cache_prepack=False` (或调用 `clear_moe_w4a8_prepack_cache()`)。 ## 环境变量 From 8cb7034c110168dae5eeb3a50ec83519614fb50c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:08:04 +0000 Subject: [PATCH 054/112] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../ark/test/README_MOE_W4A8.md | 20 +++++++++++-------- .../ark/test/README_MOE_W4A8_CN.md | 20 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 7e907dfc5a..fa7390b818 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -136,12 +136,12 @@ weights_s8, wscales, block = ark.moe_w4a8_prepack(weights, scales, group_size=32 # 2) Per forward pass (prefill or decode). out = ark.moe_gemm_w4a8( - activations, # [total_tokens, K] fp16/bf16, rows sorted by expert - weights_s8, # [E, N, K] int8 - wscales, # [E, N, K // block] fp32 - num_tokens_per_expert, # [E] int32 + activations, # [total_tokens, K] fp16/bf16, rows sorted by expert + weights_s8, # [E, N, K] int8 + wscales, # [E, N, K // block] fp32 + num_tokens_per_expert, # [E] int32 rescale_block_size=block, - phase="auto", # "auto" | "decode" | "prefill" + phase="auto", # "auto" | "decode" | "prefill" ) ``` @@ -150,12 +150,16 @@ tensor identity: ```python out = ark.moe_w4a8( - activations, weights, num_tokens_per_expert, - scales=scales, group_size=32, phase="auto", + activations, + weights, + num_tokens_per_expert, + scales=scales, + group_size=32, + phase="auto", ) ark.clear_moe_w4a8_prepack_cache() # drop the cached int8 weights -ark.moe_w4a8_release_scratch() # hand back the device scratch slabs +ark.moe_w4a8_release_scratch() # hand back the device scratch slabs ``` Helper: `ark.moe_w4a8_rescale_block_size(K, group_size, rescale_group_size)` diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 4ecff97989..4cb36b6cd8 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -134,12 +134,12 @@ weights_s8, wscales, block = ark.moe_w4a8_prepack(weights, scales, group_size=32 # 2) 每次前向 (prefill 或 decode)。 out = ark.moe_gemm_w4a8( - activations, # [total_tokens, K] fp16/bf16, 按 expert 排序 - weights_s8, # [E, N, K] int8 - wscales, # [E, N, K // block] fp32 - num_tokens_per_expert, # [E] int32 + activations, # [total_tokens, K] fp16/bf16, 按 expert 排序 + weights_s8, # [E, N, K] int8 + wscales, # [E, N, K // block] fp32 + num_tokens_per_expert, # [E] int32 rescale_block_size=block, - phase="auto", # "auto" | "decode" | "prefill" + phase="auto", # "auto" | "decode" | "prefill" ) ``` @@ -147,12 +147,16 @@ out = ark.moe_gemm_w4a8( ```python out = ark.moe_w4a8( - activations, weights, num_tokens_per_expert, - scales=scales, group_size=32, phase="auto", + activations, + weights, + num_tokens_per_expert, + scales=scales, + group_size=32, + phase="auto", ) ark.clear_moe_w4a8_prepack_cache() # 释放缓存的 int8 权重 -ark.moe_w4a8_release_scratch() # 归还设备端 scratch 内存 +ark.moe_w4a8_release_scratch() # 归还设备端 scratch 内存 ``` 辅助函数:`ark.moe_w4a8_rescale_block_size(K, group_size, rescale_group_size)` From 1ff56798a762da5b2284480d26ced27037c7bec1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:18:40 +0000 Subject: [PATCH 055/112] perf(ark): W4A8 MoE decode K-split mapping + roofline-aware benchmark Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 300 +++++++++++++++- auto_round_extension/ark/test/conftest.py | 15 + .../ark/test/test_moe_w4a8_perf.py | 332 ++++++++++++++++-- 3 files changed, 611 insertions(+), 36 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 49b502b2cf..b7cc0ec475 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -128,6 +128,9 @@ class MoEW4A8Repack; template class MoEW4A8DecodeGemv; +template +class MoEW4A8DecodeKSplit; + template class MoEW4A8GemmName; @@ -158,10 +161,23 @@ inline DeviceScratchPool& expert_map_pool() { // absmax with `reduce_over_group`, then write back the quantized row. A row // that is entirely zero gets `scale = 0` and quantizes to all zeros (the // reciprocal is forced to 0 instead of inf). +// +// The decode path also needs `expert_id_per_token`, which +// `moe_decode_detail::fill_expert_id_per_token` produces in a kernel of its +// own. That kernel does one tiny scan per token, so at decode sizes it is pure +// launch overhead on a timeline where the GEMV itself is only tens of +// microseconds and one call is issued per generated token. This kernel already +// runs one sub-group per token, so when `expert_id_per_token != nullptr` lane 0 +// folds the same scan in and the separate launch disappears -- the same "one +// fewer kernel launch on the decode timeline" the FP8 DPAS decode dispatch +// gets by consuming `num_tokens_per_expert` directly. The scan is the verbatim +// body of `fill_expert_id_per_token`, including its clamp to +// `num_experts - 1` for a routing table that sums to less than `total_tokens`. // --------------------------------------------------------------------------- template void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, - int total_tokens, int K) { + int total_tokens, int K, int* expert_id_per_token = nullptr, + const int* num_tokens_per_expert = nullptr, int num_experts = 0) { static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); if (total_tokens == 0) return; @@ -184,7 +200,22 @@ void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t const float scale = absmax / kInt8Max; const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; - if (lane == 0) ascale[token] = scale; + if (lane == 0) { + ascale[token] = scale; + if (expert_id_per_token != nullptr) { + int offset = 0; + int expert = num_experts - 1; + for (int e = 0; e < num_experts; ++e) { + const int n = num_tokens_per_expert[e]; + if (token < offset + n) { + expert = e; + break; + } + offset += n; + } + expert_id_per_token[token] = expert; + } + } for (int k = lane; k < K; k += SG_SIZE) { const float v = sycl::rint(static_cast(row[k]) * inv); @@ -658,6 +689,235 @@ void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, }); } +// --------------------------------------------------------------------------- +// Decode GEMV, K-split lane mapping (default) -- one sub-group per output +// element, lanes splitting K, plus N-blocking over `NCOLS` columns. +// +// `launch_w4a8_decode` above maps one *work-item* to one output element, so a +// lane walks a whole `[n_global, K]` int8 weight row on its own. That is the +// same mapping the FP8 decode GEMV started from, and it costs the same two +// things on a kernel that does exactly one multiply-add per weight byte: +// +// 1. Weight loads are not coalesced. Lanes `l` and `l+1` read bytes `K` +// apart, so each 16-byte load turns into 16 scattered cache-line +// requests. No DRAM byte is wasted (each lane consumes its lines as it +// walks the row), but the memory controller sees 16 independent streams +// per sub-group -- the pattern DRAM row buffers handle worst. +// 2. The grid is small: `total_tokens * N / 16` sub-groups, i.e. 768 SIMD16 +// threads for a Qwen3-MoE batch-1 step (8 routed rows, N = 1536). That is +// below the thread slots of a BMG-class GPU, so there are never enough +// loads in flight to cover DRAM latency. +// +// This kernel transposes the mapping exactly like `launch_fp8_ksplit`: a whole +// sub-group cooperates on one output element and lane `l` owns the +// `KSPLIT_CH` consecutive K elements at `l * KSPLIT_CH` inside each +// `KSPLIT_STEP`-wide K tile. One instruction then covers `KSPLIT_STEP` +// *contiguous* weight bytes (256 B = four full cache lines) and the same span +// of int8 activations, every thread walks a single sequential stream, and the +// sub-group count grows by `SG_SIZE` (12288 for that batch-1 step). The price +// is one `reduce_over_group` per output element -- a handful of shuffles +// against `K` multiply-adds. +// +// On top of that the sub-group blocks N: it owns `NCOLS` consecutive columns +// and reuses one activation load for all of them, which cuts activation +// messages per weight chunk to `1 / NCOLS` and multiplies the number of +// independent weight loads in flight by `NCOLS` (see +// `moe_w4a8_decode_ksplit_ncols`). +// +// Numerics are unchanged: the int32 partial sums are still folded to float +// once per AUTO_S8 block with that block's scale, only split across the 16 +// lanes and summed at the end (integer addition is associative, and the float +// fold happens per block per lane exactly as before). A lane's chunk is 16 +// consecutive K elements starting at a multiple of 16 and every block boundary +// is a multiple of 64, so a chunk never straddles two blocks. +// --------------------------------------------------------------------------- + +// K elements a lane owns per step: one 16-byte int8 weight load and one +// 16-byte int8 activation load, the same transactions the legacy GEMV issues. +constexpr int KSPLIT_CH = 16; +// K elements a sub-group covers per step -- the contiguous span its 16 lanes +// read in one instruction. +constexpr int KSPLIT_STEP = SG_SIZE * KSPLIT_CH; +// Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group +// covers `KSPLIT_WG_SGS * NCOLS` consecutive columns. +constexpr int KSPLIT_WG_SGS = N_TILE; +constexpr int KSPLIT_NCOLS_DEFAULT = 2; +constexpr int KSPLIT_NCOLS_MAX = 4; + +// `ARK_MOE_W4A8_DECODE_KSPLIT` (default ON). Setting it to "0" / "false" / +// "off" / "no" forces the legacy per-lane-strided GEMV, for A/B comparison and +// as a regression escape. Re-read on every call so benchmarks can toggle the +// path in-process. +inline bool moe_w4a8_decode_ksplit_enabled() { + return moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_DECODE_KSPLIT", true); +} + +// Shape gate. `blocksize >= KSPLIT_STEP` keeps every lane of the sub-group +// busy: below that some lanes own no chunk in a block and only pay the +// reduction, which is the one regime where splitting K cannot pay for itself. +// The resolved AUTO_S8 block is always a multiple of 64 that divides K, so the +// alignment condition holds for every shipped configuration and only very fine +// re-scale blocks (64 / 128 / 192) fall back to the legacy GEMV. +inline bool moe_w4a8_decode_ksplit_shape_ok(int N, int K, int blocksize) { + if (N % N_TILE != 0) return false; + if (blocksize < KSPLIT_STEP) return false; + if (blocksize % KSPLIT_CH != 0) return false; + if (K % blocksize != 0) return false; + return true; +} + +// N-blocking factor. A work-group covers `KSPLIT_WG_SGS * ncols` columns, so +// `ncols` shrinks until it tiles N. `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` +// overrides the default (1, 2 or 4); `NCOLS == 1` reproduces the plain K-split +// mapping instruction for instruction. +inline int moe_w4a8_decode_ksplit_ncols(int N) { + int ncols = KSPLIT_NCOLS_DEFAULT; + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 1 && v <= KSPLIT_NCOLS_MAX && (v & (v - 1)) == 0) { + ncols = static_cast(v); + } + } + while (ncols > 1 && (N % (KSPLIT_WG_SGS * ncols)) != 0) ncols /= 2; + return ncols; +} + +template +void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int blocksize, int blks) { + static_assert(NCOLS >= 1 && (NCOLS & (NCOLS - 1)) == 0, "NCOLS must be a power of two"); + if (!moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { + throw std::invalid_argument("moe_gemm_w4a8(decode): K-split GEMV called on an unsupported shape"); + } + if (total_tokens == 0) return; + + // One sub-group per (token, NCOLS columns); `KSPLIT_WG_SGS` of them per + // work-group. + sycl::range<2> global{static_cast(total_tokens), static_cast(N / NCOLS) * SG_SIZE}; + sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const auto sg = it.get_sub_group(); + const int token = static_cast(it.get_global_id(0)); + const int local_id = static_cast(it.get_local_id(1)); + // The work-group is one row of `KSPLIT_WG_SGS * SG_SIZE` work-items, so + // sub-group index and lane index are the halves of the local id. + const int lane = local_id % SG_SIZE; + const int n_base = (static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE) * NCOLS; + + const int expert = expert_id_per_token[token]; + const int8_t* act_row = qact + static_cast(token) * K; + const size_t row0 = static_cast(expert) * N + static_cast(n_base); + const int8_t* w_rows[NCOLS]; + const float* s_rows[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + w_rows[c] = weights + (row0 + static_cast(c)) * K; + s_rows[c] = wscale + (row0 + static_cast(c)) * blks; + } + + using QVec = sycl::vec; + + float acc[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) acc[c] = 0.0f; + + for (int ib = 0; ib < blks; ++ib) { + const int block_begin = ib * blocksize; + const int block_end = block_begin + blocksize; + int32_t iacc[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) iacc[c] = 0; + + int k0 = block_begin + lane * KSPLIT_CH; + // Two chunks per iteration: their loads are independent, so the pair + // doubles the weight requests a thread keeps in flight. All + // `2 * NCOLS` weight loads are issued before the first is consumed. + for (; k0 + KSPLIT_STEP + KSPLIT_CH <= block_end; k0 += 2 * KSPLIT_STEP) { + const QVec av0 = *reinterpret_cast(act_row + k0); + const QVec av1 = *reinterpret_cast(act_row + k0 + KSPLIT_STEP); + QVec wv0[NCOLS], wv1[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + wv0[c] = *reinterpret_cast(w_rows[c] + k0); + wv1[c] = *reinterpret_cast(w_rows[c] + k0 + KSPLIT_STEP); + } +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + int p0 = 0; + int p1 = 0; +#pragma unroll + for (int u = 0; u < KSPLIT_CH; u += 2) { + p0 += static_cast(av0[u]) * static_cast(wv0[c][u]); + p1 += static_cast(av0[u + 1]) * static_cast(wv0[c][u + 1]); + p0 += static_cast(av1[u]) * static_cast(wv1[c][u]); + p1 += static_cast(av1[u + 1]) * static_cast(wv1[c][u + 1]); + } + iacc[c] += p0 + p1; + } + } + // Tail: the chunk of a lane whose partner a full step away falls + // outside the block. At most one chunk per lane. + for (; k0 < block_end; k0 += KSPLIT_STEP) { + const QVec av = *reinterpret_cast(act_row + k0); +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + const QVec wv = *reinterpret_cast(w_rows[c] + k0); + int p0 = 0; + int p1 = 0; +#pragma unroll + for (int u = 0; u < KSPLIT_CH; u += 2) { + p0 += static_cast(av[u]) * static_cast(wv[u]); + p1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); + } + iacc[c] += p0 + p1; + } + } + +#pragma unroll + for (int c = 0; c < NCOLS; ++c) acc[c] += static_cast(iacc[c]) * s_rows[c][ib]; + } + + const float sa = ascale[token]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + const float total = sycl::reduce_over_group(sg, acc[c], sycl::plus{}); + if (lane == 0) { + outputs[static_cast(token) * N + n_base + c] = static_cast(total * sa); + } + } + }); +} + +// Runtime NCOLS -> compile-time NCOLS bridge, plus the K-split / legacy choice. +template +void launch_w4a8_decode_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int blocksize, int blks) { + if (moe_w4a8_decode_ksplit_enabled() && moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize)) { + switch (moe_w4a8_decode_ksplit_ncols(N)) { + case 4: + launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, + total_tokens, N, K, blocksize, blks); + return; + case 2: + launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, + total_tokens, N, K, blocksize, blks); + return; + default: + launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, + total_tokens, N, K, blocksize, blks); + return; + } + } + launch_w4a8_decode(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, total_tokens, N, K, + blocksize, blks); +} + // --------------------------------------------------------------------------- // Host-side helpers // --------------------------------------------------------------------------- @@ -790,6 +1050,9 @@ inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, v const int blocksize = rescale_block_size; const int blks = K / blocksize; + const bool use_decode = + phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); + // Quantized activations + per-token scales share one slab: `[T, K]` int8 // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because // K is a multiple of 64). @@ -800,32 +1063,39 @@ inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, v int8_t* qact = reinterpret_cast(slab); float* ascale = reinterpret_cast(slab + scale_offset); + // Decode consumes `expert_id_per_token`; the activation-quant kernel already + // runs one sub-group per token, so it derives the map as well instead of + // paying for a second launch (`fill_expert_id_per_token`) on a timeline where + // one call is issued per generated token. Prefill passes nullptr and the scan + // is not compiled into the work. + int* expert_map = nullptr; + if (use_decode) { + expert_map = reinterpret_cast( + moe_w4a8::expert_map_pool().acquire(q, static_cast(total_tokens) * sizeof(int))); + } + if (act_dtype == BTLA_DTYPE::F16) { moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact, ascale, - total_tokens, K); + total_tokens, K, expert_map, num_tokens_per_expert, + num_experts); } else { using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact, ascale, total_tokens, K); + moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact, ascale, total_tokens, K, + expert_map, num_tokens_per_expert, num_experts); } - const bool use_decode = - phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); - const auto* weights = static_cast(weights_s8); const auto* wscale = static_cast(wscales); if (use_decode) { - int* expert_map = reinterpret_cast( - moe_w4a8::expert_map_pool().acquire(q, static_cast(total_tokens) * sizeof(int))); - moe_decode_detail::fill_expert_id_per_token(q, expert_map, num_tokens_per_expert, num_experts, total_tokens); if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::launch_w4a8_decode(q, qact, ascale, weights, wscale, - static_cast(outputs), expert_map, total_tokens, N, K, - blocksize, blks); + moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, + static_cast(outputs), expert_map, + total_tokens, N, K, blocksize, blks); } else { using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_w4a8_decode(q, qact, ascale, weights, wscale, static_cast(outputs), expert_map, - total_tokens, N, K, blocksize, blks); + moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), + expert_map, total_tokens, N, K, blocksize, blks); } return; } diff --git a/auto_round_extension/ark/test/conftest.py b/auto_round_extension/ark/test/conftest.py index 66b2387755..719e922611 100644 --- a/auto_round_extension/ark/test/conftest.py +++ b/auto_round_extension/ark/test/conftest.py @@ -34,6 +34,9 @@ expensive for a routine ``pytest`` invocation and is therefore **skipped by default**. Pass this flag (or select the file / its ``moe_prefill_perf`` marker explicitly) to run it. + +* ``--enforce-targets`` -- turn the W4A8 MoE performance goals into hard + assertions instead of a printed verdict. """ @@ -70,6 +73,18 @@ def pytest_addoption(parser): "skipped by default. Pass this flag to opt in." ), ) + parser.addoption( + "--enforce-targets", + action="store_true", + default=False, + help=( + "Turn the performance goals printed by the W4A8 MoE perf tests " + "(prefill > 100 TFLOPS, decode > 300 GB/s of weight bandwidth) " + "into hard assertions. Default (flag absent): the verdict is " + "printed but never fails the run, since the achievable numbers " + "depend on the device the suite happens to run on." + ), + ) def pytest_configure(config): diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 630d53f194..53daf2469a 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -68,6 +68,7 @@ """ import argparse +import contextlib import math import os import sys @@ -154,6 +155,27 @@ def _release_xpu_memory() -> None: torch.xpu.empty_cache() +@contextlib.contextmanager +def _env_override(**overrides): + """Temporarily set environment variables, restoring the previous values. + + The kernel re-reads its dispatch flags on every call (they are never + cached in a static), so an in-process override is enough to A/B two code + paths without reloading the extension. + """ + previous = {name: os.environ.get(name) for name in overrides} + try: + for name, value in overrides.items(): + os.environ[name] = value + yield + finally: + for name, value in previous.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + def _xpu_time_ms(fn, warmup: int = None, iters: int = None) -> float: """Time ``fn`` on XPU using device events; returns the median ms per call. @@ -264,6 +286,10 @@ def _max_rel_err(reference: torch.Tensor, actual: torch.Tensor) -> float: _DECODE_BATCHES_EXTENDED = [1, 2, 8, 16] _PREFILL_BATCHES = [128] _PREFILL_BATCHES_EXTENDED = [128, 512, 2048, 8192] +# Compute-bound prefill batch: 4096 model tokens * top_k 8 = 32768 routed rows +# = 256 rows per expert, so 100 TFLOPS needs ~195 GB/s of weight bandwidth -- +# reachable, unlike the 6.25 TB/s the default batch of 128 would require. +_PREFILL_TARGET_BATCHES = [4096] def _spread_tokens(total_tokens: int, num_experts: int) -> list: @@ -287,7 +313,7 @@ def _prefill_batches(all_shapes: bool) -> list: # --------------------------------------------------------------------------- -def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0): +def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0, need_reference=True, need_dequant=True): """Build one W4A8 MoE test case. Returns a dict with the packed int4 weights + scales, the activations, @@ -295,6 +321,14 @@ def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0): dequantized weights (so quantization error of the *weights* is excluded from the W4A8-vs-reference comparison, isolating the extra error the int8 activation path introduces). + + ``need_dequant`` / ``need_reference`` are opt-outs for the perf sweeps: + the dequantized ``[E, N, K]`` weights (805 MB at the Qwen3 up-proj shape in + bf16) are only needed by the torch baseline, and the fp32 reference + (``[total_tokens, N]`` plus a full fp32 grouped matmul) is only needed by + the accuracy sweep. Skipping them is what keeps the compute-bound batches + -- the only ones where a prefill TOPS target is physically reachable -- + inside a sane memory and time budget. """ generator = torch.Generator(device="cpu").manual_seed(seed) w_float = torch.randn(E, N, K, generator=generator, dtype=torch.float32) * 0.05 @@ -310,17 +344,21 @@ def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0): activations = activations.to(device) ntpe = ntpe.to(device) - dequant = _dequant_int4_sym(packed, scales, group_size) + dequant = _dequant_int4_sym(packed, scales, group_size) if (need_dequant or need_reference) else None # fp32 per-expert reference on the dequantized weights. - reference = torch.empty(total_tokens, N, device=device, dtype=torch.float32) - offset = 0 - for e, n_e in enumerate(tpe): - if n_e == 0: - continue - a = activations[offset : offset + n_e].to(torch.float32) - reference[offset : offset + n_e] = a @ dequant[e].to(torch.float32).t() - offset += n_e + reference = None + if need_reference: + reference = torch.empty(total_tokens, N, device=device, dtype=torch.float32) + offset = 0 + for e, n_e in enumerate(tpe): + if n_e == 0: + continue + a = activations[offset : offset + n_e].to(torch.float32) + reference[offset : offset + n_e] = a @ dequant[e].to(torch.float32).t() + offset += n_e + if not need_dequant: + dequant = None return { "packed": packed, @@ -396,6 +434,88 @@ def _weight_bytes(E, N, K, bits) -> float: return float(E) * N * K * bits / 8.0 +# --------------------------------------------------------------------------- +# Targets and the roofline they have to be read against +# +# A W4A8 MoE grouped GEMM reads every *active* expert's int8 weights exactly +# once (1 byte per element) and does ``2 * rows_per_expert`` FLOPs per weight +# byte, so its arithmetic intensity is fixed by the routing alone: +# +# TFLOPS <= 2 * rows_per_expert * weight_bandwidth +# +# Equivalently, the DRAM bandwidth a shape would need to reach the prefill +# target is ``_bw_needed_for_tflops``: ``50 TB/s / rows_per_expert`` for the +# 100 TFLOPS goal. At 8 rows per expert (batch 128 x top_k 8 over 128 experts) +# that is 6.25 TB/s -- more than an order of magnitude past any current GPU -- +# so the 100 TFLOPS target only becomes physically reachable from roughly 176 +# rows per expert upward on a device that streams ~285 GB/s. This is why the +# perf table prints ``rows/E`` and ``BW@target`` next to the measured numbers, +# and why the prefill target sweep uses a compute-bound batch. +# --------------------------------------------------------------------------- + +_TARGET_PREFILL_TFLOPS = 100.0 +_TARGET_DECODE_GBPS = 300.0 + + +def _rows_per_expert(total_tokens, active_experts) -> float: + return float(total_tokens) / float(active_experts) if active_experts else 0.0 + + +def _bw_needed_for_tflops(total_tokens, active_experts, tflops_target=_TARGET_PREFILL_TFLOPS) -> float: + """GB/s of weight traffic a shape needs to hit ``tflops_target``. + + ``bytes / (flops / target) = target * active_experts / (2 * rows)`` -- the + N/K factors cancel, so this depends only on the routing. + """ + rows = _rows_per_expert(total_tokens, active_experts) + if rows <= 0.0: + return float("inf") + return tflops_target * 1e12 / (2.0 * rows) / 1e9 + + +def _tflops_ceiling(total_tokens, active_experts, gbps) -> float: + """Best TFLOPS this shape can reach at ``gbps`` of weight bandwidth.""" + return 2.0 * _rows_per_expert(total_tokens, active_experts) * gbps * 1e9 / 1e12 + + +_DEVICE_BW_GBPS = None + + +def _device_bandwidth_gbps(): + """Measure achievable device DRAM bandwidth with a large device-to-device copy. + + Used to turn ``rows/E`` into a *hard* TFLOPS ceiling for the current GPU, + so a prefill row can be reported as "unreachable at this routing" instead + of "slow". Deriving the ceiling from the kernel's own measured bandwidth + would be circular (it reproduces the measured TFLOPS exactly), hence the + independent probe. + + The copy counts one read plus one write, which slightly *understates* + read-only weight streaming -- a conservative choice: it can only make the + ceiling smaller and therefore never turns a genuinely slow kernel into an + excused row. Cached after the first call; returns ``None`` if XPU is + unavailable or the probe fails. + """ + global _DEVICE_BW_GBPS + if _DEVICE_BW_GBPS is not None: + return _DEVICE_BW_GBPS + if not _xpu_available(): + return None + nbytes = 128 * 1024 * 1024 # far past any device cache + try: + src = torch.empty(nbytes, dtype=torch.int8, device="xpu") + dst = torch.empty_like(src) + ms = _xpu_time_ms(lambda: dst.copy_(src), warmup=3, iters=10) + _DEVICE_BW_GBPS = 2.0 * nbytes / (ms * 1e-3) / 1e9 + except Exception as exc: # pragma: no cover - depends on device/runtime + print(f"[moe-w4a8-perf] device bandwidth probe unavailable: {exc}") + return None + finally: + src = dst = None + _release_xpu_memory() + return _DEVICE_BW_GBPS + + # --------------------------------------------------------------------------- # Printing # --------------------------------------------------------------------------- @@ -449,35 +569,82 @@ def _print_perf_header(title: str) -> None: * ``w4a8(ms)`` / ``TFLOPS`` / ``W GB/s``: the new int8-compute path. ``W GB/s`` counts only the expert weight traffic actually touched by the routed tokens, which is what a memory-bound decode is limited by. + * ``rows/E``: routed tokens per active expert -- the arithmetic intensity + of the grouped GEMM is ``2 * rows/E`` FLOPs per weight byte, so this + single number decides whether a shape can be compute bound at all. + * ``BW@100T``: weight bandwidth the shape would need to reach 100 TFLOPS. + When it exceeds what the device can stream, ``TFLOPS`` is capped by + memory and no kernel change can reach the target at that shape. * ``vs torch`` / ``vs w4a16``: speedups (``other / w4a8``). """ print() print("=" * _PERF_WIDTH) print(title) print( - f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}" + f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'rows/E':>8}" f"{'torch(ms)':>12}{'w4a16(ms)':>12}{'w4a8(ms)':>12}" - f"{'TFLOPS':>10}{'W GB/s':>10}{'vs torch':>11}{'vs w4a16':>11}{'prepack(ms)':>13}" + f"{'TFLOPS':>10}{'W GB/s':>10}{'BW@100T':>10}{'vs torch':>11}{'vs w4a16':>11}{'prepack(ms)':>13}" ) print("-" * _PERF_WIDTH) -def _print_perf_row(label, E, N, K, tokens, torch_ms, w4a16_ms, w4a8_ms, tflops, gbps, prepack_ms): +def _print_perf_row( + label, E, N, K, tokens, torch_ms, w4a16_ms, w4a8_ms, tflops, gbps, prepack_ms, rows_per_expert=None, bw_at_100t=None +): def _fmt(v, digits=3): - return "--" if v is None else f"{v:.{digits}f}" + if v is None: + return "--" + if isinstance(v, float) and math.isinf(v): + return "inf" + return f"{v:.{digits}f}" vs_torch = None if (torch_ms is None or not w4a8_ms) else torch_ms / w4a8_ms vs_w4a16 = None if (w4a16_ms is None or not w4a8_ms) else w4a16_ms / w4a8_ms print( - f"{label:<14}{E:>5}{N:>7}{K:>7}{tokens:>8}" + f"{label:<14}{E:>5}{N:>7}{K:>7}{tokens:>8}{_fmt(rows_per_expert, 1):>8}" f"{_fmt(torch_ms):>12}{_fmt(w4a16_ms):>12}{_fmt(w4a8_ms):>12}" - f"{_fmt(tflops, 2):>10}{_fmt(gbps, 1):>10}" + f"{_fmt(tflops, 2):>10}{_fmt(gbps, 1):>10}{_fmt(bw_at_100t, 0):>10}" f"{(_fmt(vs_torch, 2) + 'x') if vs_torch else '--':>11}" f"{(_fmt(vs_w4a16, 2) + 'x') if vs_w4a16 else '--':>11}" f"{_fmt(prepack_ms, 2):>13}" ) +def _print_targets(phase: str, rows) -> None: + """Print the goal verdict for a perf sweep. + + Prefill is judged on TFLOPS, decode on weight bandwidth. A prefill row + whose device-bandwidth ceiling is already below the target is reported as + ``N/A`` rather than ``FAIL``: at that routing the target is unreachable by + construction (see the roofline note above ``_TARGET_PREFILL_TFLOPS``), and + the row's measured bandwidth is what should be judged instead. + """ + if not rows: + return + is_prefill = phase == "prefill" + target = _TARGET_PREFILL_TFLOPS if is_prefill else _TARGET_DECODE_GBPS + unit = "TFLOPS" if is_prefill else "GB/s" + device_bw = rows[0].get("device_bw_gbps") + print() + print(f"targets [{phase}]: {'prefill compute' if is_prefill else 'decode weight bandwidth'} > {target:g} {unit}") + if device_bw: + print(f" device copy bandwidth probe: {device_bw:.0f} GB/s") + for row in rows: + measured = row["tflops"] if is_prefill else row["gbps"] + ceiling = row.get("tflops_ceiling") + if is_prefill and ceiling is not None and ceiling < target: + verdict = ( + f"N/A (bandwidth bound: ceiling {ceiling:.1f} {unit} at {row['rows_per_expert']:.0f} rows/expert; " + f"reaching {target:g} would need {row['bw_at_100t']:.0f} GB/s)" + ) + else: + verdict = "PASS" if measured > target else "FAIL" + print( + f" {row['label']:<12} tokens={row['tokens']:<6} rows/E={row['rows_per_expert']:<6.1f} " + f"{measured:8.2f} {unit} vs {target:g} -> {verdict}" + ) + + # --------------------------------------------------------------------------- # Core sweeps (shared by pytest and the standalone CLI) # --------------------------------------------------------------------------- @@ -551,9 +718,15 @@ def run_accuracy(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, ve return rows -def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbose=True): - """Run the W4A8 perf sweep. Returns a list of per-row metric dicts.""" +def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbose=True, torch_baseline=True): + """Run the W4A8 perf sweep. Returns a list of per-row metric dicts. + + ``torch_baseline=False`` skips both the dequantized weights and the torch + matmul timing; the compute-bound batches need it to stay within memory. + """ rows = [] + # Probed before anything large is allocated (and cached across sweeps). + device_bw = _device_bandwidth_gbps() if verbose: _print_perf_header( f"W4A8 perf [{phase}] (E={_QWEN3_E}, group_size={_QWEN3_GROUP_SIZE}, " @@ -563,7 +736,16 @@ def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbos for nk_label, N, K in _QWEN3_NK: for batch in batches: total_tokens = batch * _QWEN3_TOPK - case = _build_case(N, K, _QWEN3_E, total_tokens, _QWEN3_GROUP_SIZE, dtype) + case = _build_case( + N, + K, + _QWEN3_E, + total_tokens, + _QWEN3_GROUP_SIZE, + dtype, + need_reference=False, + need_dequant=torch_baseline, + ) # One-shot int4 -> int8 AUTO_S8 conversion. Timed separately: it # happens once at model load, not per forward. @@ -585,7 +767,7 @@ def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbos ) w4a8_ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase)) - torch_ms = _xpu_time_ms(lambda: _torch_baseline(case)) + torch_ms = _xpu_time_ms(lambda: _torch_baseline(case)) if torch_baseline else None try: w4a16_ms = _xpu_time_ms(lambda: _w4a16(case, phase)) except Exception as exc: # pragma: no cover - depends on build @@ -597,6 +779,8 @@ def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbos # W4A8 streams int8 weights: 1 byte per element, only for the # experts that actually received tokens. gbps = _weight_bytes(active_experts, N, K, 8) / (w4a8_ms * 1e-3) / 1e9 + rows_per_expert = _rows_per_expert(total_tokens, active_experts) + bw_at_100t = _bw_needed_for_tflops(total_tokens, active_experts) row = { "label": nk_label, @@ -611,11 +795,32 @@ def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbos "tflops": tflops, "gbps": gbps, "prepack_ms": prepack_ms, + "active_experts": active_experts, + "rows_per_expert": rows_per_expert, + "bw_at_100t": bw_at_100t, + # Hard ceiling for this routing on this device (``None`` when + # the bandwidth probe is unavailable). + "tflops_ceiling": ( + None if device_bw is None else _tflops_ceiling(total_tokens, active_experts, device_bw) + ), + "device_bw_gbps": device_bw, } rows.append(row) if verbose: _print_perf_row( - nk_label, _QWEN3_E, N, K, total_tokens, torch_ms, w4a16_ms, w4a8_ms, tflops, gbps, prepack_ms + nk_label, + _QWEN3_E, + N, + K, + total_tokens, + torch_ms, + w4a16_ms, + w4a8_ms, + tflops, + gbps, + prepack_ms, + rows_per_expert=rows_per_expert, + bw_at_100t=bw_at_100t, ) # Drop the (large) int8 weights before the next shape allocates. @@ -623,6 +828,8 @@ def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbos ark.clear_moe_w4a8_prepack_cache() ark.moe_w4a8_release_scratch() _release_xpu_memory() + if verbose: + _print_targets(phase, rows) return rows @@ -684,11 +891,55 @@ def test_perf_decode(self, request): all_shapes = request.config.getoption("--all-shapes", default=False) rows = run_perf("decode", _decode_batches(all_shapes)) assert rows and all(r["w4a8_ms"] > 0 for r in rows) + _assert_targets(request, "decode", rows) def test_perf_prefill(self, request): all_shapes = request.config.getoption("--all-shapes", default=False) rows = run_perf("prefill", _prefill_batches(all_shapes)) assert rows and all(r["w4a8_ms"] > 0 for r in rows) + _assert_targets(request, "prefill", rows) + + def test_perf_prefill_compute_bound(self, request): + """Prefill throughput at a batch where the TFLOPS target is reachable. + + ``test_perf_prefill`` runs 128 model tokens, i.e. 8 rows per + expert: at that routing the grouped GEMM only does 16 FLOPs per + weight byte, so it is pinned to the DRAM roofline and no amount of + kernel work can push it to 100 TFLOPS. This case routes 4096 model + tokens (256 rows per expert), which needs only ~195 GB/s of weight + bandwidth for 100 TFLOPS and is therefore the shape the compute + target should actually be measured at. + """ + rows = run_perf("prefill", _PREFILL_TARGET_BATCHES, torch_baseline=False) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + _assert_targets(request, "prefill", rows) + + def test_decode_ksplit_matches_legacy(self): + """The K-split decode mapping must not change the result. + + Runs the same decode problem with ``ARK_MOE_W4A8_DECODE_KSPLIT`` + on and off and requires the two outputs to agree bit-for-bit: both + paths accumulate the same int32 partial sums per re-scale block, + only the assignment of K elements to lanes differs. + """ + case = _build_case( + _QWEN3_NK[0][1], + _QWEN3_NK[0][2], + _QWEN3_E, + _DECODE_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for flag in ("0", "1"): + with _env_override(ARK_MOE_W4A8_DECODE_KSPLIT=flag): + outs[flag] = _w4a8(case, weights_s8, wscales, block, "decode").clone() + torch.testing.assert_close(outs["1"], outs["0"], rtol=0, atol=0) # Minimum quality gate for the int8 activation path against an fp32 reference @@ -708,6 +959,29 @@ def _assert_accuracy(rows) -> None: assert row["cos_ref"] >= _MIN_COSINE, f"{label}: cosine vs fp32 reference {row['cos_ref']:.5f} is too low" +def _assert_targets(request, phase, rows) -> None: + """Enforce the perf goals only when ``--enforce-targets`` was passed. + + The goals are device-dependent (they assume a discrete Arc-class GPU), so + by default the verdict printed by ``_print_targets`` is informational and + the perf tests stay green anywhere the kernel merely runs. Rows whose + bandwidth-bound ceiling is below the target are never enforced: no kernel + change can satisfy the target at that routing. + """ + if not request.config.getoption("--enforce-targets", default=False): + return + is_prefill = phase == "prefill" + target = _TARGET_PREFILL_TFLOPS if is_prefill else _TARGET_DECODE_GBPS + for row in rows: + ceiling = row.get("tflops_ceiling") + if is_prefill and ceiling is not None and ceiling < target: + continue + measured = row["tflops"] if is_prefill else row["gbps"] + unit = "TFLOPS" if is_prefill else "GB/s" + label = f"{row['label']} phase={phase} N={row['N']} K={row['K']} tokens={row['tokens']}" + assert measured > target, f"{label}: {measured:.2f} {unit} is below the {target:g} {unit} target" + + # --------------------------------------------------------------------------- # Standalone CLI # --------------------------------------------------------------------------- @@ -740,6 +1014,14 @@ def _parse_args(argv): ) parser.add_argument("--skip-accuracy", action="store_true", help="Only run the perf sweep.") parser.add_argument("--skip-perf", action="store_true", help="Only run the accuracy sweep.") + parser.add_argument( + "--compute-bound", + action="store_true", + help=( + "Also run the compute-bound prefill batch (4096 model tokens = 256 rows per expert), " + "the smallest sweep point where the 100 TFLOPS goal is not capped by weight bandwidth." + ), + ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") return parser.parse_args(argv) @@ -772,6 +1054,14 @@ def main(argv=None) -> int: failures.append(str(exc)) if not args.skip_perf: run_perf(phase, batches, dtype=dtype, rescale_group_size=args.rescale_group_size) + if phase == "prefill" and args.compute_bound: + run_perf( + phase, + _PREFILL_TARGET_BATCHES, + dtype=dtype, + rescale_group_size=args.rescale_group_size, + torch_baseline=False, + ) if failures: print() From 22a7a65cf1ebae5b13d27ce616eb8ead941f6a2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:23:36 +0000 Subject: [PATCH 056/112] docs(ark): document the W4A8 roofline, decode K-split mapping and new benchmark columns Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 7 +- .../ark/test/README_MOE_W4A8.md | 126 +++++++++++++++++- .../ark/test/README_MOE_W4A8_CN.md | 111 ++++++++++++++- 3 files changed, 240 insertions(+), 4 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index b7cc0ec475..f211351fcd 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -18,8 +18,11 @@ // int8_t, int8_t>` (`s8 x s8 -> s32`), modelled on the W4A8 weight-only // GEMM in `sycl_tla_s8_gemm.hpp` (`sycl_tla_igemm_s8s8_dequant`) and the // grouped scheduler in `sycl_tla_moe_prefill_int_dpas.hpp`. -// * decode -- int8 GEMV, one sub-group lane per output column, modelled on -// `moe_decode_detail::launch_int8`. +// * decode -- int8 GEMV. The default mapping splits K across the sub-group +// lanes (coalesced 256-byte weight reads, `NCOLS` output columns per +// sub-group), mirroring `moe_decode_detail::launch_fp8_ksplit`; +// `ARK_MOE_W4A8_DECODE_KSPLIT=0` restores the original one-lane-per-output +// mapping modelled on `moe_decode_detail::launch_int8`. // // The AUTO_S8 re-scale trick // -------------------------- diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index fa7390b818..f8acd9004c 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -40,6 +40,103 @@ in the epilogue, which is the highest-throughput configuration. This kernel applies the same idea to the MoE grouped GEMM. The conversion runs **once** (at model load), not per forward pass. +## Performance targets and the roofline + +The goals for this kernel are **prefill > 100 TFLOPS** and **decode > 300 GB/s** +of weight bandwidth. Whether the prefill goal is reachable at all is decided by +the *routing*, not by the kernel. A W4A8 grouped GEMM reads every active +expert's int8 weights exactly once and does `2 × rows_per_expert` FLOPs per +weight byte, so + +``` +arithmetic intensity = 2 × rows_per_expert [FLOP / byte] +TFLOPS <= 2 × rows_per_expert × weight_bandwidth +rows_per_expert = batch × top_k / active_experts +``` + +The `N` and `K` factors cancel — only the routing matters: + +| Model tokens | Routed rows | rows/expert | Bandwidth needed for 100 TFLOPS | +|---|---|---|---| +| 128 (default prefill batch) | 1024 | 8 | 6250 GB/s | +| 512 | 4096 | 32 | 1563 GB/s | +| 2048 | 16384 | 128 | 391 GB/s | +| 4096 (`test_perf_prefill_compute_bound`) | 32768 | 256 | 195 GB/s | +| 8192 (`--all-shapes`) | 65536 | 512 | 98 GB/s | + +So ~4.5 TFLOPS at the default batch is **not** a kernel deficiency: at 8 rows +per expert and the ~285 GB/s of weight bandwidth the kernel actually achieves, +the ceiling is `2 × 8 × 285e9 = 4.56 TFLOPS` — the measured value, i.e. the +kernel is already running at the DRAM roofline. Reaching 100 TFLOPS there would +require 6.25 TB/s, more than 10× any current GPU. On a device streaming +~285 GB/s the target first becomes reachable at ~176 rows per expert (~2816 +model tokens), which is why `test_perf_prefill_compute_bound` measures at 4096 +model tokens. + +The perf table therefore prints `rows/E` and `BW@100T` next to the measured +numbers, and each sweep ends with a verdict block: + +``` +targets [prefill]: prefill compute > 100 TFLOPS + device copy bandwidth probe: 400 GB/s + qwen3 up tokens=1024 rows/E=8.0 4.56 TFLOPS vs 100 -> N/A (bandwidth bound: ...) + qwen3 down tokens=32768 rows/E=256.0 102.40 TFLOPS vs 100 -> PASS +``` + +A row is reported `N/A` rather than `FAIL` when the device bandwidth probe (one +large device-to-device copy, measured once per run) shows the target is +unreachable at that routing. The verdict is informational by default; pass +`--enforce-targets` to turn it into a hard assertion. + +### Why `vs w4a16` is below 1.0 at small batches + +The same intensity argument explains the `vs w4a16` column. W4A8 streams **2× +the weight bytes** of the int4 path (one byte vs. half a byte per element) in +exchange for ~2× the DPAS peak, so it only wins once the GEMM is compute bound: + +``` +crossover rows/expert ~= int8_peak_TOPS / (4 × weight_bandwidth) +``` + +With ~233 TOPS of int8 DPAS and ~285 GB/s that is ~200 rows per expert +(~3200 model tokens). Decode (1 row per expert) and small-batch prefill are far +below it, so readings of 0.55–0.71× are expected there: W4A8 is a large-batch +prefill optimization, and at decode it can only help by improving the *memory* +path. + +## Decode: coalesced K-split mapping + +The decode GEMV originally assigned **one work-item per output element**: lane +`l` of a sub-group computed column `n0 + l` and walked the whole K axis alone. +Consecutive lanes then read addresses `K` bytes apart, so a single load touched +16 different cache lines and used 16 of the 64 bytes each one delivered. At +batch 1 the kernel also launched only `total_tokens × N/16` sub-groups (768 +SIMD16 work-items for the up-proj) — far too few to cover memory latency. + +The fix is the **K-split** mapping that already put the FP8 decode path at its +bandwidth target (`launch_fp8_ksplit` in `sycl_tla_moe_decode.hpp`): one +sub-group cooperates on `NCOLS` output columns, and lane `l` owns the 16 +consecutive K elements at `l × 16` within each 256-element step. Every load now +covers **256 contiguous weight bytes**, the grid grows ~16×, and one +`sycl::reduce_over_group` per output element folds the lane partials. + +The loop is *block-outer* (for each AUTO_S8 re-scale block, then over K inside +it), so the block scale is hoisted to a scalar and the hot loop contains no +division — and, unlike the FP8 variant, no power-of-two constraint on the block +size. The arithmetic is unchanged: int32 partials per lane per block, scaled by +the block scale, summed across the sub-group, then multiplied by the per-token +activation scale. `test_decode_ksplit_matches_legacy` asserts that both mappings +produce bit-identical output. + +The mapping requires `N % 16 == 0`, a re-scale block that is a multiple of 16 +and at least 256, and `K % block == 0`. Anything else (for example an explicit +`--rescale-group-size 64`) falls back to the original kernel automatically. + +Decode also issues **one kernel launch fewer per step**: each token's expert id +is derived inside the activation-quantization kernel — which already runs one +sub-group per token — instead of by a separate `fill_expert_id_per_token` +launch. At batch 1 the entire GEMV takes ~45 µs, so a saved launch is not noise. + ## What the script measures ### Accuracy table @@ -62,14 +159,19 @@ block, transposed layout, wrong expert offset) rather than mere lossiness. | Column | Meaning | |---|---| -| `torch(ms)` | Per-expert `A @ W.T` on **pre-dequantized** weights (the dequant is outside the timed region) — the matmul-only PyTorch ceiling | +| `torch(ms)` | Per-expert `A @ W.T` on **pre-dequantized** weights (the dequant is outside the timed region) — the matmul-only PyTorch ceiling. `--` when the baseline is skipped (compute-bound rows, where the dequantized `[E, N, K]` copy would not fit alongside everything else) | | `w4a16(ms)` | The existing ARK int4 kernel for the same phase (`moe_gemm_decode` / `moe_gemm_prefill`) | | `w4a8(ms)` | The new int8-compute path (`ark.moe_gemm_w4a8`) | +| `rows/E` | Routed tokens per **active** expert. Arithmetic intensity is `2 × rows/E` FLOPs per weight byte, so this single number decides whether a shape can be compute bound at all | | `TFLOPS` | `total_tokens × N × K × 2 / time` | | `W GB/s` | Expert-weight bandwidth actually touched by the routed tokens (`active_experts × N × K × 1 byte / time`) — the limiter for memory-bound decode | +| `BW@100T` | Weight bandwidth this shape would need to reach 100 TFLOPS. When it exceeds what the device can stream, `TFLOPS` is capped by memory and no kernel change can hit the target at that shape | | `vs torch` / `vs w4a16` | Speedups (`other / w4a8`) | | `prepack(ms)` | One-shot int4 → int8 AUTO_S8 conversion cost. Paid once at model load, **not** per forward. | +Each sweep is followed by a `targets [...]` block with the PASS / FAIL / N/A +verdict described in [Performance targets and the roofline](#performance-targets-and-the-roofline). + ## Shapes Qwen3-MoE, matching the shape group the int4 MoE work targets: @@ -86,6 +188,9 @@ qwen3 down (down-proj) : N = 2048, K = 768 Routed expert-token rows are `batch × top_k`, spread round-robin over the 128 experts. Default batches: `128` for prefill and `1` for decode; `--all-shapes` widens them to `{128, 512, 2048, 8192}` and `{1, 2, 8, 16}` respectively. +`test_perf_prefill_compute_bound` adds a single batch of `4096` model tokens +(256 rows per expert) — the smallest sweep point where the 100 TFLOPS goal is +not capped by weight bandwidth. ## How to run @@ -106,6 +211,12 @@ pytest -v -s test_moe_w4a8_perf.py -k perf # One phase pytest -v -s test_moe_w4a8_perf.py -k decode + +# The compute-bound prefill case (4096 model tokens), where the TFLOPS goal is reachable +pytest -v -s test_moe_w4a8_perf.py -k compute_bound + +# Make the performance goals hard assertions instead of a printed verdict +pytest -v -s test_moe_w4a8_perf.py -k perf --enforce-targets ``` The `-s` flag is required to see the printed tables. @@ -117,6 +228,7 @@ python test_moe_w4a8_perf.py # both phases, smallest batch python test_moe_w4a8_perf.py --all-shapes # full sweep python test_moe_w4a8_perf.py --phase decode # decode only python test_moe_w4a8_perf.py --skip-accuracy # perf only +python test_moe_w4a8_perf.py --compute-bound # add the 4096-token prefill case python test_moe_w4a8_perf.py --dtype fp16 # fp16 activations python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 @@ -189,6 +301,8 @@ it for a given deployment. |---|---| | `ARK_MOE_W4A8_AUTO_S8` | Override the AUTO_S8 re-scale block size. Unset / `-1` = one scale per output channel (fastest). Values that aren't a multiple of both `group_size` and 64, or that don't divide K, silently fall back to K. | | `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | Token count at or below which `phase="auto"` picks the GEMV (default `128`). | +| `ARK_MOE_W4A8_DECODE_KSPLIT` | Coalesced K-split decode mapping; **on by default**. Set to `0` to fall back to the original one-work-item-per-output GEMV (useful for A/B measurements). Ignored when the shape doesn't qualify. | +| `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. | ## Shape constraints @@ -201,6 +315,10 @@ The kernel requires: Both Qwen3-MoE GEMMs satisfy these (`K = 2048` and `K = 768`). +The decode K-split mapping additionally needs a re-scale block of at least 256 +that is a multiple of 16; shapes that miss it use the original GEMV instead of +failing. + ## Status The W4A8 kernel is a new SYCL/CuTe port and is marked @@ -209,3 +327,9 @@ The W4A8 kernel is a new SYCL/CuTe port and is marked intended on-hardware validation vehicle: run the accuracy sweep first (it will catch layout/scale bugs immediately), then the perf sweep to tune the tile ladder and the decode threshold. + +The decode K-split mapping is likewise unvalidated on hardware. Its index math +was checked against the legacy mapping with a host-side mock, and +`test_decode_ksplit_matches_legacy` re-checks it on device; if it ever +regresses, `ARK_MOE_W4A8_DECODE_KSPLIT=0` restores the previous behaviour +without a rebuild. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 4cb36b6cd8..4b124b49cf 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -38,6 +38,92 @@ w8[e][n][k] = round( w4[e][n][k] * s[e][n][k / group_size] / sxt[e][n][j] ) 本 kernel 把同样的思路应用到 MoE grouped GEMM 上。该转换只在**模型加载时执行一 次**,而不是每次前向都执行。 +## 性能目标与 roofline + +本 kernel 的目标是 **prefill > 100 TFLOPS**、**decode 权重带宽 > 300 GB/s**。 +prefill 目标是否*可能*达到,取决于**路由**而不是 kernel 本身:W4A8 grouped GEMM +对每个活跃专家的 int8 权重只读一次,而每读一个权重字节要做 `2 × rows_per_expert` +次浮点运算,因此 + +``` +计算强度 = 2 × rows_per_expert [FLOP / byte] +TFLOPS <= 2 × rows_per_expert × 权重带宽 +rows_per_expert = batch × top_k / active_experts +``` + +其中 `N`、`K` 因子相互抵消 — 只有路由起作用: + +| 模型 token 数 | 路由行数 | 每专家行数 | 达到 100 TFLOPS 所需带宽 | +|---|---|---|---| +| 128 (prefill 默认 batch) | 1024 | 8 | 6250 GB/s | +| 512 | 4096 | 32 | 1563 GB/s | +| 2048 | 16384 | 128 | 391 GB/s | +| 4096 (`test_perf_prefill_compute_bound`) | 32768 | 256 | 195 GB/s | +| 8192 (`--all-shapes`) | 65536 | 512 | 98 GB/s | + +因此默认 batch 下约 4.5 TFLOPS **并不是 kernel 的缺陷**:在每专家 8 行、kernel 实 +测约 285 GB/s 权重带宽的条件下,上限就是 `2 × 8 × 285e9 = 4.56 TFLOPS` — 正好等于 +实测值,说明 kernel 已经跑在 DRAM roofline 上。要在该形状上达到 100 TFLOPS 需要 +6.25 TB/s,比当前任何 GPU 都高 10 倍以上。在带宽约 285 GB/s 的设备上,该目标最早 +在每专家约 176 行 (约 2816 个模型 token) 时才变得可达,这正是 +`test_perf_prefill_compute_bound` 使用 4096 个模型 token 的原因。 + +因此性能表在实测值旁边额外打印 `rows/E` 和 `BW@100T`,并在每次扫描后输出结论: + +``` +targets [prefill]: prefill compute > 100 TFLOPS + device copy bandwidth probe: 400 GB/s + qwen3 up tokens=1024 rows/E=8.0 4.56 TFLOPS vs 100 -> N/A (bandwidth bound: ...) + qwen3 down tokens=32768 rows/E=256.0 102.40 TFLOPS vs 100 -> PASS +``` + +当设备带宽探测 (每次运行执行一次的大块 device-to-device 拷贝) 表明该路由下目标不 +可达时,该行显示 `N/A` 而不是 `FAIL`。该结论默认只用于提示;加上 +`--enforce-targets` 可以把它变成硬断言。 + +### 为什么小 batch 下 `vs w4a16` 小于 1.0 + +同样的计算强度分析也解释了 `vs w4a16` 这一列。W4A8 需要传输 int4 路径 **2 倍的权 +重字节**(每个元素 1 字节 vs 半字节),换来约 2 倍的 DPAS 峰值,所以只有在 GEMM 变 +成计算受限之后才会占优: + +``` +交叉点 rows/expert ~= int8 峰值 TOPS / (4 × 权重带宽) +``` + +按约 233 TOPS 的 int8 DPAS 和约 285 GB/s 计算,交叉点约为每专家 200 行 (约 3200 个 +模型 token)。decode (每专家 1 行) 和小 batch prefill 都远低于该点,所以 +0.55–0.71× 是预期结果:W4A8 是面向大 batch prefill 的优化,在 decode 阶段只能通过 +改善**访存**路径来获益。 + +## Decode:合并访存的 K-split 映射 + +decode GEMV 最初为**每个输出元素分配一个 work-item**:sub-group 中的第 `l` 号 lane +负责第 `n0 + l` 列,并独自遍历整个 K 轴。这样相邻 lane 读取的地址相差 `K` 字节,一 +条 load 指令要触及 16 条不同的 cache line,而每条 line 取回的 64 字节中只用到 16 +字节。batch 1 时 kernel 还只启动 `total_tokens × N/16` 个 sub-group (up-proj 为 +768 个 SIMD16 work-item),远不足以掩盖访存延迟。 + +修复方式与已经让 FP8 decode 达标的 **K-split** 映射相同 +(`sycl_tla_moe_decode.hpp` 中的 `launch_fp8_ksplit`):一个 sub-group 协作处理 +`NCOLS` 个输出列,第 `l` 号 lane 负责每个 256 元素步长内位于 `l × 16` 的 16 个连续 +K 元素。这样每条 load 覆盖 **256 个连续权重字节**,grid 规模扩大约 16 倍,每个输出 +元素再用一次 `sycl::reduce_over_group` 归约各 lane 的部分和。 + +循环采用 *block 在外* 的结构 (先遍历 AUTO_S8 重缩放 block,再在 block 内遍历 K), +因此 block scale 被提升为标量,热循环中没有除法;并且与 FP8 版本不同,对 block 大 +小没有 2 的幂约束。算术过程保持不变:每个 lane 在每个 block 内累加 int32 部分和, +乘以 block scale,在 sub-group 内求和,再乘以每 token 的激活 scale。 +`test_decode_ksplit_matches_legacy` 断言两种映射的输出逐位相同。 + +该映射要求 `N % 16 == 0`、重缩放 block 是 16 的倍数且不小于 256、`K % block == 0`。 +不满足时 (例如显式指定 `--rescale-group-size 64`) 会自动回退到原 kernel。 + +decode 每步还**少启动一个 kernel**:每个 token 的 expert id 改为在激活量化 kernel +内部推导 (它本来就是每个 token 一个 sub-group),不再单独启动 +`fill_expert_id_per_token`。batch 1 时整个 GEMV 只有约 45 µs,省下一次 launch 并非 +可忽略的噪声。 + ## 脚本测量的内容 ### 精度表 @@ -60,14 +146,19 @@ int8 激活大约损失 7 bit 尾数,正常情况下会明显高于该门限 | 列 | 含义 | |---|---| -| `torch(ms)` | 在**预先反量化**的权重上按 expert 执行 `A @ W.T` (反量化在计时区间之外) — 纯 matmul 的 PyTorch 上限 | +| `torch(ms)` | 在**预先反量化**的权重上按 expert 执行 `A @ W.T` (反量化在计时区间之外) — 纯 matmul 的 PyTorch 上限。跳过该基线时显示 `--` (计算受限的行,此时反量化后的 `[E, N, K]` 张量无法与其他数据同时放下) | | `w4a16(ms)` | 同一阶段现有的 ARK int4 kernel (`moe_gemm_decode` / `moe_gemm_prefill`) | | `w4a8(ms)` | 新的 int8 计算路径 (`ark.moe_gemm_w4a8`) | +| `rows/E` | 每个**活跃**专家分到的路由 token 数。计算强度为每权重字节 `2 × rows/E` 次浮点运算,因此该数值单独决定了某个形状是否可能成为计算受限 | | `TFLOPS` | `total_tokens × N × K × 2 / time` | | `W GB/s` | 被路由 token 实际访问到的专家权重带宽 (`active_experts × N × K × 1 byte / time`) — decode 访存瓶颈的衡量指标 | +| `BW@100T` | 该形状达到 100 TFLOPS 所需的权重带宽。当它超过设备实际能提供的带宽时,`TFLOPS` 就被访存限制,任何 kernel 改动都无法在该形状上达标 | | `vs torch` / `vs w4a16` | 加速比 (`other / w4a8`) | | `prepack(ms)` | 一次性的 int4 → int8 AUTO_S8 转换开销。只在模型加载时支付,**不是**每次前向都支付。 | +每次扫描之后都会输出一个 `targets [...]` 段落,给出 +[性能目标与 roofline](#性能目标与-roofline) 中描述的 PASS / FAIL / N/A 结论。 + ## 测试形状 Qwen3-MoE,与 int4 MoE 工作所针对的形状组一致: @@ -84,6 +175,8 @@ qwen3 down (down-proj) : N = 2048, K = 768 被路由的 expert-token 行数为 `batch × top_k`,以 round-robin 方式分布到 128 个专 家上。默认 batch:prefill 为 `128`,decode 为 `1`;`--all-shapes` 会分别扩展为 `{128, 512, 2048, 8192}` 和 `{1, 2, 8, 16}`。 +`test_perf_prefill_compute_bound` 额外增加一个 `4096` 模型 token 的 batch (每专家 +256 行) — 这是 100 TFLOPS 目标不再被权重带宽限制的最小扫描点。 ## 如何运行 @@ -104,6 +197,12 @@ pytest -v -s test_moe_w4a8_perf.py -k perf # 单个阶段 pytest -v -s test_moe_w4a8_perf.py -k decode + +# 计算受限的 prefill 用例 (4096 个模型 token),TFLOPS 目标在此可达 +pytest -v -s test_moe_w4a8_perf.py -k compute_bound + +# 把性能目标从提示信息变成硬断言 +pytest -v -s test_moe_w4a8_perf.py -k perf --enforce-targets ``` 需要加 `-s` 才能看到打印出的表格。 @@ -115,6 +214,7 @@ python test_moe_w4a8_perf.py # 两个阶段,最小 batch python test_moe_w4a8_perf.py --all-shapes # 完整扫描 python test_moe_w4a8_perf.py --phase decode # 仅 decode python test_moe_w4a8_perf.py --skip-accuracy # 仅性能 +python test_moe_w4a8_perf.py --compute-bound # 追加 4096 token 的 prefill 用例 python test_moe_w4a8_perf.py --dtype fp16 # fp16 激活 python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 @@ -183,6 +283,8 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 |---|---| | `ARK_MOE_W4A8_AUTO_S8` | 覆盖 AUTO_S8 重缩放 block 大小。未设置 / `-1` 表示每个输出通道一个 scale (最快)。如果取值不是 `group_size` 和 64 的公倍数,或不能整除 K,则静默回退为 K。 | | `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | `phase="auto"` 时选择 GEMV 的 token 数上限 (默认 `128`)。 | +| `ARK_MOE_W4A8_DECODE_KSPLIT` | 合并访存的 K-split decode 映射,**默认开启**。设为 `0` 可回退到原来每个输出一个 work-item 的 GEMV (便于 A/B 对比)。形状不满足条件时该开关无效。 | +| `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。 | ## 形状约束 @@ -195,6 +297,9 @@ kernel 要求: Qwen3-MoE 的两个 GEMM 都满足以上条件 (`K = 2048` 和 `K = 768`)。 +decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 的倍数;不满足的形 +状会退回到原 GEMV,而不是报错。 + ## 状态 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 @@ -202,3 +307,7 @@ W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 `STATUS: NEEDS-HARDWARE-VALIDATION`。本脚本正是为在真实硬件上验证它而设计的:请 先运行精度扫描 (它能立刻暴露 layout / scale 相关的 bug),再运行性能扫描来调优 tile 阶梯与 decode 阈值。 + +decode 的 K-split 映射同样尚未在硬件上验证。其下标计算已用宿主端 mock 与原映射逐 +一比对,`test_decode_ksplit_matches_legacy` 会在设备上再次校验;一旦出现回归,设置 +`ARK_MOE_W4A8_DECODE_KSPLIT=0` 即可在不重新编译的情况下恢复原有行为。 From bb7bdce442689347cc36e5f0efdc7c16134d662e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:36:03 +0000 Subject: [PATCH 057/112] fix(ark): compare the two decode mappings by SNR, not bit-equality Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 15 ++++++++----- .../ark/test/README_MOE_W4A8.md | 7 ++++-- .../ark/test/README_MOE_W4A8_CN.md | 6 +++-- .../ark/test/test_moe_w4a8_perf.py | 22 +++++++++++++------ 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index f211351fcd..6904361eff 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -727,12 +727,15 @@ void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, // independent weight loads in flight by `NCOLS` (see // `moe_w4a8_decode_ksplit_ncols`). // -// Numerics are unchanged: the int32 partial sums are still folded to float -// once per AUTO_S8 block with that block's scale, only split across the 16 -// lanes and summed at the end (integer addition is associative, and the float -// fold happens per block per lane exactly as before). A lane's chunk is 16 -// consecutive K elements starting at a multiple of 16 and every block boundary -// is a multiple of 64, so a chunk never straddles two blocks. +// Numerics are equivalent, not bit-identical: the int32 partial sums are still +// folded to float once per AUTO_S8 block with that block's scale, but they are +// split across the 16 lanes and summed at the end. Integer addition is exact +// and associative, so the *integer* partition is lossless; only the float +// accumulation is reordered (per lane, then across lanes, instead of one lane +// folding every block in sequence), which can differ from the legacy result by +// a rounding step. A lane's chunk is 16 consecutive K elements starting at a +// multiple of 16 and every block boundary is a multiple of 64, so a chunk +// never straddles two blocks. // --------------------------------------------------------------------------- // K elements a lane owns per step: one 16-byte int8 weight load and one diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index f8acd9004c..3925c57ddf 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -125,8 +125,11 @@ it), so the block scale is hoisted to a scalar and the hot loop contains no division — and, unlike the FP8 variant, no power-of-two constraint on the block size. The arithmetic is unchanged: int32 partials per lane per block, scaled by the block scale, summed across the sub-group, then multiplied by the per-token -activation scale. `test_decode_ksplit_matches_legacy` asserts that both mappings -produce bit-identical output. +activation scale. Only the float *summation order* differs (per lane then +across lanes, instead of one lane folding every block), so the two mappings are +not bit-identical; `test_decode_ksplit_matches_legacy` asserts they agree to +better than 40 dB SNR / 0.9999 cosine, far tighter than any real mapping bug +could pass. The mapping requires `N % 16 == 0`, a re-scale block that is a multiple of 16 and at least 256, and `K % block == 0`. Anything else (for example an explicit diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 4b124b49cf..bc22cba652 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -113,8 +113,10 @@ K 元素。这样每条 load 覆盖 **256 个连续权重字节**,grid 规模 循环采用 *block 在外* 的结构 (先遍历 AUTO_S8 重缩放 block,再在 block 内遍历 K), 因此 block scale 被提升为标量,热循环中没有除法;并且与 FP8 版本不同,对 block 大 小没有 2 的幂约束。算术过程保持不变:每个 lane 在每个 block 内累加 int32 部分和, -乘以 block scale,在 sub-group 内求和,再乘以每 token 的激活 scale。 -`test_decode_ksplit_matches_legacy` 断言两种映射的输出逐位相同。 +乘以 block scale,在 sub-group 内求和,再乘以每 token 的激活 scale。差异仅在于浮点 +**求和顺序** (先按 lane 累加再跨 lane 归约,而不是由单个 lane 累加所有 block),因 +此两种映射的输出并非逐位相同;`test_decode_ksplit_matches_legacy` 断言两者的 SNR +高于 40 dB、余弦相似度高于 0.9999 — 这远比任何真实的映射错误所能达到的精度更严格。 该映射要求 `N % 16 == 0`、重缩放 block 是 16 的倍数且不小于 256、`K % block == 0`。 不满足时 (例如显式指定 `--rescale-group-size 64`) 会自动回退到原 kernel。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 53daf2469a..ff4549e6ff 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -915,12 +915,17 @@ def test_perf_prefill_compute_bound(self, request): _assert_targets(request, "prefill", rows) def test_decode_ksplit_matches_legacy(self): - """The K-split decode mapping must not change the result. - - Runs the same decode problem with ``ARK_MOE_W4A8_DECODE_KSPLIT`` - on and off and requires the two outputs to agree bit-for-bit: both - paths accumulate the same int32 partial sums per re-scale block, - only the assignment of K elements to lanes differs. + """The K-split decode mapping must agree with the legacy one. + + Both paths accumulate the same int32 partial sums per re-scale + block and apply the same scales; only the assignment of K elements + to lanes differs. That does reorder the *float* accumulation (the + legacy kernel folds every block in one lane, the K-split kernel + folds per lane and then reduces across the sub-group), so the two + are not required to be bit-identical -- but they must agree far + more closely than either agrees with the fp32 reference. A wrong + lane mapping, expert offset or block scale would miss by orders of + magnitude, not by a rounding step. """ case = _build_case( _QWEN3_NK[0][1], @@ -939,7 +944,10 @@ def test_decode_ksplit_matches_legacy(self): for flag in ("0", "1"): with _env_override(ARK_MOE_W4A8_DECODE_KSPLIT=flag): outs[flag] = _w4a8(case, weights_s8, wscales, block, "decode").clone() - torch.testing.assert_close(outs["1"], outs["0"], rtol=0, atol=0) + snr = _snr_db(outs["0"], outs["1"]) + cos = _cosine(outs["0"], outs["1"]) + assert snr >= 40.0, f"K-split decode disagrees with the legacy GEMV: SNR {snr:.2f} dB" + assert cos >= 0.9999, f"K-split decode disagrees with the legacy GEMV: cosine {cos:.6f}" # Minimum quality gate for the int8 activation path against an fp32 reference From ed2e3ca9398944bc5ba2bf7866022857f65c3443 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:48:17 +0000 Subject: [PATCH 058/112] perf(ark): widen the prefill tile to 256x256 and make the decode chunk width tunable Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 205 ++++++-- auto_round_extension/ark/test/conftest.py | 13 + .../ark/test/test_moe_w4a8_perf.py | 497 ++++++++++++++---- 3 files changed, 564 insertions(+), 151 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 6904361eff..f5f750aecf 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -81,6 +81,7 @@ #include #include +#include #include #ifdef ARK_XPU @@ -131,7 +132,7 @@ class MoEW4A8Repack; template class MoEW4A8DecodeGemv; -template +template class MoEW4A8DecodeKSplit; template @@ -312,6 +313,22 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // (`SmallTileSG` / `SmallMidTileSG` / `MediumTileSG` / `LargeTileSG`), which // keeps `size(mma)` at 64 / 128 / 256 / 512 threads -- all divisors of the 512 // threads-per-SM budget the persistent scheduler assumes. +// +// Tile shape *is* the prefill bandwidth knob. A `TileM x TileN` tile reads its +// own A and B slabs, so the bytes a whole expert pulls through L2/DRAM are +// +// M*K * ceil(N/TileN) + N*K * ceil(M/TileM) ~= M*N*K * (1/TileN + 1/TileM) +// +// i.e. A is re-read once per N tile and B once per M tile. At the compute-bound +// Qwen3-MoE shape (256 rows/expert, N = 1536, K = 2048) a 128x128 tile re-reads +// A twelve times, for ~1.6 GB of tile traffic per grouped GEMM -- ~470 GB/s at +// the measured 3.3 ms, i.e. above the device's ~390 GB/s copy rate, so the GEMM +// is still memory-bound even though the compact operands are only ~0.6 GB. +// Doubling both extents to 256x256 halves that (`1/256 + 1/256` vs +// `1/128 + 1/128`), which is why the reference `launch_igemm`'s large rung is +// 256x256 and the W4A16 MoE policy uses a 256-wide N tile. `w4a8_policy_large` +// matches it; `w4a8_policy_m_256_n128` keeps the narrower variant reachable +// through `ARK_MOE_W4A8_PREFILL_TILE` for A/B measurement. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: @@ -331,12 +348,24 @@ class w4a8_policy_m_128 : public moe_dpas_fp8::dpas_policy_base { using SGLayout = Layout, Stride<_4, _1, _0>>; }; -class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { +class w4a8_policy_m_128_n256 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_128, _256, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +class w4a8_policy_m_256_n128 : public moe_dpas_fp8::dpas_policy_base { public: using WGTile = Shape<_256, _128, _64>; using SGLayout = Layout, Stride<_4, _1, _0>>; }; +class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_256, _256, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + // --------------------------------------------------------------------------- // Single-tile int8 x int8 -> int32 mainloop with a per-block weight scale and // a per-row activation scale. @@ -598,8 +627,20 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const } // --------------------------------------------------------------------------- -// Prefill driver: policy selection on the average per-expert M, matching the -// tile ladder of `launch_igemm_kblock` in `sycl_tla_s8_gemm.hpp`. +// Prefill driver: policy selection on the average per-expert M. +// +// The small-M rungs match the tile ladder of `launch_igemm_kblock` in +// `sycl_tla_s8_gemm.hpp`. The large rung differs from the reference's `m > 1024` +// threshold because a grouped GEMM's M is *per expert*: at 256 rows/expert the +// dense ladder would still pick 128x128 and pay 12-16 re-reads of the A tile +// (see the tile-policy comment above), which is what keeps the compute-bound +// Qwen3-MoE shape memory-bound. 256 rows exactly fill a 256-row tile, so the +// 256x256 policy takes over as soon as the average expert can fill it. +// +// `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile +// (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything +// else -- including the default `auto` -- keeps the ladder. It exists so the +// tile can be swept on hardware without a rebuild. // --------------------------------------------------------------------------- template void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, @@ -616,11 +657,34 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ num_tokens_per_expert, E, blocksize, blks, atomic_buffer); + const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); + if (tile_env != nullptr) { + if (std::strcmp(tile_env, "8x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) + return; + } else if (std::strcmp(tile_env, "64x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) + return; + } else if (std::strcmp(tile_env, "128x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) + return; + } else if (std::strcmp(tile_env, "128x256") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) + return; + } else if (std::strcmp(tile_env, "256x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) + return; + } else if (std::strcmp(tile_env, "256x256") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) + return; + } + } + if (A_avg_M < 16) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) } else if (A_avg_M < 128) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - } else if (A_avg_M <= 1024) { + } else if (A_avg_M < 256) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) } else { ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) @@ -712,13 +776,13 @@ void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, // loads in flight to cover DRAM latency. // // This kernel transposes the mapping exactly like `launch_fp8_ksplit`: a whole -// sub-group cooperates on one output element and lane `l` owns the -// `KSPLIT_CH` consecutive K elements at `l * KSPLIT_CH` inside each -// `KSPLIT_STEP`-wide K tile. One instruction then covers `KSPLIT_STEP` -// *contiguous* weight bytes (256 B = four full cache lines) and the same span -// of int8 activations, every thread walks a single sequential stream, and the -// sub-group count grows by `SG_SIZE` (12288 for that batch-1 step). The price -// is one `reduce_over_group` per output element -- a handful of shuffles +// sub-group cooperates on one output element and lane `l` owns the `CH` +// consecutive K elements at `l * CH` inside each `SG_SIZE * CH`-wide K tile. +// One instruction then covers `SG_SIZE * CH` *contiguous* weight bytes (256 B = +// four full cache lines at the default `CH = 16`, 512 B at `CH = 32`) and the +// same span of int8 activations, every thread walks a single sequential stream, +// and the sub-group count grows by `SG_SIZE` (12288 for that batch-1 step). The +// price is one `reduce_over_group` per output element -- a handful of shuffles // against `K` multiply-adds. // // On top of that the sub-group blocks N: it owns `NCOLS` consecutive columns @@ -733,17 +797,21 @@ void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, // and associative, so the *integer* partition is lossless; only the float // accumulation is reordered (per lane, then across lanes, instead of one lane // folding every block in sequence), which can differ from the legacy result by -// a rounding step. A lane's chunk is 16 consecutive K elements starting at a -// multiple of 16 and every block boundary is a multiple of 64, so a chunk -// never straddles two blocks. +// a rounding step. A lane's chunk is `CH` consecutive K elements starting at a +// multiple of `CH`, and the shape gate requires the block to be a multiple of +// `CH`, so a chunk never straddles two blocks. // --------------------------------------------------------------------------- -// K elements a lane owns per step: one 16-byte int8 weight load and one -// 16-byte int8 activation load, the same transactions the legacy GEMV issues. -constexpr int KSPLIT_CH = 16; -// K elements a sub-group covers per step -- the contiguous span its 16 lanes -// read in one instruction. -constexpr int KSPLIT_STEP = SG_SIZE * KSPLIT_CH; +// K elements a lane owns per step: `KSPLIT_CH_DEFAULT` is one 16-byte int8 +// weight load and one 16-byte int8 activation load, the same transactions the +// legacy GEMV issues. `ARK_MOE_W4A8_DECODE_KSPLIT_CH=32` doubles them to +// 32-byte loads, which halves the number of memory messages per byte and +// doubles the bytes a thread keeps in flight -- the lever for the gap between +// the GEMV's measured streaming rate and the device's copy bandwidth. It costs +// GRF (2 x NCOLS chunks live at once) and needs `blocksize >= SG_SIZE * CH`, +// so it stays opt-in until measured on hardware. +constexpr int KSPLIT_CH_DEFAULT = 16; +constexpr int KSPLIT_CH_MAX = 32; // Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group // covers `KSPLIT_WG_SGS * NCOLS` consecutive columns. constexpr int KSPLIT_WG_SGS = N_TILE; @@ -758,16 +826,31 @@ inline bool moe_w4a8_decode_ksplit_enabled() { return moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_DECODE_KSPLIT", true); } -// Shape gate. `blocksize >= KSPLIT_STEP` keeps every lane of the sub-group +// Per-lane chunk width in K elements (= bytes). 16 or 32; anything else falls +// back to the default. +inline int moe_w4a8_decode_ksplit_chunk() { + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_CH"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && (v == 16 || v == 32)) return static_cast(v); + } + return KSPLIT_CH_DEFAULT; +} + +// Shape gate. `blocksize >= SG_SIZE * ch` keeps every lane of the sub-group // busy: below that some lanes own no chunk in a block and only pay the // reduction, which is the one regime where splitting K cannot pay for itself. -// The resolved AUTO_S8 block is always a multiple of 64 that divides K, so the -// alignment condition holds for every shipped configuration and only very fine -// re-scale blocks (64 / 128 / 192) fall back to the legacy GEMV. -inline bool moe_w4a8_decode_ksplit_shape_ok(int N, int K, int blocksize) { +// `blocksize % ch == 0` combined with `K % blocksize == 0` also makes every +// chunk offset a multiple of `ch` off a row base that is a multiple of `K`, so +// the vector loads stay naturally aligned. The resolved AUTO_S8 block is always +// a multiple of 64 that divides K, so the conditions hold for every shipped +// configuration and only very fine re-scale blocks fall back to the legacy +// GEMV. +inline bool moe_w4a8_decode_ksplit_shape_ok(int N, int K, int blocksize, int ch = KSPLIT_CH_DEFAULT) { if (N % N_TILE != 0) return false; - if (blocksize < KSPLIT_STEP) return false; - if (blocksize % KSPLIT_CH != 0) return false; + if (blocksize < SG_SIZE * ch) return false; + if (blocksize % ch != 0) return false; if (K % blocksize != 0) return false; return true; } @@ -790,12 +873,16 @@ inline int moe_w4a8_decode_ksplit_ncols(int N) { return ncols; } -template +template void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, const float* wscale, ElementD* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, int blocksize, int blks) { static_assert(NCOLS >= 1 && (NCOLS & (NCOLS - 1)) == 0, "NCOLS must be a power of two"); - if (!moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { + static_assert(CH == 16 || CH == KSPLIT_CH_MAX, "CH must be 16 or 32"); + // K elements a sub-group covers per step -- the contiguous span its 16 lanes + // read in one instruction. + constexpr int STEP = SG_SIZE * CH; + if (!moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, CH) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { throw std::invalid_argument("moe_gemm_w4a8(decode): K-split GEMV called on an unsupported shape"); } if (total_tokens == 0) return; @@ -805,7 +892,7 @@ void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* sycl::range<2> global{static_cast(total_tokens), static_cast(N / NCOLS) * SG_SIZE}; sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; - q->parallel_for>( + q->parallel_for>( sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { const auto sg = it.get_sub_group(); const int token = static_cast(it.get_global_id(0)); @@ -826,7 +913,7 @@ void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* s_rows[c] = wscale + (row0 + static_cast(c)) * blks; } - using QVec = sycl::vec; + using QVec = sycl::vec; float acc[NCOLS]; #pragma unroll @@ -839,25 +926,25 @@ void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* #pragma unroll for (int c = 0; c < NCOLS; ++c) iacc[c] = 0; - int k0 = block_begin + lane * KSPLIT_CH; + int k0 = block_begin + lane * CH; // Two chunks per iteration: their loads are independent, so the pair // doubles the weight requests a thread keeps in flight. All // `2 * NCOLS` weight loads are issued before the first is consumed. - for (; k0 + KSPLIT_STEP + KSPLIT_CH <= block_end; k0 += 2 * KSPLIT_STEP) { + for (; k0 + STEP + CH <= block_end; k0 += 2 * STEP) { const QVec av0 = *reinterpret_cast(act_row + k0); - const QVec av1 = *reinterpret_cast(act_row + k0 + KSPLIT_STEP); + const QVec av1 = *reinterpret_cast(act_row + k0 + STEP); QVec wv0[NCOLS], wv1[NCOLS]; #pragma unroll for (int c = 0; c < NCOLS; ++c) { wv0[c] = *reinterpret_cast(w_rows[c] + k0); - wv1[c] = *reinterpret_cast(w_rows[c] + k0 + KSPLIT_STEP); + wv1[c] = *reinterpret_cast(w_rows[c] + k0 + STEP); } #pragma unroll for (int c = 0; c < NCOLS; ++c) { int p0 = 0; int p1 = 0; #pragma unroll - for (int u = 0; u < KSPLIT_CH; u += 2) { + for (int u = 0; u < CH; u += 2) { p0 += static_cast(av0[u]) * static_cast(wv0[c][u]); p1 += static_cast(av0[u + 1]) * static_cast(wv0[c][u + 1]); p0 += static_cast(av1[u]) * static_cast(wv1[c][u]); @@ -868,7 +955,7 @@ void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* } // Tail: the chunk of a lane whose partner a full step away falls // outside the block. At most one chunk per lane. - for (; k0 < block_end; k0 += KSPLIT_STEP) { + for (; k0 < block_end; k0 += STEP) { const QVec av = *reinterpret_cast(act_row + k0); #pragma unroll for (int c = 0; c < NCOLS; ++c) { @@ -876,7 +963,7 @@ void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* int p0 = 0; int p1 = 0; #pragma unroll - for (int u = 0; u < KSPLIT_CH; u += 2) { + for (int u = 0; u < CH; u += 2) { p0 += static_cast(av[u]) * static_cast(wv[u]); p1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); } @@ -899,26 +986,44 @@ void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* }); } -// Runtime NCOLS -> compile-time NCOLS bridge, plus the K-split / legacy choice. +// Runtime (NCOLS, CH) -> compile-time bridge, plus the K-split / legacy choice. +// `CH = 32` needs a block of at least 512 elements, so it silently falls back to +// 16 on shapes it cannot serve rather than dropping to the legacy GEMV. template void launch_w4a8_decode_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, const float* wscale, ElementD* outputs, const int* expert_id_per_token, int total_tokens, int N, int K, int blocksize, int blks) { if (moe_w4a8_decode_ksplit_enabled() && moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize)) { - switch (moe_w4a8_decode_ksplit_ncols(N)) { + const int ncols = moe_w4a8_decode_ksplit_ncols(N); + const int ch = moe_w4a8_decode_ksplit_chunk() == KSPLIT_CH_MAX && + moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, KSPLIT_CH_MAX) + ? KSPLIT_CH_MAX + : KSPLIT_CH_DEFAULT; + +#define ARK_MOE_W4A8_KSPLIT(ncols_v, ch_v) \ + launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, \ + expert_id_per_token, total_tokens, N, K, blocksize, blks); \ + return; + + if (ch == KSPLIT_CH_MAX) { + switch (ncols) { + case 4: + ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_MAX) + case 2: + ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_MAX) + default: + ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_MAX) + } + } + switch (ncols) { case 4: - launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, - total_tokens, N, K, blocksize, blks); - return; + ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_DEFAULT) case 2: - launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, - total_tokens, N, K, blocksize, blks); - return; + ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_DEFAULT) default: - launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, - total_tokens, N, K, blocksize, blks); - return; + ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_DEFAULT) } +#undef ARK_MOE_W4A8_KSPLIT } launch_w4a8_decode(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, total_tokens, N, K, blocksize, blks); diff --git a/auto_round_extension/ark/test/conftest.py b/auto_round_extension/ark/test/conftest.py index 719e922611..78b65d29a5 100644 --- a/auto_round_extension/ark/test/conftest.py +++ b/auto_round_extension/ark/test/conftest.py @@ -37,6 +37,9 @@ * ``--enforce-targets`` -- turn the W4A8 MoE performance goals into hard assertions instead of a printed verdict. + +* ``--models`` -- shape groups for the W4A8 MoE perf tests (``qwen3``, + ``minimax``, a comma-separated list, or ``all``). """ @@ -73,6 +76,16 @@ def pytest_addoption(parser): "skipped by default. Pass this flag to opt in." ), ) + parser.addoption( + "--models", + action="store", + default=None, + help=( + "Shape groups for the W4A8 MoE perf tests: 'qwen3', 'minimax', a " + "comma-separated list, or 'all'. Default (flag absent): qwen3 only, " + "so the default run stays short." + ), + ) parser.addoption( "--enforce-targets", action="store_true", diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index ff4549e6ff..752cb9af8b 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -161,12 +161,17 @@ def _env_override(**overrides): The kernel re-reads its dispatch flags on every call (they are never cached in a static), so an in-process override is enough to A/B two code - paths without reloading the extension. + paths without reloading the extension. A ``None`` value unsets the variable + for the duration of the block, which is how a sweep expresses "kernel + default". """ previous = {name: os.environ.get(name) for name in overrides} try: for name, value in overrides.items(): - os.environ[name] = value + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value yield finally: for name, value in previous.items(): @@ -281,15 +286,73 @@ def _max_rel_err(reference: torch.Tensor, actual: torch.Tensor) -> float: ("qwen3 down", _QWEN3_HIDDEN, _QWEN3_INTER), ] +# --------------------------------------------------------------------------- +# MiniMax-M2 shapes, matching ``test_moe_prefill_perf.py``: +# hidden_size = 3072, intermediate_size = 1536, num_local_experts = 192, +# num_experts_per_tok = 8. +# +# A second shape group matters here because both perf targets are shape +# dependent: with 192 experts a given batch spreads over 1.5x more experts +# (fewer rows per expert, so a *lower* compute ceiling at the same batch), while +# K doubles for the up-proj (3072 vs 2048), which lengthens the decode GEMV's +# sequential stream and gives the prefill tile more K per tile-load. Routing is +# the same round-robin spread used for Qwen3; the heavy-tailed empirical +# distribution lives in ``test_moe_prefill_perf.py``'s ``minimax real`` rows. +# --------------------------------------------------------------------------- +_MINIMAX_E = 192 +_MINIMAX_HIDDEN = 3072 +_MINIMAX_INTER = 1536 +_MINIMAX_TOPK = 8 +_MINIMAX_NK = [ + ("minimax up ", _MINIMAX_INTER, _MINIMAX_HIDDEN), + ("minimax down", _MINIMAX_HIDDEN, _MINIMAX_INTER), +] + +# A "model" is the (experts, top-k, group size, [(label, N, K)]) tuple the +# sweeps iterate over. Everything downstream reads the per-row ``E``/``topk``, +# so adding a group here is enough to get it benchmarked and target-checked. +_MODELS = { + "qwen3": {"E": _QWEN3_E, "topk": _QWEN3_TOPK, "group_size": _QWEN3_GROUP_SIZE, "nk": _QWEN3_NK}, + "minimax": {"E": _MINIMAX_E, "topk": _MINIMAX_TOPK, "group_size": _QWEN3_GROUP_SIZE, "nk": _MINIMAX_NK}, +} +_DEFAULT_MODELS = ["qwen3"] + + +def _models(names) -> list: + """Resolve model names (or ``"all"``) to ``(name, spec)`` pairs.""" + if names is None: + names = _DEFAULT_MODELS + if isinstance(names, str): + names = _MODELS.keys() if names == "all" else [names] + return [(n, _MODELS[n]) for n in names] + + +def _models_option(request): + """Read the ``--models`` pytest option (absent under a foreign conftest).""" + value = request.config.getoption("--models", default=None) if request is not None else None + if not value: + return None + if value == "all": + return "all" + return [name.strip() for name in value.split(",") if name.strip()] + + # Model-token batches (routed rows = batch * top_k). _DECODE_BATCHES = [1] _DECODE_BATCHES_EXTENDED = [1, 2, 8, 16] _PREFILL_BATCHES = [128] _PREFILL_BATCHES_EXTENDED = [128, 512, 2048, 8192] -# Compute-bound prefill batch: 4096 model tokens * top_k 8 = 32768 routed rows -# = 256 rows per expert, so 100 TFLOPS needs ~195 GB/s of weight bandwidth -- -# reachable, unlike the 6.25 TB/s the default batch of 128 would require. -_PREFILL_TARGET_BATCHES = [4096] +# Rows per expert at which a prefill TOPS target is physically reachable: the +# ceiling is ``2 * rows/E * weight_bandwidth``, so 256 rows/expert needs only +# ~195 GB/s for 100 TFLOPS -- unlike the 6.25 TB/s a batch of 128 would need. +# The batch is derived per model (``rows/E * E / top_k``): 4096 model tokens for +# Qwen3-MoE (128 experts), 6144 for MiniMax (192). +_PREFILL_TARGET_ROWS_PER_EXPERT = 256 + + +def _compute_bound_batches(model: dict) -> list: + """Model-token batches that put ``_PREFILL_TARGET_ROWS_PER_EXPERT`` rows on every expert.""" + return [_PREFILL_TARGET_ROWS_PER_EXPERT * model["E"] // model["topk"]] def _spread_tokens(total_tokens: int, num_experts: int) -> list: @@ -718,121 +781,297 @@ def run_accuracy(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, ve return rows -def run_perf(phase, batches, dtype=torch.bfloat16, rescale_group_size=-1, verbose=True, torch_baseline=True): +def run_perf( + phase, + batches, + dtype=torch.bfloat16, + rescale_group_size=-1, + verbose=True, + torch_baseline=True, + models=None, + compute_bound=False, +): """Run the W4A8 perf sweep. Returns a list of per-row metric dicts. ``torch_baseline=False`` skips both the dequantized weights and the torch matmul timing; the compute-bound batches need it to stay within memory. + + ``models`` selects the shape groups (``"qwen3"``, ``"minimax"``, ``"all"`` + or a list); ``compute_bound=True`` ignores ``batches`` and derives, per + model, the batch that puts ``_PREFILL_TARGET_ROWS_PER_EXPERT`` rows on every + expert -- the only regime where the prefill TOPS target is reachable. """ rows = [] # Probed before anything large is allocated (and cached across sweeps). device_bw = _device_bandwidth_gbps() + resolved = _models(models) if verbose: _print_perf_header( - f"W4A8 perf [{phase}] (E={_QWEN3_E}, group_size={_QWEN3_GROUP_SIZE}, " + f"W4A8 perf [{phase}] (models={'+'.join(n for n, _ in resolved)}, " + f"group_size={_QWEN3_GROUP_SIZE}, " f"act={str(dtype).split('.')[-1]}, rescale_group_size={rescale_group_size}) " f"-- ark.moe_gemm_w4a8 vs W4A16 vs torch" ) - for nk_label, N, K in _QWEN3_NK: - for batch in batches: - total_tokens = batch * _QWEN3_TOPK - case = _build_case( - N, - K, - _QWEN3_E, - total_tokens, - _QWEN3_GROUP_SIZE, - dtype, - need_reference=False, - need_dequant=torch_baseline, - ) + shapes = [ + (nk_label, N, K, spec, batch) + for _, spec in resolved + for nk_label, N, K in spec["nk"] + for batch in (_compute_bound_batches(spec) if compute_bound else batches) + ] + for nk_label, N, K, spec, batch in shapes: + E, topk, group_size = spec["E"], spec["topk"], spec["group_size"] + total_tokens = batch * topk + case = _build_case( + N, + K, + E, + total_tokens, + group_size, + dtype, + need_reference=False, + need_dequant=torch_baseline, + ) - # One-shot int4 -> int8 AUTO_S8 conversion. Timed separately: it - # happens once at model load, not per forward. - prepack_ms = _xpu_time_ms( - lambda: ark.moe_w4a8_prepack( - case["packed"], - case["scales"], - group_size=_QWEN3_GROUP_SIZE, - rescale_group_size=rescale_group_size, - ), - warmup=1, - iters=3, - ) - weights_s8, wscales, block = ark.moe_w4a8_prepack( + # One-shot int4 -> int8 AUTO_S8 conversion. Timed separately: it + # happens once at model load, not per forward. + prepack_ms = _xpu_time_ms( + lambda: ark.moe_w4a8_prepack( case["packed"], case["scales"], - group_size=_QWEN3_GROUP_SIZE, + group_size=group_size, rescale_group_size=rescale_group_size, + ), + warmup=1, + iters=3, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], + case["scales"], + group_size=group_size, + rescale_group_size=rescale_group_size, + ) + + w4a8_ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase)) + torch_ms = _xpu_time_ms(lambda: _torch_baseline(case)) if torch_baseline else None + try: + w4a16_ms = _xpu_time_ms(lambda: _w4a16(case, phase)) + except Exception as exc: # pragma: no cover - depends on build + print(f"[moe-w4a8-perf] W4A16 timing unavailable for {nk_label}: {exc}") + w4a16_ms = None + + active_experts = sum(1 for n_e in case["tpe"] if n_e > 0) + tflops = _flops(total_tokens, N, K) / (w4a8_ms * 1e-3) / 1e12 + # W4A8 streams int8 weights: 1 byte per element, only for the + # experts that actually received tokens. + gbps = _weight_bytes(active_experts, N, K, 8) / (w4a8_ms * 1e-3) / 1e9 + rows_per_expert = _rows_per_expert(total_tokens, active_experts) + bw_at_100t = _bw_needed_for_tflops(total_tokens, active_experts) + + row = { + "label": nk_label, + "phase": phase, + "E": E, + "N": N, + "K": K, + "tokens": total_tokens, + "torch_ms": torch_ms, + "w4a16_ms": w4a16_ms, + "w4a8_ms": w4a8_ms, + "tflops": tflops, + "gbps": gbps, + "prepack_ms": prepack_ms, + "active_experts": active_experts, + "rows_per_expert": rows_per_expert, + "bw_at_100t": bw_at_100t, + # Hard ceiling for this routing on this device (``None`` when + # the bandwidth probe is unavailable). + "tflops_ceiling": (None if device_bw is None else _tflops_ceiling(total_tokens, active_experts, device_bw)), + "device_bw_gbps": device_bw, + } + rows.append(row) + if verbose: + _print_perf_row( + nk_label, + E, + N, + K, + total_tokens, + torch_ms, + w4a16_ms, + w4a8_ms, + tflops, + gbps, + prepack_ms, + rows_per_expert=rows_per_expert, + bw_at_100t=bw_at_100t, ) - w4a8_ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase)) - torch_ms = _xpu_time_ms(lambda: _torch_baseline(case)) if torch_baseline else None - try: - w4a16_ms = _xpu_time_ms(lambda: _w4a16(case, phase)) - except Exception as exc: # pragma: no cover - depends on build - print(f"[moe-w4a8-perf] W4A16 timing unavailable for {nk_label}: {exc}") - w4a16_ms = None + # Drop the (large) int8 weights before the next shape allocates. + case = weights_s8 = wscales = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + if verbose: + _print_targets(phase, rows) + return rows - active_experts = sum(1 for n_e in case["tpe"] if n_e > 0) - tflops = _flops(total_tokens, N, K) / (w4a8_ms * 1e-3) / 1e12 - # W4A8 streams int8 weights: 1 byte per element, only for the - # experts that actually received tokens. - gbps = _weight_bytes(active_experts, N, K, 8) / (w4a8_ms * 1e-3) / 1e9 - rows_per_expert = _rows_per_expert(total_tokens, active_experts) - bw_at_100t = _bw_needed_for_tflops(total_tokens, active_experts) - row = { - "label": nk_label, - "phase": phase, - "E": _QWEN3_E, - "N": N, - "K": K, - "tokens": total_tokens, - "torch_ms": torch_ms, - "w4a16_ms": w4a16_ms, - "w4a8_ms": w4a8_ms, - "tflops": tflops, - "gbps": gbps, - "prepack_ms": prepack_ms, - "active_experts": active_experts, - "rows_per_expert": rows_per_expert, - "bw_at_100t": bw_at_100t, - # Hard ceiling for this routing on this device (``None`` when - # the bandwidth probe is unavailable). - "tflops_ceiling": ( - None if device_bw is None else _tflops_ceiling(total_tokens, active_experts, device_bw) - ), - "device_bw_gbps": device_bw, - } - rows.append(row) - if verbose: - _print_perf_row( - nk_label, - _QWEN3_E, - N, - K, - total_tokens, - torch_ms, - w4a16_ms, - w4a8_ms, - tflops, - gbps, - prepack_ms, - rows_per_expert=rows_per_expert, - bw_at_100t=bw_at_100t, - ) +# --------------------------------------------------------------------------- +# Kernel-configuration sweeps +# +# Both perf targets depend on a dispatch choice that can only be settled on +# hardware (how many bytes a decode lane keeps in flight; how wide a prefill +# tile should be). Every choice is reachable through an environment variable +# and re-read per call, so one run can time them all against the same +# workload -- built and prepacked once -- and name the winner. +# +# Each configuration is also checked against the first one for numerical +# equivalence, so a configuration that is fast because it computes the wrong +# thing cannot win. +# --------------------------------------------------------------------------- - # Drop the (large) int8 weights before the next shape allocates. - case = weights_s8 = wscales = None - ark.clear_moe_w4a8_prepack_cache() - ark.moe_w4a8_release_scratch() - _release_xpu_memory() +# Decode: (label, env overrides). ``None`` unsets a variable. +_DECODE_CONFIGS = [ + ("legacy gemv", {"ARK_MOE_W4A8_DECODE_KSPLIT": "0"}), +] + [ + ( + f"ksplit ch{ch} ncols{ncols}", + { + "ARK_MOE_W4A8_DECODE_KSPLIT": "1", + "ARK_MOE_W4A8_DECODE_KSPLIT_CH": str(ch), + "ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS": str(ncols), + }, + ) + for ch in (16, 32) + for ncols in (1, 2, 4) +] + +# Prefill: work-group tile shapes. ``auto`` is the built-in ladder. +_PREFILL_TILES = ["auto", "128x128", "128x256", "256x128", "256x256"] +_PREFILL_TILE_CONFIGS = [ + (f"tile {tile}", {"ARK_MOE_W4A8_PREFILL_TILE": None if tile == "auto" else tile}) for tile in _PREFILL_TILES +] + +_SWEEP_MIN_SNR_DB = 40.0 + + +def _print_sweep_header(title: str, metric: str) -> None: + print() + print("=" * _PERF_WIDTH) + print(title) + print( + f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'rows/E':>8} " + f"{'config':<22}{'ms':>10}{metric:>10}{'vs default':>12}{'SNR(dB)':>10}" + ) + print("-" * _PERF_WIDTH) + + +def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose=True, compute_bound=None): + """Time every kernel configuration in ``configs`` on the same workload. + + ``phase`` picks the sweep's shapes and its metric: ``decode`` reports + weight bandwidth (the decode target), ``prefill`` reports TFLOPS (the + prefill target) at the compute-bound batch. Returns one dict per + (shape, configuration). + """ + is_prefill = phase == "prefill" + compute_bound = is_prefill if compute_bound is None else compute_bound + metric_name = "TFLOPS" if is_prefill else "W GB/s" + device_bw = _device_bandwidth_gbps() + resolved = _models(models) if verbose: - _print_targets(phase, rows) + _print_sweep_header( + f"W4A8 config sweep [{phase}] (models={'+'.join(n for n, _ in resolved)}, " + f"act={str(dtype).split('.')[-1]}) -- same workload, one row per kernel configuration", + metric_name, + ) + batches = _DECODE_BATCHES if not is_prefill else None + rows = [] + for _, spec in resolved: + E, topk, group_size = spec["E"], spec["topk"], spec["group_size"] + for nk_label, N, K in spec["nk"]: + for batch in _compute_bound_batches(spec) if compute_bound else batches: + total_tokens = batch * topk + case = _build_case(N, K, E, total_tokens, group_size, dtype, need_reference=False, need_dequant=False) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=group_size, rescale_group_size=-1 + ) + active_experts = sum(1 for n_e in case["tpe"] if n_e > 0) + rows_per_expert = _rows_per_expert(total_tokens, active_experts) + + baseline_out = None + baseline_ms = None + for label, overrides in configs: + with _env_override(**overrides): + out = _w4a8(case, weights_s8, wscales, block, phase) + ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase)) + if baseline_out is None: + # Cloned: the kernel may hand back a reused scratch + # buffer, which would make every later comparison + # compare a tensor with itself. + baseline_out, baseline_ms, snr = out.clone(), ms, float("inf") + else: + snr = _snr_db(baseline_out.to(torch.float32), out.to(torch.float32)) + tflops = _flops(total_tokens, N, K) / (ms * 1e-3) / 1e12 + gbps = _weight_bytes(active_experts, N, K, 8) / (ms * 1e-3) / 1e9 + row = { + "label": nk_label, + "phase": phase, + "config": label, + "overrides": overrides, + "E": E, + "N": N, + "K": K, + "tokens": total_tokens, + "rows_per_expert": rows_per_expert, + "w4a8_ms": ms, + "tflops": tflops, + "gbps": gbps, + "snr_db": snr, + "speedup": baseline_ms / ms if ms else None, + "device_bw_gbps": device_bw, + } + rows.append(row) + if verbose: + metric = tflops if is_prefill else gbps + snr_txt = "--" if math.isinf(snr) else f"{snr:.1f}" + speedup_txt = "--" if row["speedup"] is None else f"{row['speedup']:.2f}x" + print( + f"{nk_label:<14}{E:>5}{N:>7}{K:>7}{total_tokens:>8}{rows_per_expert:>8.1f} " + f"{label:<22}{ms:>10.3f}{metric:>10.2f}{speedup_txt:>12}{snr_txt:>10}" + ) + out = None + case = weights_s8 = wscales = baseline_out = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + if verbose: + _print_sweep_best(phase, rows) return rows +def _print_sweep_best(phase, rows) -> None: + """Report, per shape, the fastest numerically-equivalent configuration.""" + if not rows: + return + is_prefill = phase == "prefill" + print() + print(f"best configuration [{phase}] (equivalent within {_SWEEP_MIN_SNR_DB:g} dB SNR of the first configuration):") + shapes = [] + for row in rows: + if row["label"] not in shapes: + shapes.append(row["label"]) + for shape in shapes: + candidates = [r for r in rows if r["label"] == shape and r["snr_db"] >= _SWEEP_MIN_SNR_DB] + if not candidates: + print(f" {shape:<14} no numerically-equivalent configuration") + continue + best = min(candidates, key=lambda r: r["w4a8_ms"]) + metric = f"{best['tflops']:.2f} TFLOPS" if is_prefill else f"{best['gbps']:.1f} GB/s" + env = " ".join(f"{k}={v}" for k, v in sorted(best["overrides"].items()) if v is not None) or "(defaults)" + print(f" {shape:<14} {best['config']:<22} {best['w4a8_ms']:.3f} ms {metric:<16} {env}") + + # --------------------------------------------------------------------------- # pytest entry points # --------------------------------------------------------------------------- @@ -889,13 +1128,13 @@ def test_accuracy_rescale_group_size(self, request): def test_perf_decode(self, request): all_shapes = request.config.getoption("--all-shapes", default=False) - rows = run_perf("decode", _decode_batches(all_shapes)) + rows = run_perf("decode", _decode_batches(all_shapes), models=_models_option(request)) assert rows and all(r["w4a8_ms"] > 0 for r in rows) _assert_targets(request, "decode", rows) def test_perf_prefill(self, request): all_shapes = request.config.getoption("--all-shapes", default=False) - rows = run_perf("prefill", _prefill_batches(all_shapes)) + rows = run_perf("prefill", _prefill_batches(all_shapes), models=_models_option(request)) assert rows and all(r["w4a8_ms"] > 0 for r in rows) _assert_targets(request, "prefill", rows) @@ -910,10 +1149,43 @@ def test_perf_prefill_compute_bound(self, request): bandwidth for 100 TFLOPS and is therefore the shape the compute target should actually be measured at. """ - rows = run_perf("prefill", _PREFILL_TARGET_BATCHES, torch_baseline=False) + rows = run_perf("prefill", None, torch_baseline=False, compute_bound=True, models=_models_option(request)) assert rows and all(r["w4a8_ms"] > 0 for r in rows) _assert_targets(request, "prefill", rows) + def test_perf_decode_config_sweep(self, request): + """Time every decode lane mapping on one workload and name the best. + + The decode target is weight bandwidth, and how close the GEMV gets + to the device's streaming rate is decided by how many bytes a lane + keeps in flight (``CH``) and how many output columns a sub-group + blocks over (``NCOLS``). Both are dispatch-time environment knobs, + so a single run can measure the whole grid -- including the legacy + mapping -- against the same prepacked weights. + """ + rows = run_config_sweep("decode", _DECODE_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"decode config {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + + def test_perf_prefill_tile_sweep(self, request): + """Time every prefill work-group tile at the compute-bound batch. + + A ``TileM x TileN`` tile re-reads A once per N tile and B once per M + tile, so the tile shape sets the GEMM's tile-load traffic + (``~ M*N*K * (1/TileM + 1/TileN)``) and therefore whether a + compute-bound shape is actually limited by compute. This sweep + measures the ladder's choice against every explicit tile. + """ + rows = run_config_sweep("prefill", _PREFILL_TILE_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"prefill tile {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_decode_ksplit_matches_legacy(self): """The K-split decode mapping must agree with the legacy one. @@ -1014,6 +1286,22 @@ def _parse_args(argv): default="bf16", help="Activation dtype (default: bf16).", ) + parser.add_argument( + "--models", + default=",".join(_DEFAULT_MODELS), + help=( + "Comma-separated shape groups to benchmark, or 'all' " + f"(available: {', '.join(_MODELS)}; default: {','.join(_DEFAULT_MODELS)})." + ), + ) + parser.add_argument( + "--sweep-configs", + action="store_true", + help=( + "Also sweep the kernel dispatch configurations (decode lane mapping, prefill tile) " + "and report the fastest numerically-equivalent one per shape." + ), + ) parser.add_argument( "--rescale-group-size", type=int, @@ -1026,7 +1314,7 @@ def _parse_args(argv): "--compute-bound", action="store_true", help=( - "Also run the compute-bound prefill batch (4096 model tokens = 256 rows per expert), " + f"Also run the compute-bound prefill batch ({_PREFILL_TARGET_ROWS_PER_EXPERT} rows per expert), " "the smallest sweep point where the 100 TFLOPS goal is not capped by weight bandwidth." ), ) @@ -1051,6 +1339,8 @@ def main(argv=None) -> int: if env_block is not None: print(f"[moe-w4a8-perf] ARK_MOE_W4A8_AUTO_S8={env_block} overrides --rescale-group-size") + models = "all" if args.models == "all" else [m.strip() for m in args.models.split(",") if m.strip()] + failures = [] for phase in phases: batches = _decode_batches(args.all_shapes) if phase == "decode" else _prefill_batches(args.all_shapes) @@ -1061,15 +1351,20 @@ def main(argv=None) -> int: except AssertionError as exc: failures.append(str(exc)) if not args.skip_perf: - run_perf(phase, batches, dtype=dtype, rescale_group_size=args.rescale_group_size) + run_perf(phase, batches, dtype=dtype, rescale_group_size=args.rescale_group_size, models=models) if phase == "prefill" and args.compute_bound: run_perf( phase, - _PREFILL_TARGET_BATCHES, + None, dtype=dtype, rescale_group_size=args.rescale_group_size, torch_baseline=False, + compute_bound=True, + models=models, ) + if args.sweep_configs: + configs = _PREFILL_TILE_CONFIGS if phase == "prefill" else _DECODE_CONFIGS + run_config_sweep(phase, configs, dtype=dtype, models=models) if failures: print() From 725b2c82280e2b61808652c6383cf9fb0ae399bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:49:52 +0000 Subject: [PATCH 059/112] docs(ark): document the prefill tile / decode chunk knobs, MiniMax shapes and config sweeps Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 41 +++++++++++++++++-- .../ark/test/README_MOE_W4A8_CN.md | 37 ++++++++++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 3925c57ddf..d9c9676d78 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -191,9 +191,28 @@ qwen3 down (down-proj) : N = 2048, K = 768 Routed expert-token rows are `batch × top_k`, spread round-robin over the 128 experts. Default batches: `128` for prefill and `1` for decode; `--all-shapes` widens them to `{128, 512, 2048, 8192}` and `{1, 2, 8, 16}` respectively. -`test_perf_prefill_compute_bound` adds a single batch of `4096` model tokens -(256 rows per expert) — the smallest sweep point where the 100 TFLOPS goal is -not capped by weight bandwidth. +`test_perf_prefill_compute_bound` adds a single batch sized so every expert +gets 256 rows (4096 model tokens for Qwen3-MoE) — the smallest sweep point where +the 100 TFLOPS goal is not capped by weight bandwidth. + +A second shape group covers MiniMax-M2, matching `test_moe_prefill_perf.py`: + +``` +hidden_size = 3072, intermediate_size = 1536 +num_local_experts = 192, num_experts_per_tok = 8 + +minimax up : N = 1536, K = 3072 +minimax down : N = 3072, K = 1536 +``` + +It matters because both targets are shape dependent: 192 experts spread a given +batch over 1.5× more experts (fewer rows per expert, so a *lower* compute +ceiling at the same batch), while the longer K gives the decode GEMV a longer +sequential stream and the prefill tile more K per tile-load. The compute-bound +batch is derived per model, so MiniMax runs 6144 model tokens for the same 256 +rows per expert. Shape groups are selected with `--models` +(`qwen3` — the default —, `minimax`, a comma-separated list, or `all`); the +heavy-tailed empirical routing for MiniMax lives in `test_moe_prefill_perf.py`. ## How to run @@ -220,8 +239,22 @@ pytest -v -s test_moe_w4a8_perf.py -k compute_bound # Make the performance goals hard assertions instead of a printed verdict pytest -v -s test_moe_w4a8_perf.py -k perf --enforce-targets + +# Add the MiniMax shapes (or --models all for both groups) +pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax + +# Sweep the kernel dispatch configurations and print the fastest equivalent one +pytest -v -s test_moe_w4a8_perf.py -k sweep ``` +`test_perf_decode_config_sweep` and `test_perf_prefill_tile_sweep` build one +workload, prepack it once, then time every dispatch configuration against it — +the decode lane mapping (legacy GEMV plus every `CH` × `NCOLS` combination) and +the prefill work-group tile. Each configuration is checked for numerical +equivalence with the first one, and the table is followed by a `best +configuration` block naming the winning environment variables per shape, so the +tuning knobs can be settled in a single on-hardware run. + The `-s` flag is required to see the printed tables. ### As a standalone script (no pytest) @@ -306,6 +339,8 @@ it for a given deployment. | `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | Token count at or below which `phase="auto"` picks the GEMV (default `128`). | | `ARK_MOE_W4A8_DECODE_KSPLIT` | Coalesced K-split decode mapping; **on by default**. Set to `0` to fall back to the original one-work-item-per-output GEMV (useful for A/B measurements). Ignored when the shape doesn't qualify. | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. | +| `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. | +| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder. A `TileM × TileN` tile re-reads A once per N tile and B once per M tile, so tile traffic is `~ M·N·K · (1/TileM + 1/TileN)` -- at 256 rows/expert the old `128x128` choice pulled ~1.6 GB per grouped GEMM (above the device copy rate), which is why the large rung is now `256x256`. | ## Shape constraints diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index bc22cba652..19b08ea16d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -177,8 +177,26 @@ qwen3 down (down-proj) : N = 2048, K = 768 被路由的 expert-token 行数为 `batch × top_k`,以 round-robin 方式分布到 128 个专 家上。默认 batch:prefill 为 `128`,decode 为 `1`;`--all-shapes` 会分别扩展为 `{128, 512, 2048, 8192}` 和 `{1, 2, 8, 16}`。 -`test_perf_prefill_compute_bound` 额外增加一个 `4096` 模型 token 的 batch (每专家 -256 行) — 这是 100 TFLOPS 目标不再被权重带宽限制的最小扫描点。 +`test_perf_prefill_compute_bound` 额外增加一个 batch,其大小保证每个专家拿到 256 +行 (Qwen3-MoE 为 4096 个模型 token) — 这是 100 TFLOPS 目标不再被权重带宽限制的最 +小扫描点。 + +第二个形状组是 MiniMax-M2,与 `test_moe_prefill_perf.py` 保持一致: + +``` +hidden_size = 3072, intermediate_size = 1536 +num_local_experts = 192, num_experts_per_tok = 8 + +minimax up : N = 1536, K = 3072 +minimax down : N = 3072, K = 1536 +``` + +之所以需要它,是因为两个目标都与形状相关:192 个专家会把同样的 batch 摊到 1.5 倍 +的专家上 (每专家行数更少,因此相同 batch 下的算力上限*更低*),而更长的 K 则让 +decode GEMV 的顺序访存流更长、也让 prefill 的 tile 每次加载覆盖更多 K。compute- +bound 的 batch 按模型推导,因此 MiniMax 用 6144 个模型 token 达到同样的每专家 256 +行。形状组通过 `--models` 选择 (`qwen3` — 默认 —、`minimax`、逗号分隔的列表或 +`all`);MiniMax 的重尾真实路由分布仍在 `test_moe_prefill_perf.py` 中。 ## 如何运行 @@ -205,8 +223,21 @@ pytest -v -s test_moe_w4a8_perf.py -k compute_bound # 把性能目标从提示信息变成硬断言 pytest -v -s test_moe_w4a8_perf.py -k perf --enforce-targets + +# 加入 MiniMax 形状 (--models all 则两组都跑) +pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax + +# 扫描 kernel 的各种 dispatch 配置,并打印最快且数值等价的一个 +pytest -v -s test_moe_w4a8_perf.py -k sweep ``` +`test_perf_decode_config_sweep` 和 `test_perf_prefill_tile_sweep` 只构造一次 +workload、只 prepack 一次,然后用同一份数据依次给每种 dispatch 配置计时——decode +的 lane 映射 (legacy GEMV 以及 `CH` × `NCOLS` 的全部组合) 和 prefill 的 work-group +tile。每种配置都会与第一种配置做数值等价性检查,表格之后还会打印一段 `best +configuration`,按形状给出获胜配置对应的环境变量,因此在硬件上跑一次就能确定这些 +调优开关。 + 需要加 `-s` 才能看到打印出的表格。 ### 作为独立脚本运行 (不依赖 pytest) @@ -287,6 +318,8 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 | `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | `phase="auto"` 时选择 GEMV 的 token 数上限 (默认 `128`)。 | | `ARK_MOE_W4A8_DECODE_KSPLIT` | 合并访存的 K-split decode 映射,**默认开启**。设为 `0` 可回退到原来每个输出一个 work-item 的 GEMV (便于 A/B 对比)。形状不满足条件时该开关无效。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。 | +| `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。 | +| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择。`TileM × TileN` 的 tile 会让 A 每个 N tile 重读一次、B 每个 M tile 重读一次,因此 tile 访存量约为 `M·N·K · (1/TileM + 1/TileN)`——在每专家 256 行时,原来的 `128x128` 每个 grouped GEMM 要搬运约 1.6 GB (已超过设备的拷贝带宽),所以最大档现在改为 `256x256`。 | ## 形状约束 From 35e89aee6cddec759747126e9ed2b43375b44c9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:35:05 +0000 Subject: [PATCH 060/112] fix(ark): carry the 32-byte W4A8 decode chunk as 16-byte sycl::vec sub-vectors Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index f5f750aecf..1847d2fca8 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -818,6 +818,33 @@ constexpr int KSPLIT_WG_SGS = N_TILE; constexpr int KSPLIT_NCOLS_DEFAULT = 2; constexpr int KSPLIT_NCOLS_MAX = 4; +// A lane's `CH`-byte chunk, as a register type. +// +// `sycl::vec` only exists for 1, 2, 3, 4, 8 and 16 elements, so a `CH = 32` +// chunk cannot be spelled `sycl::vec`: that instantiation is a hard +// static_assert in the SYCL headers ("Invalid number of elements for +// sycl::vec"). A chunk is therefore an aggregate of `CH / 16` 16-byte +// sub-vectors covering *consecutive* bytes. The lane still reads one contiguous +// `CH`-byte span at a `CH`-aligned address (`moe_w4a8_decode_ksplit_shape_ok` +// makes every chunk offset a multiple of `CH` off a row base that is a multiple +// of `K`, itself a multiple of `CH`), the sub-vectors are adjacent both in +// memory and in the GRF, and the declared alignment lets IGC fold the pair back +// into a single wider message. At the default `CH = 16` the aggregate holds a +// single `sycl::vec`, i.e. exactly the load this kernel issued +// before. +// +// `operator[]` is only ever called from the fully unrolled inner loops, so the +// sub-vector selection folds away at compile time and no dynamic indexing +// (which would push the chunk out to scratch) is generated. +template +struct alignas(CH) QChunk { + static constexpr int kSub = KSPLIT_CH_DEFAULT; + static_assert(CH % kSub == 0, "chunk width must be a whole number of 16-byte sub-vectors"); + sycl::vec v[CH / kSub]; + + int8_t operator[](int i) const { return v[i / kSub][i % kSub]; } +}; + // `ARK_MOE_W4A8_DECODE_KSPLIT` (default ON). Setting it to "0" / "false" / // "off" / "no" forces the legacy per-lane-strided GEMV, for A/B comparison and // as a regression escape. Re-read on every call so benchmarks can toggle the @@ -913,7 +940,7 @@ void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* s_rows[c] = wscale + (row0 + static_cast(c)) * blks; } - using QVec = sycl::vec; + using QVec = QChunk; float acc[NCOLS]; #pragma unroll From d112a37e4cc1af2429f612767c75b689c6048048 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:08:22 +0000 Subject: [PATCH 061/112] perf(ark): top the W4A8 prefill tile ladder at 128x128 from the measured sweep Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 75 +++++++++++------- .../ark/test/README_MOE_W4A8.md | 76 +++++++++++++++---- .../ark/test/README_MOE_W4A8_CN.md | 66 +++++++++++++--- 3 files changed, 167 insertions(+), 50 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 1847d2fca8..67d4be7107 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -1,8 +1,13 @@ // SYCL MoE W4A8 -- INT4 weights / INT8 compute (prefill + decode) // -// STATUS: NEEDS-HARDWARE-VALIDATION -- this header has not been compiled or -// run on an Intel GPU yet (the authoring environment has no XPU and no SYCL -// compiler). It follows the same porting conventions as its siblings +// STATUS: PARTIALLY HARDWARE-VALIDATED -- `test_perf_prefill_tile_sweep` and +// `test_perf_decode_config_sweep` have been run on BMG, so both phases compile +// and run and their dispatch defaults (tile ladder, decode CH / NCOLS) come +// from those measurements; every swept configuration also passed the +// cross-configuration equivalence check. The accuracy gates against the fp32 +// reference and `test_decode_ksplit_matches_legacy` still need a device run. +// The authoring environment has no XPU and no SYCL compiler, so anything added +// since follows the porting conventions of its siblings // `sycl_tla_moe_prefill_int_dpas.hpp` / `sycl_tla_moe_prefill_fp8_dpas.hpp`. // --------------------------------------------------------------------------- // @@ -319,16 +324,28 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // // M*K * ceil(N/TileN) + N*K * ceil(M/TileM) ~= M*N*K * (1/TileN + 1/TileM) // -// i.e. A is re-read once per N tile and B once per M tile. At the compute-bound -// Qwen3-MoE shape (256 rows/expert, N = 1536, K = 2048) a 128x128 tile re-reads -// A twelve times, for ~1.6 GB of tile traffic per grouped GEMM -- ~470 GB/s at -// the measured 3.3 ms, i.e. above the device's ~390 GB/s copy rate, so the GEMM -// is still memory-bound even though the compact operands are only ~0.6 GB. -// Doubling both extents to 256x256 halves that (`1/256 + 1/256` vs -// `1/128 + 1/128`), which is why the reference `launch_igemm`'s large rung is -// 256x256 and the W4A16 MoE policy uses a 256-wide N tile. `w4a8_policy_large` -// matches it; `w4a8_policy_m_256_n128` keeps the narrower variant reachable -// through `ARK_MOE_W4A8_PREFILL_TILE` for A/B measurement. +// i.e. A is re-read once per N tile and B once per M tile. That argument alone +// says "widen both extents", and it is wrong here: `test_perf_prefill_tile_sweep` +// on BMG (256 rows/expert, bf16 act) measures the 256-wide N tiles *slower* than +// the 128-wide ones on every shape, by a margin no traffic saving comes close to. +// +// shape 128x128 256x128 128x256 256x256 +// qwen3 up 3.377 ms 3.419 ms 4.703 ms 4.668 ms +// qwen3 down 2.614 ms 2.718 ms 3.862 ms 3.835 ms +// minimax up 6.743 ms 6.761 ms 8.964 ms 9.049 ms +// minimax down 7.350 ms 7.101 ms 9.928 ms 9.872 ms +// +// The split is by `TileN`, not by `TileM`: both 128-wide tiles land within ~4% +// of each other and both 256-wide ones ~35-50% behind, whatever `TileM` is. The +// sub-group layouts explain it -- `SGLayout` is 4 sub-groups wide in all four, +// so `TileN = 256` gives every sub-group a 32x64 C fragment instead of 32x32, +// i.e. 128 int32 accumulators per SIMD16 lane instead of 64. That is where the +// register file stops holding the fragment plus the staged A/B tiles, and the +// occupancy (or spill) cost of it swamps the halved tile traffic. +// +// So the ladder tops out at 128x128. The 256-wide policies stay compiled and +// reachable through `ARK_MOE_W4A8_PREFILL_TILE` so the sweep can re-check them +// on a device with a different register budget. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: @@ -629,13 +646,11 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // --------------------------------------------------------------------------- // Prefill driver: policy selection on the average per-expert M. // -// The small-M rungs match the tile ladder of `launch_igemm_kblock` in -// `sycl_tla_s8_gemm.hpp`. The large rung differs from the reference's `m > 1024` -// threshold because a grouped GEMM's M is *per expert*: at 256 rows/expert the -// dense ladder would still pick 128x128 and pay 12-16 re-reads of the A tile -// (see the tile-policy comment above), which is what keeps the compute-bound -// Qwen3-MoE shape memory-bound. 256 rows exactly fill a 256-row tile, so the -// 256x256 policy takes over as soon as the average expert can fill it. +// The rungs match the tile ladder of `launch_igemm_kblock` in +// `sycl_tla_s8_gemm.hpp`, minus its `m > 1024` 256-wide rung: a grouped GEMM's +// M is *per expert*, and the on-hardware sweep (see the tile-policy comment +// above) measures every 256-wide N tile 35-50% slower than 128x128 at 256 +// rows/expert, so the ladder tops out at 128x128 instead. // // `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile // (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything @@ -684,10 +699,8 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) } else if (A_avg_M < 128) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - } else if (A_avg_M < 256) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) } else { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) } #undef ARK_MOE_W4A8_LAUNCH } @@ -806,15 +819,23 @@ void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, // weight load and one 16-byte int8 activation load, the same transactions the // legacy GEMV issues. `ARK_MOE_W4A8_DECODE_KSPLIT_CH=32` doubles them to // 32-byte loads, which halves the number of memory messages per byte and -// doubles the bytes a thread keeps in flight -- the lever for the gap between -// the GEMV's measured streaming rate and the device's copy bandwidth. It costs -// GRF (2 x NCOLS chunks live at once) and needs `blocksize >= SG_SIZE * CH`, -// so it stays opt-in until measured on hardware. +// doubles the bytes a thread keeps in flight; it costs GRF (2 x NCOLS chunks +// live at once) and needs `blocksize >= SG_SIZE * CH`. +// +// Measured (`test_perf_decode_config_sweep`, BMG, 8 routed rows, bf16 act), at +// the default NCOLS: 284.0 -> 278.9 GB/s (qwen3 up), 280.1 -> 244.4 (qwen3 +// down), 268.1 -> 259.9 (minimax up), 315.5 -> 308.7 (minimax down). The wider +// chunk never wins at any NCOLS and costs up to 13%, so 16 stays the default +// and 32 stays an opt-in sweep point. constexpr int KSPLIT_CH_DEFAULT = 16; constexpr int KSPLIT_CH_MAX = 32; // Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group // covers `KSPLIT_WG_SGS * NCOLS` consecutive columns. constexpr int KSPLIT_WG_SGS = N_TILE; +// `NCOLS = 2` is the measured default: it is the fastest configuration on two +// of the four swept shapes and within 2% of the best (`4` on qwen3 down, `1` on +// minimax up) on the other two, while `1` costs 47% on qwen3 up and `4` costs +// 14% on minimax up. constexpr int KSPLIT_NCOLS_DEFAULT = 2; constexpr int KSPLIT_NCOLS_MAX = 4; diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index d9c9676d78..1c31922b87 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -331,6 +331,55 @@ otherwise collide with another layer's weights). Use `cache_prepack=False` on `ark.moe_w4a8` (or `clear_moe_w4a8_prepack_cache()`) if that trade isn't worth it for a given deployment. +## Tuned defaults (measured) + +The defaults below come from one `-k sweep` run on BMG (bf16 activations, 8 +routed rows for decode, 256 rows/expert for prefill). Every configuration is +checked for numerical equivalence with the first one before it is timed. + +### Prefill tile + +| shape | `128x128` | `256x128` | `128x256` | `256x256` | +|---|---|---|---|---| +| qwen3 up | **3.377 ms** | 3.419 ms | 4.703 ms | 4.668 ms | +| qwen3 down | **2.614 ms** | 2.718 ms | 3.862 ms | 3.835 ms | +| minimax up | **6.743 ms** | 6.761 ms | 8.964 ms | 9.049 ms | +| minimax down | 7.350 ms | **7.101 ms** | 9.928 ms | 9.872 ms | + +The 256-wide N tiles are 35–50% *slower* than the 128-wide ones on every shape — +the opposite of what the tile-traffic argument predicts (`1/TileM + 1/TileN` +halves when both extents double, so `256x256` should have moved half the bytes +of `128x128`). `TileM` barely matters; `TileN` does, because `SGLayout` is 4 +sub-groups wide in every policy: `TileN = 256` gives each sub-group a 32×64 C +fragment — 128 int32 accumulators per SIMD16 lane — instead of 32×32 (64), past +the point where the register file still holds the fragment plus the staged A/B +tiles. + +The ladder therefore tops out at **`128x128`**: `< 16` rows/expert → `8x128`, +`< 128` → `64x128`, otherwise `128x128`. The 256-wide policies stay compiled and +selectable with `ARK_MOE_W4A8_PREFILL_TILE`, so the sweep can re-check them on a +device with a different register budget. + +Even at the best tile the swept shapes reach 61.1 / 39.4 / 68.8 / 65.3 TFLOPS +(qwen3 up / down, minimax up / down) — below both the 100 TFLOPS target and the +`2 × 256 rows × bandwidth` roofline for this routing, so the tile is not the only +remaining gap at 256 rows/expert. + +### Decode chunk width and column blocking + +| shape | fastest equivalent config | default (`CH=16`, `NCOLS=2`) | `CH=32`, same `NCOLS` | +|---|---|---|---| +| qwen3 up | ch16 ncols2 — **284.0 GB/s** | 284.0 GB/s | 278.9 GB/s | +| qwen3 down | ch16 ncols4 — **285.7 GB/s** | 280.1 GB/s | 244.4 GB/s | +| minimax up | ch16 ncols1 — **271.0 GB/s** | 268.1 GB/s | 259.9 GB/s | +| minimax down | ch16 ncols2 — **315.5 GB/s** | 315.5 GB/s | 308.7 GB/s | + +`CH = 32` never wins and costs up to 13%, so `16` stays the default. `NCOLS = 2` +is the fastest configuration on two of the four shapes and within 2% of the best +on the other two, while `1` loses 47% on qwen3 up and `4` loses 14% on minimax +up, so it stays the default as well. At those defaults the K-split mapping is +worth 1.09–1.93× over the legacy GEMV. + ## Environment variables | Variable | Effect | @@ -338,9 +387,9 @@ it for a given deployment. | `ARK_MOE_W4A8_AUTO_S8` | Override the AUTO_S8 re-scale block size. Unset / `-1` = one scale per output channel (fastest). Values that aren't a multiple of both `group_size` and 64, or that don't divide K, silently fall back to K. | | `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | Token count at or below which `phase="auto"` picks the GEMV (default `128`). | | `ARK_MOE_W4A8_DECODE_KSPLIT` | Coalesced K-split decode mapping; **on by default**. Set to `0` to fall back to the original one-work-item-per-output GEMV (useful for A/B measurements). Ignored when the shape doesn't qualify. | -| `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. | -| `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. | -| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder. A `TileM × TileN` tile re-reads A once per N tile and B once per M tile, so tile traffic is `~ M·N·K · (1/TileM + 1/TileN)` -- at 256 rows/expert the old `128x128` choice pulled ~1.6 GB per grouped GEMM (above the device copy rate), which is why the large rung is now `256x256`. | +| `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. `2` is the measured default, see [Tuned defaults](#tuned-defaults-measured). | +| `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. Measured slower than `16` on every swept shape, so it is a sweep point rather than a recommendation. | +| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which tops out at `128x128`. The 256-wide N tiles move less tile traffic but measure 35–50% slower (see [Tuned defaults](#tuned-defaults-measured)); `256x128` is within ~4% of `128x128` everywhere and slightly ahead on minimax down. | ## Shape constraints @@ -359,15 +408,16 @@ failing. ## Status -The W4A8 kernel is a new SYCL/CuTe port and is marked -`STATUS: NEEDS-HARDWARE-VALIDATION` in -`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. This script is the -intended on-hardware validation vehicle: run the accuracy sweep first (it will -catch layout/scale bugs immediately), then the perf sweep to tune the tile -ladder and the decode threshold. - -The decode K-split mapping is likewise unvalidated on hardware. Its index math -was checked against the legacy mapping with a host-side mock, and -`test_decode_ksplit_matches_legacy` re-checks it on device; if it ever +The W4A8 kernel is a new SYCL/CuTe port, marked +`STATUS: PARTIALLY HARDWARE-VALIDATED` in +`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. Both perf sweeps have +been run on BMG — that run is where the tile ladder and the decode `CH` / +`NCOLS` defaults come from (see [Tuned defaults](#tuned-defaults-measured)) — and +every swept configuration passed the cross-configuration equivalence check. + +Still to run on device: the accuracy sweep against the fp32 reference (it will +catch layout/scale bugs immediately) and `test_decode_ksplit_matches_legacy`, +which re-checks the K-split mapping against the legacy one. The K-split index +math has otherwise only been checked with a host-side mock; if it ever regresses, `ARK_MOE_W4A8_DECODE_KSPLIT=0` restores the previous behaviour without a rebuild. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 19b08ea16d..53a7480e8b 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -310,6 +310,50 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 在 `ark.moe_w4a8` 上传 `cache_prepack=False` (或调用 `clear_moe_w4a8_prepack_cache()`)。 +## 实测得到的默认值 + +下面的默认值来自 BMG 上的一次 `-k sweep` (bf16 激活,decode 为 8 条 routed +行,prefill 为每专家 256 行)。每种配置在计时之前都会先与第一种配置做数值等价性 +检查。 + +### Prefill tile + +| 形状 | `128x128` | `256x128` | `128x256` | `256x256` | +|---|---|---|---|---| +| qwen3 up | **3.377 ms** | 3.419 ms | 4.703 ms | 4.668 ms | +| qwen3 down | **2.614 ms** | 2.718 ms | 3.862 ms | 3.835 ms | +| minimax up | **6.743 ms** | 6.761 ms | 8.964 ms | 9.049 ms | +| minimax down | 7.350 ms | **7.101 ms** | 9.928 ms | 9.872 ms | + +在所有形状上,N 方向为 256 的 tile 都比 128 的**慢** 35–50%——这与 tile 访存量的 +推导恰好相反 (两个维度同时翻倍时 `1/TileM + 1/TileN` 会减半,`256x256` 本应只搬运 +`128x128` 一半的字节)。起决定作用的不是 `TileM` 而是 `TileN`:所有 policy 的 +`SGLayout` 在 N 方向都是 4 个 sub-group,因此 `TileN = 256` 会让每个 sub-group 的 +C fragment 从 32×32 (每个 SIMD16 lane 64 个 int32 累加器) 变成 32×64 (128 个),超 +出了寄存器堆同时容纳 fragment 与暂存 A/B tile 的容量。 + +因此 tile 阶梯的最大档就是 **`128x128`**:每专家 `< 16` 行 → `8x128`,`< 128` → +`64x128`,其余 → `128x128`。N 方向为 256 的 policy 仍然会被编译,并可通过 +`ARK_MOE_W4A8_PREFILL_TILE` 选择,以便在寄存器预算不同的设备上重新验证。 + +即使选用最优 tile,被扫描的四个形状也只达到 61.1 / 39.4 / 68.8 / 65.3 TFLOPS +(qwen3 up / down、minimax up / down)——既低于 100 TFLOPS 的目标,也低于该路由下 +`2 × 256 行 × 带宽` 的 roofline,说明在每专家 256 行时 tile 并不是唯一的差距来源。 + +### Decode 的 chunk 宽度与列分块 + +| 形状 | 数值等价配置中最快的一个 | 默认值 (`CH=16`、`NCOLS=2`) | 相同 `NCOLS` 下的 `CH=32` | +|---|---|---|---| +| qwen3 up | ch16 ncols2 — **284.0 GB/s** | 284.0 GB/s | 278.9 GB/s | +| qwen3 down | ch16 ncols4 — **285.7 GB/s** | 280.1 GB/s | 244.4 GB/s | +| minimax up | ch16 ncols1 — **271.0 GB/s** | 268.1 GB/s | 259.9 GB/s | +| minimax down | ch16 ncols2 — **315.5 GB/s** | 315.5 GB/s | 308.7 GB/s | + +`CH = 32` 从未取胜,最多还慢 13%,因此默认值保持 `16`。`NCOLS = 2` 在四个形状中的 +两个上最快,在另外两个上也与最优值相差不到 2%;而 `1` 在 qwen3 up 上慢 47%、`4` 在 +minimax up 上慢 14%,因此 `2` 同样保持为默认值。在这组默认值下,K-split 映射相对 +legacy GEMV 的收益为 1.09–1.93×。 + ## 环境变量 | 变量 | 作用 | @@ -317,9 +361,9 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 | `ARK_MOE_W4A8_AUTO_S8` | 覆盖 AUTO_S8 重缩放 block 大小。未设置 / `-1` 表示每个输出通道一个 scale (最快)。如果取值不是 `group_size` 和 64 的公倍数,或不能整除 K,则静默回退为 K。 | | `ARK_MOE_W4A8_DECODE_MAX_TOKENS` | `phase="auto"` 时选择 GEMV 的 token 数上限 (默认 `128`)。 | | `ARK_MOE_W4A8_DECODE_KSPLIT` | 合并访存的 K-split decode 映射,**默认开启**。设为 `0` 可回退到原来每个输出一个 work-item 的 GEMV (便于 A/B 对比)。形状不满足条件时该开关无效。 | -| `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。 | -| `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。 | -| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择。`TileM × TileN` 的 tile 会让 A 每个 N tile 重读一次、B 每个 M tile 重读一次,因此 tile 访存量约为 `M·N·K · (1/TileM + 1/TileN)`——在每专家 256 行时,原来的 `128x128` 每个 grouped GEMM 要搬运约 1.6 GB (已超过设备的拷贝带宽),所以最大档现在改为 `256x256`。 | +| `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。默认值 `2` 来自实测,参见[实测得到的默认值](#实测得到的默认值)。 | +| `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。实测中它在所有形状上都慢于 `16`,因此只作为扫描项而非推荐值。 | +| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择,其最大档为 `128x128`。N 方向为 256 的 tile 虽然访存量更小,但实测慢 35–50% (参见[实测得到的默认值](#实测得到的默认值));`256x128` 在所有形状上与 `128x128` 相差约 4% 以内,在 minimax down 上还略微领先。 | ## 形状约束 @@ -339,10 +383,12 @@ decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 `auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp` 中被标记为 -`STATUS: NEEDS-HARDWARE-VALIDATION`。本脚本正是为在真实硬件上验证它而设计的:请 -先运行精度扫描 (它能立刻暴露 layout / scale 相关的 bug),再运行性能扫描来调优 -tile 阶梯与 decode 阈值。 - -decode 的 K-split 映射同样尚未在硬件上验证。其下标计算已用宿主端 mock 与原映射逐 -一比对,`test_decode_ksplit_matches_legacy` 会在设备上再次校验;一旦出现回归,设置 -`ARK_MOE_W4A8_DECODE_KSPLIT=0` 即可在不重新编译的情况下恢复原有行为。 +`STATUS: PARTIALLY HARDWARE-VALIDATED`。两个性能扫描都已在 BMG 上跑过——tile 阶梯 +以及 decode 的 `CH` / `NCOLS` 默认值正是来自那次运行 (参见[实测得到的默认 +值](#实测得到的默认值)),并且所有被扫描的配置都通过了配置间的数值等价性检查。 + +仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描 (它能立刻暴露 layout / +scale 相关的 bug),以及重新校验 K-split 映射与原映射一致性的 +`test_decode_ksplit_matches_legacy`。除此之外,K-split 的下标计算目前只用宿主端 +mock 比对过;一旦出现回归,设置 `ARK_MOE_W4A8_DECODE_KSPLIT=0` 即可在不重新编译的 +情况下恢复原有行为。 From 210c9ad4d396cfb65baf5edecf152682d00a2382 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:32:10 +0000 Subject: [PATCH 062/112] perf(ark): widen W4A8 act-quant messages and drop the float C shadow at blks==1 Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 277 +++++++++++++++--- 1 file changed, 235 insertions(+), 42 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 67d4be7107..5c32e7db83 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -128,6 +128,9 @@ constexpr int kPrepackOctet = 8; template class MoEW4A8ActQuant; +template +class MoEW4A8ActQuantVec; + template class MoEW4A8ScaleReduce; @@ -182,7 +185,113 @@ inline DeviceScratchPool& expert_map_pool() { // gets by consuming `num_tokens_per_expert` directly. The scan is the verbatim // body of `fill_expert_id_per_token`, including its clamp to // `num_experts - 1` for a routing table that sums to less than `total_tokens`. +// +// Message width (the prefill cost that matters) +// --------------------------------------------- +// This kernel is a pure streaming pass -- it reads `[T, K]` activations twice +// (absmax, then quantize) and writes `[T, K]` int8 -- so at prefill sizes it is +// a sizeable fraction of the whole `moe_gemm_w4a8` call, not a preamble. At +// 32768 routed rows and K = 2048 it touches ~200 MB, next to the ~400 MB the +// grouped GEMM streams for the qwen3 up-proj weights. +// +// The scalar mapping below (`k = lane; k < K; k += SG_SIZE`) moves that traffic +// in the *narrowest* messages the sub-group can issue: 16 lanes x one 16-bit +// element is a 32-byte load and 16 lanes x one int8 is a **16-byte** store, i.e. +// a quarter of a cache line per store message. That is the same defect the +// decode GEMV had before the K-split rewrite, and it was worth 1.09-1.93x there. +// +// `launch_act_dynamic_quant_vec` fixes it the same way: each lane owns `VEC` +// *consecutive* elements, so one message covers `SG_SIZE * VEC` contiguous +// elements -- 256 bytes of activations and 128 bytes of int8 at `VEC = 8`. +// Both passes read the same `sycl::vec`, and the second pass re-reads a row the +// first pass just touched, so it is served by the cache rather than DRAM. +// +// `VEC` is chosen from K: 8 when `K % 128 == 0` (every shipped MoE shape -- +// 768 / 1536 / 2048 / 3072), otherwise 4, which the `K % 64 == 0` shape gate +// always satisfies. A misaligned base pointer (never the case for torch +// allocations or the scratch pool) falls back to the scalar kernel, and +// `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. +// +// `sycl::vec` is used rather than `sycl::vec` +// because `sycl::vec` of `bfloat16` is not universally available; the elements +// are `bit_cast` back one at a time, exactly like the decode kernels' +// `ActVec` loads in `sycl_tla_moe_decode.hpp`. // --------------------------------------------------------------------------- + +// Fold the per-token expert scan (decode only) into the quantization kernel. +// Verbatim body of `moe_decode_detail::fill_expert_id_per_token`. +inline void act_quant_write_scale(float* ascale, int token, float scale, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + ascale[token] = scale; + if (expert_id_per_token == nullptr) return; + int offset = 0; + int expert = num_experts - 1; + for (int e = 0; e < num_experts; ++e) { + const int n = num_tokens_per_expert[e]; + if (token < offset + n) { + expert = e; + break; + } + offset += n; + } + expert_id_per_token[token] = expert; +} + +template +void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); + using ActVec = sycl::vec; + using QVec = sycl::vec; + + // Vectors a lane walks over. `K % (SG_SIZE * VEC) == 0` is checked by the + // caller, so the loop needs no tail. + const int steps = K / (SG_SIZE * VEC); + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); + QVec* out = reinterpret_cast(qact + static_cast(token) * K); + + float local_max = 0.0f; + for (int s = 0; s < steps; ++s) { + const ActVec v = row[static_cast(s) * SG_SIZE + lane]; +#pragma unroll + for (int u = 0; u < VEC; ++u) { + const ScalarT a = sycl::bit_cast(static_cast(v[u])); + local_max = sycl::fmax(local_max, sycl::fabs(static_cast(a))); + } + } + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + for (int s = 0; s < steps; ++s) { + const ActVec v = row[static_cast(s) * SG_SIZE + lane]; + QVec qv; +#pragma unroll + for (int u = 0; u < VEC; ++u) { + const ScalarT a = sycl::bit_cast(static_cast(v[u])); + const float x = sycl::rint(static_cast(a) * inv); + qv[u] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s) * SG_SIZE + lane] = qv; + } + }); +} + template void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, int total_tokens, int K, int* expert_id_per_token = nullptr, @@ -190,6 +299,25 @@ void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); if (total_tokens == 0) return; + // Widest lane chunk this shape and these buffers support. The alignment + // checks never fire for torch allocations or the scratch pool (both are at + // least 256-byte aligned), but a caller-supplied activation view could be + // offset, and an unaligned `sycl::vec` access would be undefined. + if (moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_VEC", true)) { + const auto act_addr = reinterpret_cast(activations); + const auto q_addr = reinterpret_cast(qact); + if (K % (SG_SIZE * 8) == 0 && act_addr % (8 * sizeof(ScalarT)) == 0 && q_addr % 8 == 0) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, expert_id_per_token, + num_tokens_per_expert, num_experts); + return; + } + if (K % (SG_SIZE * 4) == 0 && act_addr % (4 * sizeof(ScalarT)) == 0 && q_addr % 4 == 0) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, expert_id_per_token, + num_tokens_per_expert, num_experts); + return; + } + } + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; sycl::range<2> local{1, static_cast(SG_SIZE)}; @@ -210,20 +338,7 @@ void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t const float scale = absmax / kInt8Max; const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; if (lane == 0) { - ascale[token] = scale; - if (expert_id_per_token != nullptr) { - int offset = 0; - int expert = num_experts - 1; - for (int e = 0; e < num_experts; ++e) { - const int n = num_tokens_per_expert[e]; - if (token < offset + n) { - expert = e; - break; - } - offset += n; - } - expert_id_per_token[token] = expert; - } + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); } for (int k = lane; k < K; k += SG_SIZE) { @@ -346,6 +461,14 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // So the ladder tops out at 128x128. The 256-wide policies stay compiled and // reachable through `ARK_MOE_W4A8_PREFILL_TILE` so the sweep can re-check them // on a device with a different register budget. +// +// Re-sweep note: those numbers were measured when the mainloop also kept a +// float shadow of the C fragment live throughout (see `xe_gemm_w4a8`), which +// *doubled* the per-lane C footprint -- 128 + 128 registers at `TileN = 256`, +// i.e. the entire 256-register large-GRF file. The single-block epilogue no +// longer allocates it, so the 256-wide policies now ask for half of what they +// did when they lost, and are worth re-sweeping on device before the ladder is +// considered settled. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: @@ -394,13 +517,48 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // * A/B/D base pointers are the per-expert slices. // // `blks == 1` (the AUTO_S8 `group=-1` default) collapses the outer loop to a -// single full-K int32 accumulation -- the maximum-efficiency shape. +// single full-K int32 accumulation -- the maximum-efficiency shape -- and gets +// its own instantiation, because on this architecture the *register file*, not +// the tile traffic, is what the prefill GEMM runs out of. +// +// Why `blks == 1` is specialized (the register-file argument) +// ----------------------------------------------------------- +// The blocked path needs two C fragments: the int32 DPAS accumulator `tCrC`, +// cleared once per re-scale block, and a float `tFrC` that survives across +// blocks because the per-block weight scale has to be applied before the next +// block overwrites `tCrC`. Both are the size of the work-group tile divided by +// the sub-group count, and a lane holds them in GRF for the *entire* mainloop: +// +// tile SG C fragment int32 regs/lane + float regs/lane +// 128x128 32 x 32 64 64 +// 128x256 32 x 64 128 128 +// +// With `grf_size<256>` a lane has 256 registers in total, so at 128x128 the +// float shadow alone reserves a quarter of the register file for the whole +// mainloop, and at 128x256 the two fragments together *are* the register file +// -- leaving nothing for the staged A/B tiles. That is the measured cliff +// documented in the tile-policy comment above (256-wide N tiles 35-50% slower +// than 128-wide ones, split by `TileN` and not by `TileM`), and it is pure +// overhead when `blks == 1`: with one block there is nothing to carry across +// blocks, so the scale can be folded on the way out and no float fragment +// needs to exist while the mainloop runs. +// +// The single-block epilogue therefore keeps only `tCrC` live and applies +// `scale_b[col] * scale_a[row]` in one pass, exactly like the `AccumBlock == +// false` branch of the reference `igemm_device_impl`. // // The epilogue writes through the raw `[m, n]` row-major output pointer using // the coordinates of `thr_mma.partition_C(...)`, exactly like the reference, // because the int32 accumulator has to be converted and scaled per element -// anyway. Bounds are always checked: a grouped GEMM's per-expert M is -// arbitrary, so tiles at the M edge are partial. +// anyway. A grouped GEMM's per-expert M is arbitrary, so tiles at the M edge +// are partial and the *store* is always predicated -- but the scale *loads* +// are not: their indices are clamped into range instead. Both scale reads are +// then unconditional loads at a compile-time offset from a uniform base, which +// is what lets the compiler collapse the `size(tCrC)` per-element reads into +// the handful of distinct addresses a sub-group's fragment actually covers +// (all lanes of a row group share `scale_a[row]`, and a lane repeats the same +// `scale_b[col]` for every row it owns). Under the previous `continue` guard +// each read sat in its own basic block and none of that could be hoisted. // --------------------------------------------------------------------------- template CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, const float* scale_a, @@ -440,7 +598,6 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con Tensor tBgB = thr_copy_b.partition_S(gB); Tensor tCrC = partition_fragment_C(mma, select<0, 1>(wg_tile)); - Tensor tFrC = make_tensor_like(tCrC); Tensor tCgC = thr_mma.partition_C(gC); auto prefetch_a = make_block_2d_prefetch(copy_a); @@ -457,10 +614,26 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con const int k_tile_count = blks * k_tiles_per_block; int k_tile_prefetch = 0; - CUTE_UNROLL - for (int i = 0; i < size(tFrC); ++i) { - tFrC(i) = 0.0f; - } + // One k-tile of the DPAS pipeline. Shared by both paths so the two + // instantiations differ only in what they keep live around it. + auto run_k_tile = [&](int k_tile) { + barrier_arrive(barrier_scope); + + copy(copy_a, tAgA(_, _, _, k_tile), tArA); + copy(copy_b, tBgB(_, _, _, k_tile), tBrB); + + if (k_tile_prefetch < k_tile_count) { + prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); + prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); + } + ++k_tile_prefetch; + + reorder(tArA, tCrA); + reorder(tBrB, tCrB); + cute::gemm(mma, tCrA, tCrB, tCrC); + + barrier_wait(barrier_scope); + }; CUTE_UNROLL for (; k_tile_prefetch < prefetch_dist && k_tile_prefetch < k_tile_count; ++k_tile_prefetch) { @@ -468,37 +641,54 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); } - for (int ib = 0; ib < blks; ++ib) { + // `blks` is a kernel argument, so it is uniform across the work-group and + // this branch never splits the split-barrier pairing below. + if (blks == 1) { clear(tCrC); - for (int bk = 0; bk < k_tiles_per_block; ++bk) { - const int k_tile = ib * k_tiles_per_block + bk; + for (int k_tile = 0; k_tile < k_tile_count; ++k_tile) { + run_k_tile(k_tile); + } - barrier_arrive(barrier_scope); + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + // Clamp rather than branch: an out-of-range element's value is dropped + // by the guarded store, and unconditional loads let the redundant reads + // across the fragment collapse. `m` and `n` are both >= 1 here (an + // expert with no rows contributes no tiles). + const int row_in = row < m ? row : m - 1; + const int col_in = col < n ? col : n - 1; + const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * scale_a[row_in]; + if (row < m && col < n) { + c[static_cast(row) * n + col] = static_cast(value); + } + } + return; + } - copy(copy_a, tAgA(_, _, _, k_tile), tArA); - copy(copy_b, tBgB(_, _, _, k_tile), tBrB); + Tensor tFrC = make_tensor_like(tCrC); - if (k_tile_prefetch < k_tile_count) { - prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); - prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); - } - ++k_tile_prefetch; + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + tFrC(i) = 0.0f; + } - reorder(tArA, tCrA); - reorder(tBrB, tCrB); - cute::gemm(mma, tCrA, tCrB, tCrC); + for (int ib = 0; ib < blks; ++ib) { + clear(tCrC); - barrier_wait(barrier_scope); + for (int bk = 0; bk < k_tiles_per_block; ++bk) { + run_k_tile(ib * k_tiles_per_block + bk); } CUTE_UNROLL for (int i = 0; i < size(tCrC); ++i) { auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); const int col = static_cast(get<1>(coord)); - if (row >= m || col >= n) continue; - tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col) * blks + ib]; + const int col_in = col < n ? col : n - 1; + tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col_in) * blks + ib]; } } @@ -507,8 +697,11 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con auto coord = tCgC(i); const int row = static_cast(get<0>(coord)); const int col = static_cast(get<1>(coord)); - if (row >= m || col >= n) continue; - c[static_cast(row) * n + col] = static_cast(tFrC(i) * scale_a[row]); + const int row_in = row < m ? row : m - 1; + const float value = tFrC(i) * scale_a[row_in]; + if (row < m && col < n) { + c[static_cast(row) * n + col] = static_cast(value); + } } } From 1197fe216a04229b12c23e77b9ccefd5ef6e77b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:36:36 +0000 Subject: [PATCH 063/112] test(ark): sweep the W4A8 act-quant message width and assert bit-identity; docs(ark): document both prefill changes Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 88 +++++++++++++++++-- .../ark/test/README_MOE_W4A8_CN.md | 74 +++++++++++++++- .../ark/test/test_moe_w4a8_perf.py | 62 +++++++++++++ 3 files changed, 215 insertions(+), 9 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 1c31922b87..b6b6c86e62 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -140,6 +140,65 @@ is derived inside the activation-quantization kernel — which already runs one sub-group per token — instead of by a separate `fill_expert_id_per_token` launch. At batch 1 the entire GEMV takes ~45 µs, so a saved launch is not noise. +## Prefill: message width and register pressure + +Two costs sit next to the grouped GEMM at prefill sizes, and both were paid in +full before this change. + +**Activation quantization.** Converting the routed activations to int8 is a pure +streaming pass — read `[T, K]` twice (absmax, then quantize), write `[T, K]` +int8. At 32768 routed rows and `K = 2048` that is ~200 MB, next to the ~400 MB +the qwen3 up-proj GEMM streams for weights, so it is a real share of the call +rather than a preamble. The original mapping (`k = lane; k += SG_SIZE`) moved it +in the *narrowest* messages a sub-group can issue: 16 lanes × one 16-bit element +is a 32-byte load, and 16 lanes × one int8 is a **16-byte store** — a quarter of +a cache line per store message. That is exactly the defect the decode GEMV had +before the K-split rewrite, where fixing it was worth 1.09–1.93×. + +Each lane now owns `VEC` *consecutive* elements, so one message covers +`SG_SIZE × VEC` contiguous elements: 256 bytes of activations and 128 bytes of +int8 at `VEC = 8`. `VEC` is picked from K — 8 when `K % 128 == 0` (every shipped +MoE shape: 768 / 1536 / 2048 / 3072), otherwise 4, which the `K % 64 == 0` shape +gate always satisfies — and a misaligned base pointer falls back to the scalar +kernel. Nothing that rounds is reordered: the per-lane partial reduction is +`fmax`, which is exact and order-independent, so both mappings feed the +sub-group reduce the same absmax and quantize every element identically. +`test_act_quant_vec_matches_scalar` asserts the two are **bit-identical**, and +`ARK_MOE_W4A8_ACT_QUANT_VEC=0` restores the scalar mapping for A/B measurement. + +**The GEMM epilogue.** The mainloop kept two C fragments live: the int32 DPAS +accumulator, cleared once per AUTO_S8 re-scale block, and a float shadow that +had to survive across blocks because each block's weight scale is applied before +the next block overwrites the accumulator. A lane holds both in GRF for the +*entire* mainloop: + +| tile | sub-group C fragment | int32 regs/lane | + float regs/lane | +|---|---|---|---| +| `128x128` | 32 × 32 | 64 | 64 | +| `128x256` | 32 × 64 | 128 | 128 | + +With `grf_size<256>` a lane has 256 registers, so at `128x128` the float shadow +alone reserved a quarter of the register file for the whole mainloop, and at +`128x256` the two fragments together *are* the register file — leaving nothing +for the staged A/B tiles. That is the measured 256-wide cliff in +[Tuned defaults](#tuned-defaults-measured), and at the default re-scale block it +was pure overhead: `blks == 1` (the AUTO_S8 `group=-1` default) has nothing to +carry across blocks, so the scale can be folded on the way out instead. That +path now runs without the float fragment, and applies +`scale_b[col] × scale_a[row]` in a single pass — the same shape as the +`AccumBlock == false` branch of the reference dense int8 GEMM. + +The same epilogue also stopped branching around out-of-range elements. A grouped +GEMM's per-expert M is arbitrary, so tiles at the M edge are partial and the +*store* must stay predicated — but the scale *loads* need not be: their indices +are clamped into range instead, which makes both reads unconditional loads at a +compile-time offset from a uniform base. That is what lets the compiler collapse +the per-element reads into the handful of distinct addresses a sub-group's +fragment actually covers (all lanes of a row group share `scale_a[row]`, and a +lane repeats the same `scale_b[col]` for every row it owns); under the previous +`continue` guard each read sat in its own basic block and none of it could be +hoisted. + ## What the script measures ### Accuracy table @@ -247,11 +306,12 @@ pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax pytest -v -s test_moe_w4a8_perf.py -k sweep ``` -`test_perf_decode_config_sweep` and `test_perf_prefill_tile_sweep` build one -workload, prepack it once, then time every dispatch configuration against it — -the decode lane mapping (legacy GEMV plus every `CH` × `NCOLS` combination) and -the prefill work-group tile. Each configuration is checked for numerical -equivalence with the first one, and the table is followed by a `best +`test_perf_decode_config_sweep`, `test_perf_prefill_tile_sweep` and +`test_perf_prefill_act_quant_sweep` build one workload, prepack it once, then +time every dispatch configuration against it — the decode lane mapping (legacy +GEMV plus every `CH` × `NCOLS` combination), the prefill work-group tile, and the +activation-quantization message width. Each configuration is checked for +numerical equivalence with the first one, and the table is followed by a `best configuration` block naming the winning environment variables per shape, so the tuning knobs can be settled in a single on-hardware run. @@ -365,6 +425,14 @@ Even at the best tile the swept shapes reach 61.1 / 39.4 / 68.8 / 65.3 TFLOPS `2 × 256 rows × bandwidth` roofline for this routing, so the tile is not the only remaining gap at 256 rows/expert. +> These numbers were measured while the mainloop still kept a float shadow of the +> C fragment live throughout, which doubled the per-lane C footprint — 128 + 128 +> registers at `TileN = 256`, i.e. the entire large-GRF file. The single-block +> epilogue no longer allocates it (see +> [Prefill: message width and register pressure](#prefill-message-width-and-register-pressure)), +> so the 256-wide policies now ask for half of what they did when they lost and +> are worth re-sweeping before the ladder is considered settled. + ### Decode chunk width and column blocking | shape | fastest equivalent config | default (`CH=16`, `NCOLS=2`) | `CH=32`, same `NCOLS` | @@ -390,6 +458,7 @@ worth 1.09–1.93× over the legacy GEMV. | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. `2` is the measured default, see [Tuned defaults](#tuned-defaults-measured). | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. Measured slower than `16` on every swept shape, so it is a sweep point rather than a recommendation. | | `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which tops out at `128x128`. The 256-wide N tiles move less tile traffic but measure 35–50% slower (see [Tuned defaults](#tuned-defaults-measured)); `256x128` is within ~4% of `128x128` everywhere and slightly ahead on minimax down. | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | ## Shape constraints @@ -421,3 +490,12 @@ which re-checks the K-split mapping against the legacy one. The K-split index math has otherwise only been checked with a host-side mock; if it ever regresses, `ARK_MOE_W4A8_DECODE_KSPLIT=0` restores the previous behaviour without a rebuild. + +The two prefill changes above are in the same position: the vectorized +activation quantizer and the single-block epilogue have been reasoned through +but not timed. `test_act_quant_vec_matches_scalar` and +`test_perf_prefill_act_quant_sweep` are the device checks for the first +(`ARK_MOE_W4A8_ACT_QUANT_VEC=0` reverts it without a rebuild); the second has no +knob because it is the same arithmetic in the same order — its effect shows up +as a re-sweep of `test_perf_prefill_tile_sweep`, where the 256-wide tiles should +move now that they no longer need a doubled C fragment. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 53a7480e8b..cfaea90077 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -126,6 +126,56 @@ decode 每步还**少启动一个 kernel**:每个 token 的 expert id 改为 `fill_expert_id_per_token`。batch 1 时整个 GEMV 只有约 45 µs,省下一次 launch 并非 可忽略的噪声。 +## Prefill:访存消息宽度与寄存器压力 + +在 prefill 规模下,grouped GEMM 旁边还有两项开销,而在本次改动之前它们都是按最坏 +情况付出的。 + +**激活量化。** 把路由后的激活转成 int8 是一个纯流式过程——读两遍 `[T, K]` (先求 +absmax,再量化),写一遍 `[T, K]` 的 int8。在 32768 条路由行、`K = 2048` 时这就是约 +200 MB,与 qwen3 up-proj GEMM 为权重流动的约 400 MB 处于同一量级,因此它是整次调用 +中实打实的一部分开销,而不是可忽略的前置步骤。原先的映射 +(`k = lane; k += SG_SIZE`) 用的是 sub-group 能发出的**最窄**消息:16 个 lane × 1 个 +16 位元素是一条 32 字节 load,16 个 lane × 1 个 int8 则是一条 **16 字节 store**—— +每条 store 消息只占 cache line 的四分之一。这正是 decode GEMV 在 K-split 重写之前 +存在的问题,而在那里修复它带来了 1.09–1.93 倍的收益。 + +现在每个 lane 负责 `VEC` 个**连续**元素,因此一条消息覆盖 `SG_SIZE × VEC` 个连续元 +素:`VEC = 8` 时是 256 字节激活与 128 字节 int8。`VEC` 由 K 决定——`K % 128 == 0` +时取 8 (所有已上线的 MoE 形状均满足:768 / 1536 / 2048 / 3072),否则取 4,而 +`K % 64 == 0` 的形状约束保证后者总能成立——基址未对齐时则回退到标量 kernel。任何会 +产生舍入的步骤都没有被重排:每个 lane 的局部归约用的是 `fmax`,它精确且与顺序无 +关,因此两种映射送进 sub-group 归约的 absmax 完全相同,每个元素的量化结果也完全相 +同。`test_act_quant_vec_matches_scalar` 断言两者**逐位相同**,而 +`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射以便做 A/B 对比测量。 + +**GEMM 尾声 (epilogue)。** 原先的主循环同时保持两份 C fragment:int32 的 DPAS 累加 +器 (每个 AUTO_S8 重缩放 block 清零一次),以及一份必须跨 block 存活的 float 影子—— +因为每个 block 的权重 scale 必须在下一个 block 覆盖累加器之前应用。在**整个**主循 +环期间,每个 lane 都要把两者留在 GRF 中: + +| tile | sub-group C fragment | 每 lane int32 寄存器 | + 每 lane float 寄存器 | +|---|---|---|---| +| `128x128` | 32 × 32 | 64 | 64 | +| `128x256` | 32 × 64 | 128 | 128 | + +在 `grf_size<256>` 下每个 lane 只有 256 个寄存器,因此 `128x128` 时仅 float 影子就 +在整个主循环里占掉了四分之一的寄存器堆,而 `128x256` 时两份 fragment 加起来*就是* +整个寄存器堆——留给暂存 A/B tile 的空间为零。这正是[实测得到的默认值](#实测得到的默认值) +中 N 方向 256 的性能悬崖;而在默认重缩放 block 下它完全是白付的开销:`blks == 1` +(AUTO_S8 `group=-1` 默认值) 根本没有需要跨 block 携带的东西,scale 完全可以在写出时 +再折进去。该路径现在不再分配 float fragment,而是一次遍历就应用 +`scale_b[col] × scale_a[row]`——与参考的稠密 int8 GEMM 中 `AccumBlock == false` +分支的结构一致。 + +同一段 epilogue 也不再对越界元素做分支跳过。grouped GEMM 中每个专家的 M 是任意的, +因此 M 边缘的 tile 是部分 tile,**store** 必须保持谓词保护——但 scale 的 **load** +不必:改为把下标钳制到合法范围内,两次读取就都变成了相对于统一基址、编译期偏移的无 +条件 load。正是这一点让编译器能把逐元素的读取收敛成一个 sub-group 的 fragment 实际 +覆盖的那少数几个不同地址 (同一行组的所有 lane 共享 `scale_a[row]`,而一个 lane 对 +它负责的每一行都重复同一个 `scale_b[col]`);在原先的 `continue` 保护下,每次读取都 +位于各自独立的基本块中,这些优化一个也做不了。 + ## 脚本测量的内容 ### 精度表 @@ -231,10 +281,11 @@ pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax pytest -v -s test_moe_w4a8_perf.py -k sweep ``` -`test_perf_decode_config_sweep` 和 `test_perf_prefill_tile_sweep` 只构造一次 -workload、只 prepack 一次,然后用同一份数据依次给每种 dispatch 配置计时——decode -的 lane 映射 (legacy GEMV 以及 `CH` × `NCOLS` 的全部组合) 和 prefill 的 work-group -tile。每种配置都会与第一种配置做数值等价性检查,表格之后还会打印一段 `best +`test_perf_decode_config_sweep`、`test_perf_prefill_tile_sweep` 和 +`test_perf_prefill_act_quant_sweep` 只构造一次 workload、只 prepack 一次,然后用同 +一份数据依次给每种 dispatch 配置计时——decode 的 lane 映射 (legacy GEMV 以及 +`CH` × `NCOLS` 的全部组合)、prefill 的 work-group tile,以及激活量化的消息宽度。每 +种配置都会与第一种配置做数值等价性检查,表格之后还会打印一段 `best configuration`,按形状给出获胜配置对应的环境变量,因此在硬件上跑一次就能确定这些 调优开关。 @@ -340,6 +391,13 @@ C fragment 从 32×32 (每个 SIMD16 lane 64 个 int32 累加器) 变成 32×64 (qwen3 up / down、minimax up / down)——既低于 100 TFLOPS 的目标,也低于该路由下 `2 × 256 行 × 带宽` 的 roofline,说明在每专家 256 行时 tile 并不是唯一的差距来源。 +> 上述数据是在主循环仍然全程保持一份 C fragment 的 float 影子时测得的,那使每个 +> lane 的 C 占用翻倍——`TileN = 256` 时是 128 + 128 个寄存器,即整个 large-GRF 寄 +> 存器堆。单 block 的 epilogue 已经不再分配它 (参见 +> [Prefill:访存消息宽度与寄存器压力](#prefill访存消息宽度与寄存器压力)),因此 N +> 方向为 256 的 policy 现在所需的寄存器只有当初落败时的一半,在认定 tile 阶梯已经 +> 定型之前值得重新扫描一次。 + ### Decode 的 chunk 宽度与列分块 | 形状 | 数值等价配置中最快的一个 | 默认值 (`CH=16`、`NCOLS=2`) | 相同 `NCOLS` 下的 `CH=32` | @@ -364,6 +422,7 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。默认值 `2` 来自实测,参见[实测得到的默认值](#实测得到的默认值)。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。实测中它在所有形状上都慢于 `16`,因此只作为扫描项而非推荐值。 | | `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择,其最大档为 `128x128`。N 方向为 256 的 tile 虽然访存量更小,但实测慢 35–50% (参见[实测得到的默认值](#实测得到的默认值));`256x128` 在所有形状上与 `128x128` 相差约 4% 以内,在 minimax down 上还略微领先。 | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | ## 形状约束 @@ -392,3 +451,10 @@ scale 相关的 bug),以及重新校验 K-split 映射与原映射一致性的 `test_decode_ksplit_matches_legacy`。除此之外,K-split 的下标计算目前只用宿主端 mock 比对过;一旦出现回归,设置 `ARK_MOE_W4A8_DECODE_KSPLIT=0` 即可在不重新编译的 情况下恢复原有行为。 + +上述两项 prefill 改动同样如此:向量化激活量化与单 block epilogue 只经过推导,尚未 +实测计时。前者在设备上的检查是 `test_act_quant_vec_matches_scalar` 与 +`test_perf_prefill_act_quant_sweep` (设置 `ARK_MOE_W4A8_ACT_QUANT_VEC=0` 即可在不 +重新编译的情况下回退);后者没有对应开关,因为它是完全相同的算术、完全相同的顺序 +——其效果体现在重新跑一次 `test_perf_prefill_tile_sweep`:N 方向为 256 的 tile 不 +再需要双倍的 C fragment,其排名应当发生变化。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 752cb9af8b..0b05431067 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -952,6 +952,16 @@ def run_perf( (f"tile {tile}", {"ARK_MOE_W4A8_PREFILL_TILE": None if tile == "auto" else tile}) for tile in _PREFILL_TILES ] +# Prefill: activation-quantization message width. The vectorized kernel hands +# each lane ``VEC`` *consecutive* elements, turning the scalar mapping's 32-byte +# loads / 16-byte stores into 256-byte / 128-byte ones. At prefill sizes this +# pass streams ~200 MB next to the GEMM's ~400 MB, so it is a real share of the +# call, not a preamble. +_ACT_QUANT_CONFIGS = [ + ("act-quant scalar", {"ARK_MOE_W4A8_ACT_QUANT_VEC": "0"}), + ("act-quant vec", {"ARK_MOE_W4A8_ACT_QUANT_VEC": "1"}), +] + _SWEEP_MIN_SNR_DB = 40.0 @@ -1186,6 +1196,58 @@ def test_perf_prefill_tile_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"prefill tile {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_act_quant_sweep(self, request): + """Time both activation-quantization mappings at the compute-bound batch. + + Quantizing the routed activations is a pure streaming pass -- read + ``[T, K]`` twice, write ``[T, K]`` int8 -- so its cost is set by how + wide a message the sub-group issues, not by arithmetic. The scalar + mapping (``k = lane; k += SG_SIZE``) stores 16 bytes per message, a + quarter of a cache line; the vectorized one gives each lane + ``VEC`` consecutive elements instead. Both are dispatch-time + choices, so one run measures the pair against the same workload. + """ + rows = run_config_sweep("prefill", _ACT_QUANT_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"act-quant config {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + + def test_act_quant_vec_matches_scalar(self): + """The vectorized activation quantizer must be bit-identical to the scalar one. + + Unlike the K-split decode rewrite, this one reorders nothing that + rounds: the per-lane partial reduction is ``fmax``, which is exact + and order-independent, so both mappings feed the sub-group reduce + the same absmax and therefore the same reciprocal. Every element is + then put through the same ``rint``/``clamp`` expression, and the + GEMM that consumes the int8 is deterministic. Only the lane -> K + assignment differs, so any difference at all is a bug (a wrong + vector index, a row-stride slip, or a missed tail). + """ + case = _build_case( + _QWEN3_NK[0][1], + _QWEN3_NK[0][2], + _QWEN3_E, + _PREFILL_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for flag in ("0", "1"): + with _env_override(ARK_MOE_W4A8_ACT_QUANT_VEC=flag): + outs[flag] = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + assert torch.equal(outs["0"], outs["1"]), ( + "vectorized activation quantization disagrees with the scalar mapping: " + f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" + ) + def test_decode_ksplit_matches_legacy(self): """The K-split decode mapping must agree with the legacy one. From 95e785333cd758bf081ba1dcbe9d217ed9e7769c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:57:19 +0000 Subject: [PATCH 064/112] perf(ark): add the measured 256x128 prefill rung and an interior-tile epilogue Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 220 +++++++++++++----- .../ark/test/README_MOE_W4A8.md | 162 ++++++++----- .../ark/test/README_MOE_W4A8_CN.md | 140 ++++++----- .../ark/test/test_moe_w4a8_perf.py | 67 ++++++ 4 files changed, 414 insertions(+), 175 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 5c32e7db83..2c1349dd20 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -1,14 +1,18 @@ // SYCL MoE W4A8 -- INT4 weights / INT8 compute (prefill + decode) // -// STATUS: PARTIALLY HARDWARE-VALIDATED -- `test_perf_prefill_tile_sweep` and -// `test_perf_decode_config_sweep` have been run on BMG, so both phases compile -// and run and their dispatch defaults (tile ladder, decode CH / NCOLS) come -// from those measurements; every swept configuration also passed the +// STATUS: PARTIALLY HARDWARE-VALIDATED -- `test_perf_prefill_tile_sweep`, +// `test_perf_prefill_act_quant_sweep`, `test_perf_decode_config_sweep`, +// `test_act_quant_vec_matches_scalar` and `test_decode_ksplit_matches_legacy` +// have been run on BMG, so both phases compile and run and every dispatch +// default (tile ladder, activation-quant message width, decode CH / NCOLS) +// comes from those measurements; every swept configuration also passed the // cross-configuration equivalence check. The accuracy gates against the fp32 -// reference and `test_decode_ksplit_matches_legacy` still need a device run. -// The authoring environment has no XPU and no SYCL compiler, so anything added -// since follows the porting conventions of its siblings -// `sycl_tla_moe_prefill_int_dpas.hpp` / `sycl_tla_moe_prefill_fp8_dpas.hpp`. +// reference still need a device run. The authoring environment has no XPU and +// no SYCL compiler, so anything added since follows the porting conventions of +// its siblings `sycl_tla_moe_prefill_int_dpas.hpp` / +// `sycl_tla_moe_prefill_fp8_dpas.hpp` -- currently the interior-tile epilogue +// (`ARK_MOE_W4A8_PREFILL_FULL_TILE`), which is pure C++ around the same +// arithmetic. // --------------------------------------------------------------------------- // // What this file implements @@ -88,6 +92,7 @@ #include #include #include +#include #ifdef ARK_XPU #include @@ -212,6 +217,12 @@ inline DeviceScratchPool& expert_map_pool() { // allocations or the scratch pool) falls back to the scalar kernel, and // `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. // +// `test_perf_prefill_act_quant_sweep` on BMG (256 rows/expert, bf16 act) puts +// the widened messages at 1.14x (qwen3 up), 1.07x (qwen3 down), 1.10x (minimax +// up) and 1.07x (minimax down) of the scalar mapping on the *whole* +// `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of +// prefill than that, since the GEMM around it is unchanged. +// // `sycl::vec` is used rather than `sycl::vec` // because `sycl::vec` of `bfloat16` is not universally available; the elements // are `bit_cast` back one at a time, exactly like the decode kernels' @@ -439,36 +450,40 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // // M*K * ceil(N/TileN) + N*K * ceil(M/TileM) ~= M*N*K * (1/TileN + 1/TileM) // -// i.e. A is re-read once per N tile and B once per M tile. That argument alone -// says "widen both extents", and it is wrong here: `test_perf_prefill_tile_sweep` -// on BMG (256 rows/expert, bf16 act) measures the 256-wide N tiles *slower* than -// the 128-wide ones on every shape, by a margin no traffic saving comes close to. +// i.e. A is re-read once per N tile and B once per M tile. Doubling `TileM` is +// the half of that argument which survives measurement: at 256 rows/expert it +// takes B from "read twice per expert" to "read once", i.e. -25% tile traffic, +// while leaving the per-lane C fragment at 32x32 (`SGLayout` grows to 8x4 with +// the tile). Doubling `TileN` does not: `SGLayout` stays 4 sub-groups wide, so +// `TileN = 256` gives every sub-group a 32x64 fragment -- 128 int32 +// accumulators per SIMD16 lane instead of 64 -- and that is where the register +// file stops holding the fragment plus the staged A/B tiles. // -// shape 128x128 256x128 128x256 256x256 -// qwen3 up 3.377 ms 3.419 ms 4.703 ms 4.668 ms -// qwen3 down 2.614 ms 2.718 ms 3.862 ms 3.835 ms -// minimax up 6.743 ms 6.761 ms 8.964 ms 9.049 ms -// minimax down 7.350 ms 7.101 ms 9.928 ms 9.872 ms +// `test_perf_prefill_tile_sweep` on BMG (256 rows/expert, bf16 act), re-run +// after the vectorized activation quantizer and the single-block epilogue +// landed: // -// The split is by `TileN`, not by `TileM`: both 128-wide tiles land within ~4% -// of each other and both 256-wide ones ~35-50% behind, whatever `TileM` is. The -// sub-group layouts explain it -- `SGLayout` is 4 sub-groups wide in all four, -// so `TileN = 256` gives every sub-group a 32x64 C fragment instead of 32x32, -// i.e. 128 int32 accumulators per SIMD16 lane instead of 64. That is where the -// register file stops holding the fragment plus the staged A/B tiles, and the -// occupancy (or spill) cost of it swamps the halved tile traffic. +// shape 128x128 256x128 128x256 256x256 +// qwen3 up 2.846 ms 2.914 ms 2.938 ms 3.057 ms +// qwen3 down 2.078 ms 2.049 ms 2.170 ms 2.387 ms +// minimax up 5.866 ms 5.856 ms 5.997 ms 5.861 ms +// minimax down 6.271 ms 5.676 ms 6.001 ms 6.250 ms // -// So the ladder tops out at 128x128. The 256-wide policies stay compiled and -// reachable through `ARK_MOE_W4A8_PREFILL_TILE` so the sweep can re-check them -// on a device with a different register budget. +// The 35-50% cliff the previous sweep saw on every 256-wide N tile is gone -- +// it was the float C shadow the mainloop used to keep live (see +// `xe_gemm_w4a8`), which doubled the per-lane C footprint and made `TileN = +// 256` ask for the entire 256-register large-GRF file. What is left is a +// 3-8% deficit, so the ladder still stays 128 wide in N. // -// Re-sweep note: those numbers were measured when the mainloop also kept a -// float shadow of the C fragment live throughout (see `xe_gemm_w4a8`), which -// *doubled* the per-lane C footprint -- 128 + 128 registers at `TileN = 256`, -// i.e. the entire 256-register large-GRF file. The single-block epilogue no -// longer allocates it, so the 256-wide policies now ask for half of what they -// did when they lost, and are worth re-sweeping on device before the ladder is -// considered settled. +// In M the two down-projections now prefer the 256-row tile -- 1.4% (qwen3 +// down) and 9.5% (minimax down) faster than 128x128 -- while the two +// up-projections are a wash: 0.2% faster on minimax up and 2.4% slower on +// qwen3 up. The same run measures the ladder's own 128x128 choice 0.3-6% apart +// from the explicit `128x128` row, so only minimax down is clearly outside +// run-to-run noise -- hence the M rung is taken only where it measured faster +// (`N >= K`, the down-projection regime; see `moe_w4a8_prefill_dispatch`), and +// every policy stays reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a +// re-sweep on a device with a different register budget. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: @@ -551,7 +566,7 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // the coordinates of `thr_mma.partition_C(...)`, exactly like the reference, // because the int32 accumulator has to be converted and scaled per element // anyway. A grouped GEMM's per-expert M is arbitrary, so tiles at the M edge -// are partial and the *store* is always predicated -- but the scale *loads* +// are partial and their *store* has to be predicated -- but the scale *loads* // are not: their indices are clamped into range instead. Both scale reads are // then unconditional loads at a compile-time offset from a uniform base, which // is what lets the compiler collapse the `size(tCrC)` per-element reads into @@ -559,11 +574,32 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // (all lanes of a row group share `scale_a[row]`, and a lane repeats the same // `scale_b[col]` for every row it owns). Under the previous `continue` guard // each read sat in its own basic block and none of that could be hoisted. +// +// Interior tiles skip the guard entirely (the cost that shows up at small K) +// ------------------------------------------------------------------------- +// `m`, `n`, `m_coord` and `n_coord` are all uniform across the work-group, so +// "does this tile touch the M or N edge" is one uniform compare, not a +// per-element one. Off the edge the clamps and the store predicate are dead +// weight: per fragment element they add two compares plus two selects for the +// scale indices and another compare pair for the store, roughly doubling the +// instruction count of an epilogue whose real work is one int32->float +// convert, two multiplies and one store. +// +// That matters because the epilogue is *not* amortized over a long mainloop at +// these shapes. A 128x128 tile runs `K / 64` k-tiles -- 12 of them for the +// qwen3 down-projection (K = 768) -- while it always writes `TileM * TileN` +// elements, and qwen3 down is exactly the shape the sweep reports furthest +// from the compute target (50 TFLOPS against 73-82 for the other three). The +// fast path emits the same expression in the same order for every element it +// stores, so it is bit-identical to the guarded one +// (`test_full_tile_epilogue_matches_predicated`), and +// `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` forces the guarded path for A/B +// measurement. // --------------------------------------------------------------------------- template CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, const float* scale_a, const float* scale_b, int m, int n, int k, int blocksize, int blks, int m_coord, - int n_coord, TiledMMA const& mma) { + int n_coord, bool allow_full_tile, TiledMMA const& mma) { auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); const int local_id = static_cast(item.get_local_linear_id()); @@ -641,6 +677,12 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); } + // Does this tile touch the M or N edge? Uniform across the work-group (`m`, + // `n` and both coordinates are), so the epilogues below branch once instead + // of testing every fragment element. + const bool full_tile = allow_full_tile && (m_coord + 1) * static_cast(get<0>(wg_tile)) <= m && + (n_coord + 1) * static_cast(get<1>(wg_tile)) <= n; + // `blks` is a kernel argument, so it is uniform across the work-group and // this branch never splits the split-barrier pairing below. if (blks == 1) { @@ -650,21 +692,37 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con run_k_tile(k_tile); } - CUTE_UNROLL - for (int i = 0; i < size(tCrC); ++i) { - auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); - const int col = static_cast(get<1>(coord)); - // Clamp rather than branch: an out-of-range element's value is dropped - // by the guarded store, and unconditional loads let the redundant reads - // across the fragment collapse. `m` and `n` are both >= 1 here (an - // expert with no rows contributes no tiles). - const int row_in = row < m ? row : m - 1; - const int col_in = col < n ? col : n - 1; - const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * scale_a[row_in]; - if (row < m && col < n) { - c[static_cast(row) * n + col] = static_cast(value); + // Single expression, instantiated once guarded and once not. `FullTile` + // only removes work: the value stored is computed by the same operations + // in the same order, so the two paths are bit-identical. + auto store_scaled = [&](auto full) { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + if constexpr (decltype(full)::value) { + c[static_cast(row) * n + col] = static_cast( + static_cast(tCrC(i)) * scale_b[static_cast(col)] * scale_a[row]); + } else { + // Clamp rather than branch: an out-of-range element's value is + // dropped by the guarded store, and unconditional loads let the + // redundant reads across the fragment collapse. `m` and `n` are both + // >= 1 here (an expert with no rows contributes no tiles). + const int row_in = row < m ? row : m - 1; + const int col_in = col < n ? col : n - 1; + const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * scale_a[row_in]; + if (row < m && col < n) { + c[static_cast(row) * n + col] = static_cast(value); + } + } } + }; + + if (full_tile) { + store_scaled(std::true_type{}); + } else { + store_scaled(std::false_type{}); } return; } @@ -683,13 +741,31 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con run_k_tile(ib * k_tiles_per_block + bk); } + if (full_tile) { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + const int col = static_cast(get<1>(tCgC(i))); + tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col) * blks + ib]; + } + } else { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + const int col = static_cast(get<1>(tCgC(i))); + const int col_in = col < n ? col : n - 1; + tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col_in) * blks + ib]; + } + } + } + + if (full_tile) { CUTE_UNROLL - for (int i = 0; i < size(tCrC); ++i) { + for (int i = 0; i < size(tFrC); ++i) { auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); const int col = static_cast(get<1>(coord)); - const int col_in = col < n ? col : n - 1; - tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col_in) * blks + ib]; + c[static_cast(row) * n + col] = static_cast(tFrC(i) * scale_a[row]); } + return; } CUTE_UNROLL @@ -721,7 +797,8 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const float* ScaleB, ElementD* Outputs, TiledMMA const& mma, const int* rows_per_expert, const int32_t num_experts, const int32_t gemm_n, const int32_t gemm_k, const int32_t blocksize, const int32_t blks, - int32_t* atomic_buffer, const sycl::local_accessor& slm_mem_const) { + const bool allow_full_tile, int32_t* atomic_buffer, + const sycl::local_accessor& slm_mem_const) { auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); auto wg_tile = mma.tile_mnk(); auto wg_tile_m = get<0>(wg_tile); @@ -774,7 +851,7 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, xe_gemm_w4a8(ptr_A_curr_batch, ptr_B_curr_batch, ptr_D_curr_batch, ptr_SA_curr_batch, ptr_SB_curr_batch, gemm_m, gemm_n, gemm_k, - blocksize, blks, m_coord, n_coord, mma); + blocksize, blks, m_coord, n_coord, allow_full_tile, mma); if (local_id == 0) { slm_mem[0] = cutlass::atomicAdd(atomic_buffer, 1); @@ -796,7 +873,7 @@ template void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const int8_t* weights, const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, - const int blks, int32_t* atomic_buffer) { + const int blks, const bool allow_full_tile, int32_t* atomic_buffer) { using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; using WGTile = typename Policy::WGTile; using SGLayout = typename Policy::SGLayout; @@ -828,7 +905,7 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, - blks, atomic_buffer, local_mem); + blks, allow_full_tile, atomic_buffer, local_mem); }); }); @@ -840,15 +917,30 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // Prefill driver: policy selection on the average per-expert M. // // The rungs match the tile ladder of `launch_igemm_kblock` in -// `sycl_tla_s8_gemm.hpp`, minus its `m > 1024` 256-wide rung: a grouped GEMM's -// M is *per expert*, and the on-hardware sweep (see the tile-policy comment -// above) measures every 256-wide N tile 35-50% slower than 128x128 at 256 -// rows/expert, so the ladder tops out at 128x128 instead. +// `sycl_tla_s8_gemm.hpp`: a grouped GEMM's M is *per expert*, so the ladder +// walks the average rows/expert rather than the total token count. It stays +// 128 wide in N at every rung -- the on-hardware sweep (see the tile-policy +// comment above) never measures a 256-wide N tile ahead. +// +// The top rung is the 256-row tile, which halves how often each expert's +// weights are pulled through L2/DRAM (B is read once per M tile). It is taken +// only when both conditions the sweep found it winning under hold: +// +// * `A_avg_M >= 256` -- otherwise the tile is half empty and the doubled +// `TileM` buys nothing but padding, and +// * `N >= K` -- the down-projection regime, where it measured 1.4% (qwen3 +// down) and 9.5% (minimax down) faster than 128x128. On the two +// up-projections (`N < K`) it measured 0.2% faster and 2.4% slower, i.e. +// inside the same run's noise, so those keep 128x128. // // `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile // (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything // else -- including the default `auto` -- keeps the ladder. It exists so the // tile can be swept on hardware without a rebuild. +// +// `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` makes every tile take the guarded +// epilogue (see `xe_gemm_w4a8`), which is the A/B baseline for the interior- +// tile fast path; it is read here, once per call, rather than on the device. // --------------------------------------------------------------------------- template void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, @@ -859,11 +951,13 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* compat::set_default_queue(*q); const int A_avg_M = total_tokens / E; + const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); int32_t* atomic_buffer = moe_dpas_fp8::get_persistent_atomic_buffer(q); #define ARK_MOE_W4A8_LAUNCH(policy) \ MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ - num_tokens_per_expert, E, blocksize, blks, atomic_buffer); + num_tokens_per_expert, E, blocksize, blks, allow_full_tile, \ + atomic_buffer); const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); if (tile_env != nullptr) { @@ -892,6 +986,8 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) } else if (A_avg_M < 128) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) + } else if (A_avg_M >= 256 && N >= K) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) } else { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) } diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index b6b6c86e62..11a0cecea4 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -180,9 +180,10 @@ the next block overwrites the accumulator. A lane holds both in GRF for the With `grf_size<256>` a lane has 256 registers, so at `128x128` the float shadow alone reserved a quarter of the register file for the whole mainloop, and at `128x256` the two fragments together *are* the register file — leaving nothing -for the staged A/B tiles. That is the measured 256-wide cliff in -[Tuned defaults](#tuned-defaults-measured), and at the default re-scale block it -was pure overhead: `blks == 1` (the AUTO_S8 `group=-1` default) has nothing to +for the staged A/B tiles. That was the 35–50% penalty the 256-wide tiles used to +pay in [Tuned defaults](#tuned-defaults-measured); with the shadow gone the whole +tile table fits in a 3–8% band. At the default re-scale block the shadow was pure +overhead: `blks == 1` (the AUTO_S8 `group=-1` default) has nothing to carry across blocks, so the scale can be folded on the way out instead. That path now runs without the float fragment, and applies `scale_b[col] × scale_a[row]` in a single pass — the same shape as the @@ -199,6 +200,18 @@ lane repeats the same `scale_b[col]` for every row it owns); under the previous `continue` guard each read sat in its own basic block and none of it could be hoisted. +**Interior tiles.** Both of those guards — the store predicate and the two +clamps — are needed only where a tile hangs off the edge of the expert's rows or +of N, and whether it does is uniform across the work-group: `m`, `n` and the +tile coordinates are all kernel-uniform. Testing it once per tile instead of +once per fragment element removes ~4 instructions from every output element of +an interior tile, and at `K = 768` (12 k-tiles per tile) the epilogue is a real +share of the tile's time. The guarded path stays for edge tiles and behind +`ARK_MOE_W4A8_PREFILL_FULL_TILE=0`; the arithmetic and its order are untouched, +so the two are bit-identical and +`test_full_tile_epilogue_matches_predicated` asserts exactly that at a batch +that gives every expert one interior tile and one ragged one. + ## What the script measures ### Accuracy table @@ -306,11 +319,12 @@ pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax pytest -v -s test_moe_w4a8_perf.py -k sweep ``` -`test_perf_decode_config_sweep`, `test_perf_prefill_tile_sweep` and -`test_perf_prefill_act_quant_sweep` build one workload, prepack it once, then -time every dispatch configuration against it — the decode lane mapping (legacy -GEMV plus every `CH` × `NCOLS` combination), the prefill work-group tile, and the -activation-quantization message width. Each configuration is checked for +`test_perf_decode_config_sweep`, `test_perf_prefill_tile_sweep`, +`test_perf_prefill_act_quant_sweep` and `test_perf_prefill_epilogue_sweep` build +one workload, prepack it once, then time every dispatch configuration against +it — the decode lane mapping (legacy GEMV plus every `CH` × `NCOLS` +combination), the prefill work-group tile, the activation-quantization message +width, and the epilogue's edge guard. Each configuration is checked for numerical equivalence with the first one, and the table is followed by a `best configuration` block naming the winning environment variables per shape, so the tuning knobs can be settled in a single on-hardware run. @@ -401,37 +415,57 @@ checked for numerical equivalence with the first one before it is timed. | shape | `128x128` | `256x128` | `128x256` | `256x256` | |---|---|---|---|---| -| qwen3 up | **3.377 ms** | 3.419 ms | 4.703 ms | 4.668 ms | -| qwen3 down | **2.614 ms** | 2.718 ms | 3.862 ms | 3.835 ms | -| minimax up | **6.743 ms** | 6.761 ms | 8.964 ms | 9.049 ms | -| minimax down | 7.350 ms | **7.101 ms** | 9.928 ms | 9.872 ms | - -The 256-wide N tiles are 35–50% *slower* than the 128-wide ones on every shape — -the opposite of what the tile-traffic argument predicts (`1/TileM + 1/TileN` -halves when both extents double, so `256x256` should have moved half the bytes -of `128x128`). `TileM` barely matters; `TileN` does, because `SGLayout` is 4 -sub-groups wide in every policy: `TileN = 256` gives each sub-group a 32×64 C -fragment — 128 int32 accumulators per SIMD16 lane — instead of 32×32 (64), past -the point where the register file still holds the fragment plus the staged A/B -tiles. - -The ladder therefore tops out at **`128x128`**: `< 16` rows/expert → `8x128`, -`< 128` → `64x128`, otherwise `128x128`. The 256-wide policies stay compiled and -selectable with `ARK_MOE_W4A8_PREFILL_TILE`, so the sweep can re-check them on a -device with a different register budget. - -Even at the best tile the swept shapes reach 61.1 / 39.4 / 68.8 / 65.3 TFLOPS -(qwen3 up / down, minimax up / down) — below both the 100 TFLOPS target and the -`2 × 256 rows × bandwidth` roofline for this routing, so the tile is not the only -remaining gap at 256 rows/expert. - -> These numbers were measured while the mainloop still kept a float shadow of the -> C fragment live throughout, which doubled the per-lane C footprint — 128 + 128 -> registers at `TileN = 256`, i.e. the entire large-GRF file. The single-block -> epilogue no longer allocates it (see -> [Prefill: message width and register pressure](#prefill-message-width-and-register-pressure)), -> so the 256-wide policies now ask for half of what they did when they lost and -> are worth re-sweeping before the ladder is considered settled. +| qwen3 up | **2.846 ms** | 2.914 ms | 2.938 ms | 3.057 ms | +| qwen3 down | 2.078 ms | **2.049 ms** | 2.170 ms | 2.387 ms | +| minimax up | 5.866 ms | **5.856 ms** | 5.997 ms | 5.861 ms | +| minimax down | 6.271 ms | **5.676 ms** | 6.001 ms | 6.250 ms | + +The spread across the whole table is now 3–8%. It used to be 35–50%, and the +cliff was not the tile shape: the mainloop kept a float shadow of the C fragment +live next to the int32 one, so `TileN = 256` asked for 128 + 128 registers per +SIMD16 lane — the entire large-GRF file. With the shadow gone (see +[Prefill: message width and register pressure](#prefill-message-width-and-register-pressure)) +the 256-wide N tiles are merely a few percent behind, which is what a 32×64 C +fragment per sub-group (`SGLayout` is 4 wide in every policy) costs in +occupancy rather than in spills. + +`TileM = 256` is the direction that pays. It keeps the per-lane fragment at +32×32 because `SGLayout` grows to 8×4 instead, while halving how often the +expert's B panel is re-read: per-expert tile traffic is +`M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`. The win is real only where B is the larger +operand — the down-projections, `N ≥ K` — and there it is 1.5% (qwen3) to 9.5% +(minimax). On qwen3 up (`N < K`) it costs 2.4%. + +The ladder therefore is: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, +`≥ 256` **and** `N ≥ K` → **`256x128`**, otherwise `128x128`. All six policies +stay compiled and selectable with `ARK_MOE_W4A8_PREFILL_TILE`. + +Two caveats on reading the table. "tile auto" and "tile `128x128`" run the same +kernel yet differ by up to 6% (2.212 vs 2.078 ms on qwen3 down), so anything +under ~5% here is inside run-to-run noise — only the minimax down row is clearly +outside it. And the ladder's rows/expert threshold is compared against +`total_tokens / E`, the *average*, so a skewed routing that averages 256 rows +can still leave many experts with ragged 256-row tiles. + +At the chosen tiles the swept shapes reach 72.7 / 50.3 / 79.2 / 81.7 TFLOPS +(qwen3 up / down, minimax up / down) — up from 61.1 / 39.4 / 68.8 / 65.3 before +the C-shadow removal and the vectorized activation quantizer, but qwen3 down is +still far short of the 100 TFLOPS target: at `K = 768` a tile runs only 12 +k-tiles, so the epilogue and the prologue are a large share of it. + +### Prefill activation quantization + +| shape | scalar | vectorized (default) | speedup | +|---|---|---|---| +| qwen3 up | 3.082 ms | **2.713 ms** | 1.14× | +| qwen3 down | 2.165 ms | **2.017 ms** | 1.07× | +| minimax up | 6.161 ms | **5.611 ms** | 1.10× | +| minimax down | 6.563 ms | **6.142 ms** | 1.07× | + +Quantizing the routed activations is a streaming pass over `[T, K]` next to a +GEMM that already moves ~400 MB, and it is worth 7–14% of the whole call purely +by issuing 256-byte loads and 128-byte stores instead of 32-byte and 16-byte +ones. `ARK_MOE_W4A8_ACT_QUANT_VEC=0` restores the scalar mapping. ### Decode chunk width and column blocking @@ -457,8 +491,9 @@ worth 1.09–1.93× over the legacy GEMV. | `ARK_MOE_W4A8_DECODE_KSPLIT` | Coalesced K-split decode mapping; **on by default**. Set to `0` to fall back to the original one-work-item-per-output GEMV (useful for A/B measurements). Ignored when the shape doesn't qualify. | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. `2` is the measured default, see [Tuned defaults](#tuned-defaults-measured). | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. Measured slower than `16` on every swept shape, so it is a sweep point rather than a recommendation. | -| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which tops out at `128x128`. The 256-wide N tiles move less tile traffic but measure 35–50% slower (see [Tuned defaults](#tuned-defaults-measured)); `256x128` is within ~4% of `128x128` everywhere and slightly ahead on minimax down. | -| `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | +| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which picks `256x128` at ≥ 256 rows/expert when `N ≥ K` and `128x128` otherwise (see [Tuned defaults](#tuned-defaults-measured)). The whole table is now within 3–8%, so this is a re-tuning knob rather than a cliff. | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.07–1.14× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | +| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**. The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | ## Shape constraints @@ -479,23 +514,28 @@ failing. The W4A8 kernel is a new SYCL/CuTe port, marked `STATUS: PARTIALLY HARDWARE-VALIDATED` in -`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. Both perf sweeps have -been run on BMG — that run is where the tile ladder and the decode `CH` / -`NCOLS` defaults come from (see [Tuned defaults](#tuned-defaults-measured)) — and -every swept configuration passed the cross-configuration equivalence check. - -Still to run on device: the accuracy sweep against the fp32 reference (it will -catch layout/scale bugs immediately) and `test_decode_ksplit_matches_legacy`, -which re-checks the K-split mapping against the legacy one. The K-split index -math has otherwise only been checked with a host-side mock; if it ever -regresses, `ARK_MOE_W4A8_DECODE_KSPLIT=0` restores the previous behaviour -without a rebuild. - -The two prefill changes above are in the same position: the vectorized -activation quantizer and the single-block epilogue have been reasoned through -but not timed. `test_act_quant_vec_matches_scalar` and -`test_perf_prefill_act_quant_sweep` are the device checks for the first -(`ARK_MOE_W4A8_ACT_QUANT_VEC=0` reverts it without a rebuild); the second has no -knob because it is the same arithmetic in the same order — its effect shows up -as a re-sweep of `test_perf_prefill_tile_sweep`, where the 256-wide tiles should -move now that they no longer need a doubled C fragment. +`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. The perf sweeps have +been run on BMG — that run is where the tile ladder, the activation-quantization +default and the decode `CH` / `NCOLS` defaults come from (see +[Tuned defaults](#tuned-defaults-measured)) — and every swept configuration +passed the cross-configuration equivalence check. +`test_act_quant_vec_matches_scalar` and `test_decode_ksplit_matches_legacy` pass +on device, so both the vectorized activation quantizer and the K-split decode +mapping are checked against their predecessors as well as timed. + +Still to run on device: the accuracy sweep against the fp32 reference, which +will catch layout/scale bugs immediately. + +The interior-tile epilogue is the one change that has been reasoned through but +neither timed nor run: a tile that touches neither the M nor the N edge stores +without a predicate and indexes the scales without clamping. Its device checks +are `test_full_tile_epilogue_matches_predicated` (bit-identity, at a batch that +gives every expert one interior and one ragged tile) and +`test_perf_prefill_epilogue_sweep`; `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` reverts +it without a rebuild. + +The next prefill step, once that lands, is the store itself: the epilogue still +writes D one element per predicate through a scalar pointer, where the sibling +int8 and fp8 kernels use a block-2D copy for D. At `K = 768` — the shape stuck +at 50 TFLOPS — a tile runs only 12 k-tiles, so the store is a large fraction of +its time. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index cfaea90077..eabfa82aac 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -161,10 +161,11 @@ absmax,再量化),写一遍 `[T, K]` 的 int8。在 32768 条路由行、`K 在 `grf_size<256>` 下每个 lane 只有 256 个寄存器,因此 `128x128` 时仅 float 影子就 在整个主循环里占掉了四分之一的寄存器堆,而 `128x256` 时两份 fragment 加起来*就是* -整个寄存器堆——留给暂存 A/B tile 的空间为零。这正是[实测得到的默认值](#实测得到的默认值) -中 N 方向 256 的性能悬崖;而在默认重缩放 block 下它完全是白付的开销:`blks == 1` -(AUTO_S8 `group=-1` 默认值) 根本没有需要跨 block 携带的东西,scale 完全可以在写出时 -再折进去。该路径现在不再分配 float fragment,而是一次遍历就应用 +整个寄存器堆——留给暂存 A/B tile 的空间为零。这正是 N 方向 256 的 tile 过去在 +[实测得到的默认值](#实测得到的默认值)中要多付 35–50% 的原因;去掉 float 影子之后, +整张 tile 表已经收敛到 3–8% 的区间内。而在默认重缩放 block 下它完全是白付的开销: +`blks == 1` (AUTO_S8 `group=-1` 默认值) 根本没有需要跨 block 携带的东西,scale 完全 +可以在写出时再折进去。该路径现在不再分配 float fragment,而是一次遍历就应用 `scale_b[col] × scale_a[row]`——与参考的稠密 int8 GEMM 中 `AccumBlock == false` 分支的结构一致。 @@ -176,6 +177,16 @@ absmax,再量化),写一遍 `[T, K]` 的 int8。在 32768 条路由行、`K 它负责的每一行都重复同一个 `scale_b[col]`);在原先的 `continue` 保护下,每次读取都 位于各自独立的基本块中,这些优化一个也做不了。 +**内部 tile。** 上述两类保护——store 谓词与两次下标钳制——只有在 tile 越过专家行数 +边界或 N 边界时才是必要的,而是否越界在整个 work-group 内是一致的:`m`、`n` 与 tile +坐标都是 work-group 内统一的量。把这个判断从"每个 fragment 元素一次"提到"每个 tile +一次",可以为内部 tile 的每个输出元素省下约 4 条指令;而在 `K = 768` (每个 tile 只有 +12 个 k-tile) 时,epilogue 在整个 tile 耗时中占据可观的比例。边缘 tile 仍走原来的 +保护路径,`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 也可以把所有 tile 都切回该路径;由于算 +术运算及其顺序完全没变,两条路径**逐位相同**, +`test_full_tile_epilogue_matches_predicated` 正是在"每个专家恰好有一个完整 tile 和一 +个残缺 tile"的 batch 上断言这一点。 + ## 脚本测量的内容 ### 精度表 @@ -281,10 +292,11 @@ pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax pytest -v -s test_moe_w4a8_perf.py -k sweep ``` -`test_perf_decode_config_sweep`、`test_perf_prefill_tile_sweep` 和 -`test_perf_prefill_act_quant_sweep` 只构造一次 workload、只 prepack 一次,然后用同 -一份数据依次给每种 dispatch 配置计时——decode 的 lane 映射 (legacy GEMV 以及 -`CH` × `NCOLS` 的全部组合)、prefill 的 work-group tile,以及激活量化的消息宽度。每 +`test_perf_decode_config_sweep`、`test_perf_prefill_tile_sweep`、 +`test_perf_prefill_act_quant_sweep` 和 `test_perf_prefill_epilogue_sweep` 只构造一 +次 workload、只 prepack 一次,然后用同一份数据依次给每种 dispatch 配置计时——decode +的 lane 映射 (legacy GEMV 以及 `CH` × `NCOLS` 的全部组合)、prefill 的 work-group +tile、激活量化的消息宽度,以及 epilogue 的边界保护。每 种配置都会与第一种配置做数值等价性检查,表格之后还会打印一段 `best configuration`,按形状给出获胜配置对应的环境变量,因此在硬件上跑一次就能确定这些 调优开关。 @@ -371,32 +383,51 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 | 形状 | `128x128` | `256x128` | `128x256` | `256x256` | |---|---|---|---|---| -| qwen3 up | **3.377 ms** | 3.419 ms | 4.703 ms | 4.668 ms | -| qwen3 down | **2.614 ms** | 2.718 ms | 3.862 ms | 3.835 ms | -| minimax up | **6.743 ms** | 6.761 ms | 8.964 ms | 9.049 ms | -| minimax down | 7.350 ms | **7.101 ms** | 9.928 ms | 9.872 ms | - -在所有形状上,N 方向为 256 的 tile 都比 128 的**慢** 35–50%——这与 tile 访存量的 -推导恰好相反 (两个维度同时翻倍时 `1/TileM + 1/TileN` 会减半,`256x256` 本应只搬运 -`128x128` 一半的字节)。起决定作用的不是 `TileM` 而是 `TileN`:所有 policy 的 -`SGLayout` 在 N 方向都是 4 个 sub-group,因此 `TileN = 256` 会让每个 sub-group 的 -C fragment 从 32×32 (每个 SIMD16 lane 64 个 int32 累加器) 变成 32×64 (128 个),超 -出了寄存器堆同时容纳 fragment 与暂存 A/B tile 的容量。 - -因此 tile 阶梯的最大档就是 **`128x128`**:每专家 `< 16` 行 → `8x128`,`< 128` → -`64x128`,其余 → `128x128`。N 方向为 256 的 policy 仍然会被编译,并可通过 -`ARK_MOE_W4A8_PREFILL_TILE` 选择,以便在寄存器预算不同的设备上重新验证。 - -即使选用最优 tile,被扫描的四个形状也只达到 61.1 / 39.4 / 68.8 / 65.3 TFLOPS -(qwen3 up / down、minimax up / down)——既低于 100 TFLOPS 的目标,也低于该路由下 -`2 × 256 行 × 带宽` 的 roofline,说明在每专家 256 行时 tile 并不是唯一的差距来源。 - -> 上述数据是在主循环仍然全程保持一份 C fragment 的 float 影子时测得的,那使每个 -> lane 的 C 占用翻倍——`TileN = 256` 时是 128 + 128 个寄存器,即整个 large-GRF 寄 -> 存器堆。单 block 的 epilogue 已经不再分配它 (参见 -> [Prefill:访存消息宽度与寄存器压力](#prefill访存消息宽度与寄存器压力)),因此 N -> 方向为 256 的 policy 现在所需的寄存器只有当初落败时的一半,在认定 tile 阶梯已经 -> 定型之前值得重新扫描一次。 +| qwen3 up | **2.846 ms** | 2.914 ms | 2.938 ms | 3.057 ms | +| qwen3 down | 2.078 ms | **2.049 ms** | 2.170 ms | 2.387 ms | +| minimax up | 5.866 ms | **5.856 ms** | 5.997 ms | 5.861 ms | +| minimax down | 6.271 ms | **5.676 ms** | 6.001 ms | 6.250 ms | + +现在整张表的差距只有 3–8%。它曾经是 35–50%,而那道悬崖的成因并不是 tile 形状:当时 +主循环在 int32 累加器之外还全程保持一份 C fragment 的 float 影子,于是 `TileN = 256` +需要每个 SIMD16 lane 128 + 128 个寄存器,即整个 large-GRF 寄存器堆。去掉这份影子之后 +(参见 [Prefill:访存消息宽度与寄存器压力](#prefill访存消息宽度与寄存器压力)),N 方向 +为 256 的 tile 只落后几个百分点——这正是每个 sub-group 32×64 的 C fragment +(所有 policy 的 `SGLayout` 在 N 方向都是 4) 在 occupancy 而非寄存器溢出上付出的代价。 + +真正有收益的方向是 `TileM = 256`:它把 `SGLayout` 变成 8×4,从而让每个 lane 的 +fragment 仍保持 32×32,同时把专家 B 面板的重复读取次数减半——每个专家的 tile 访存量 +为 `M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`。只有当 B 是较大的那个操作数时 (即 down 投影, +`N ≥ K`) 这个收益才成立,此时可得 1.5% (qwen3) 到 9.5% (minimax) 的提升;而在 +qwen3 up (`N < K`) 上则要倒亏 2.4%。 + +因此 tile 阶梯为:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,`≥ 256` **且** +`N ≥ K` → **`256x128`**,其余 → `128x128`。六个 policy 全部保留编译,并可通过 +`ARK_MOE_W4A8_PREFILL_TILE` 手动选择。 + +读这张表时有两点需要注意。"tile auto" 与 "tile `128x128`" 跑的是同一个 kernel,二者 +却相差最多 6% (qwen3 down 上 2.212 vs 2.078 ms),因此这里 ~5% 以内的差异都在运行间 +噪声范围内——只有 minimax down 那一行明显超出噪声。另外,阶梯里的每专家行数阈值比较 +的是 `total_tokens / E` 这个**平均值**,因此在路由不均衡时,即便平均为 256 行,仍可 +能有很多专家只有残缺的 256 行 tile。 + +在各自选中的 tile 下,被扫描的四个形状达到 72.7 / 50.3 / 79.2 / 81.7 TFLOPS +(qwen3 up / down、minimax up / down)——相比去掉 float 影子和引入向量化激活量化之前的 +61.1 / 39.4 / 68.8 / 65.3 有明显提升,但 qwen3 down 距离 100 TFLOPS 的目标仍然很远: +`K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此占了相当大的比例。 + +### Prefill 激活量化 + +| 形状 | 标量 | 向量化 (默认) | 加速比 | +|---|---|---|---| +| qwen3 up | 3.082 ms | **2.713 ms** | 1.14× | +| qwen3 down | 2.165 ms | **2.017 ms** | 1.07× | +| minimax up | 6.161 ms | **5.611 ms** | 1.10× | +| minimax down | 6.563 ms | **6.142 ms** | 1.07× | + +量化 routed 激活只是对 `[T, K]` 的一次流式遍历,而与它并行的 GEMM 本身就要搬运约 +400 MB;仅仅把 32 字节 load / 16 字节 store 换成 256 字节 / 128 字节,就能带来整次调 +用 7–14% 的收益。`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射。 ### Decode 的 chunk 宽度与列分块 @@ -421,8 +452,9 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_DECODE_KSPLIT` | 合并访存的 K-split decode 映射,**默认开启**。设为 `0` 可回退到原来每个输出一个 work-item 的 GEMV (便于 A/B 对比)。形状不满足条件时该开关无效。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。默认值 `2` 来自实测,参见[实测得到的默认值](#实测得到的默认值)。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。实测中它在所有形状上都慢于 `16`,因此只作为扫描项而非推荐值。 | -| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择,其最大档为 `128x128`。N 方向为 256 的 tile 虽然访存量更小,但实测慢 35–50% (参见[实测得到的默认值](#实测得到的默认值));`256x128` 在所有形状上与 `128x128` 相差约 4% 以内,在 minimax down 上还略微领先。 | -| `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | +| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 ≥ 256 行且 `N ≥ K` 时取 `256x128`,否则取 `128x128` (参见[实测得到的默认值](#实测得到的默认值))。现在整张表的差距已收敛到 3–8%,因此这个开关是重新调优用的旋钮,而不再对应一道性能悬崖。 | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.07–1.14× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | +| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | ## 形状约束 @@ -442,19 +474,23 @@ decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 `auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp` 中被标记为 -`STATUS: PARTIALLY HARDWARE-VALIDATED`。两个性能扫描都已在 BMG 上跑过——tile 阶梯 -以及 decode 的 `CH` / `NCOLS` 默认值正是来自那次运行 (参见[实测得到的默认 -值](#实测得到的默认值)),并且所有被扫描的配置都通过了配置间的数值等价性检查。 - -仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描 (它能立刻暴露 layout / -scale 相关的 bug),以及重新校验 K-split 映射与原映射一致性的 -`test_decode_ksplit_matches_legacy`。除此之外,K-split 的下标计算目前只用宿主端 -mock 比对过;一旦出现回归,设置 `ARK_MOE_W4A8_DECODE_KSPLIT=0` 即可在不重新编译的 -情况下恢复原有行为。 - -上述两项 prefill 改动同样如此:向量化激活量化与单 block epilogue 只经过推导,尚未 -实测计时。前者在设备上的检查是 `test_act_quant_vec_matches_scalar` 与 -`test_perf_prefill_act_quant_sweep` (设置 `ARK_MOE_W4A8_ACT_QUANT_VEC=0` 即可在不 -重新编译的情况下回退);后者没有对应开关,因为它是完全相同的算术、完全相同的顺序 -——其效果体现在重新跑一次 `test_perf_prefill_tile_sweep`:N 方向为 256 的 tile 不 -再需要双倍的 C fragment,其排名应当发生变化。 +`STATUS: PARTIALLY HARDWARE-VALIDATED`。各项性能扫描都已在 BMG 上跑过——tile 阶梯、 +激活量化的默认值以及 decode 的 `CH` / `NCOLS` 默认值正是来自那些运行 (参见[实测得到 +的默认值](#实测得到的默认值)),并且所有被扫描的配置都通过了配置间的数值等价性检查。 +`test_act_quant_vec_matches_scalar` 与 `test_decode_ksplit_matches_legacy` 也已在设 +备上通过,因此向量化激活量化与 K-split decode 映射既有计时数据,也都与各自的前身做 +过比对。 + +仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描,它能立刻暴露 layout / +scale 相关的 bug。 + +内部 tile 的 epilogue 是唯一一项只经过推导、既未实测计时也尚未在设备上运行的改动: +既不触及 M 边界也不触及 N 边界的 tile 在写出时不再使用谓词,读 scale 时也不再钳制下 +标。它在设备上的检查是 `test_full_tile_epilogue_matches_predicated` (逐位一致性,所 +用 batch 让每个专家恰好有一个完整 tile 和一个残缺 tile) 与 +`test_perf_prefill_epilogue_sweep`;设置 `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 即可在 +不重新编译的情况下回退。 + +这一项落地之后,prefill 的下一步是写出本身:epilogue 目前仍然通过标量指针、按谓词逐 +元素写 D,而同类的 int8 与 fp8 kernel 对 D 用的是 block-2D copy。在卡在 50 TFLOPS 的 +`K = 768` 形状上,一个 tile 只有 12 个 k-tile,写出因此占据了其中相当大的一部分时间。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 0b05431067..d15dbbc0bc 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -962,6 +962,16 @@ def run_perf( ("act-quant vec", {"ARK_MOE_W4A8_ACT_QUANT_VEC": "1"}), ] +# Prefill: epilogue guard. A tile that touches neither the M nor the N edge +# needs no store predicate and no scale-index clamp, and whether it does is +# uniform across the work-group. The guarded path is what every tile used to +# run; it stays reachable so the saving can be measured (it is largest where +# the mainloop is shortest, i.e. small K). +_EPILOGUE_CONFIGS = [ + ("epilogue guarded", {"ARK_MOE_W4A8_PREFILL_FULL_TILE": "0"}), + ("epilogue interior", {"ARK_MOE_W4A8_PREFILL_FULL_TILE": "1"}), +] + _SWEEP_MIN_SNR_DB = 40.0 @@ -1214,6 +1224,25 @@ def test_perf_prefill_act_quant_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"act-quant config {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_epilogue_sweep(self, request): + """Time the epilogue guard at the compute-bound batch. + + The mainloop is identical in both rows; only the store differs. A + tile that lies entirely inside the expert's rows and inside N can + skip the per-element store predicate and the two scale-index + clamps, and the answer is uniform across the work-group, so the + branch costs one comparison per tile rather than per element. The + saving is a fixed number of instructions per output element, so it + shows up as a larger fraction where the mainloop is shortest -- + the small-K down-projections -- and should never be negative. + """ + rows = run_config_sweep("prefill", _EPILOGUE_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"epilogue config {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_act_quant_vec_matches_scalar(self): """The vectorized activation quantizer must be bit-identical to the scalar one. @@ -1248,6 +1277,44 @@ def test_act_quant_vec_matches_scalar(self): f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" ) + def test_full_tile_epilogue_matches_predicated(self): + """Skipping the epilogue guard on interior tiles must change nothing. + + The fast path drops only the store predicate and the two + scale-index clamps, all three of which are no-ops on a tile that + lies inside the expert's rows and inside N; the convert, the two + multiplies and their order are untouched, so the results must be + bit-identical, not merely close. + + The shape matters: the batch puts 300 rows on every expert against + a 256-row tile, so each expert has one interior tile *and* one + ragged tile and a single launch exercises both paths. A + small-batch case would leave every tile ragged and the test would + pass without the fast path ever running. + """ + rows_per_expert = _PREFILL_TARGET_ROWS_PER_EXPERT + 44 + case = _build_case( + _QWEN3_NK[1][1], + _QWEN3_NK[1][2], + _QWEN3_E, + rows_per_expert * _QWEN3_E, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for flag in ("0", "1"): + with _env_override(ARK_MOE_W4A8_PREFILL_FULL_TILE=flag): + outs[flag] = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + assert torch.equal(outs["0"], outs["1"]), ( + "the interior-tile epilogue disagrees with the guarded one: " + f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" + ) + def test_decode_ksplit_matches_legacy(self): """The K-split decode mapping must agree with the legacy one. From fba1656668a97b48f98f7b99eec3deefc65fe673 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:40:35 +0000 Subject: [PATCH 065/112] perf(ark): take the 256-row prefill tile everywhere and batch the act-quant loads Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 258 +++++++++++++----- .../ark/test/test_moe_w4a8_perf.py | 81 ++++++ 2 files changed, 275 insertions(+), 64 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 2c1349dd20..04ccc13c8b 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -1,18 +1,21 @@ // SYCL MoE W4A8 -- INT4 weights / INT8 compute (prefill + decode) // // STATUS: PARTIALLY HARDWARE-VALIDATED -- `test_perf_prefill_tile_sweep`, -// `test_perf_prefill_act_quant_sweep`, `test_perf_decode_config_sweep`, -// `test_act_quant_vec_matches_scalar` and `test_decode_ksplit_matches_legacy` -// have been run on BMG, so both phases compile and run and every dispatch -// default (tile ladder, activation-quant message width, decode CH / NCOLS) -// comes from those measurements; every swept configuration also passed the -// cross-configuration equivalence check. The accuracy gates against the fp32 -// reference still need a device run. The authoring environment has no XPU and -// no SYCL compiler, so anything added since follows the porting conventions of -// its siblings `sycl_tla_moe_prefill_int_dpas.hpp` / -// `sycl_tla_moe_prefill_fp8_dpas.hpp` -- currently the interior-tile epilogue -// (`ARK_MOE_W4A8_PREFILL_FULL_TILE`), which is pure C++ around the same -// arithmetic. +// `test_perf_prefill_act_quant_sweep`, `test_perf_prefill_epilogue_sweep`, +// `test_perf_decode_config_sweep`, `test_act_quant_vec_matches_scalar`, +// `test_full_tile_epilogue_matches_predicated` and +// `test_decode_ksplit_matches_legacy` have been run on BMG, so both phases +// compile and run and every dispatch default (tile ladder, activation-quant +// message width, interior-tile epilogue, decode CH / NCOLS) comes from those +// measurements; every swept configuration also passed the cross-configuration +// equivalence check. The accuracy gates against the fp32 reference still need +// a device run. The authoring environment has no XPU and no SYCL compiler, so +// anything added since follows the porting conventions of its siblings +// `sycl_tla_moe_prefill_int_dpas.hpp` / +// `sycl_tla_moe_prefill_fp8_dpas.hpp` -- currently the activation quantizer's +// batched loads (`ARK_MOE_W4A8_ACT_QUANT_UNROLL`), which are pure C++ around +// the same arithmetic and are swept by +// `test_perf_prefill_act_quant_unroll_sweep`. // --------------------------------------------------------------------------- // // What this file implements @@ -133,7 +136,7 @@ constexpr int kPrepackOctet = 8; template class MoEW4A8ActQuant; -template +template class MoEW4A8ActQuantVec; template @@ -218,15 +221,50 @@ inline DeviceScratchPool& expert_map_pool() { // `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. // // `test_perf_prefill_act_quant_sweep` on BMG (256 rows/expert, bf16 act) puts -// the widened messages at 1.14x (qwen3 up), 1.07x (qwen3 down), 1.10x (minimax -// up) and 1.07x (minimax down) of the scalar mapping on the *whole* +// the widened messages at 1.13x (qwen3 up), 1.14x (qwen3 down), 1.12x (minimax +// up) and 1.04x (minimax down) of the scalar mapping on the *whole* // `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of -// prefill than that, since the GEMM around it is unchanged. +// prefill than that, since the GEMM around it is unchanged. (An earlier run of +// the same sweep read 1.14 / 1.07 / 1.10 / 1.07: the ranking is stable, the +// individual ratios move by a few percent between runs.) // // `sycl::vec` is used rather than `sycl::vec` // because `sycl::vec` of `bfloat16` is not universally available; the elements // are `bit_cast` back one at a time, exactly like the decode kernels' // `ActVec` loads in `sycl_tla_moe_decode.hpp`. +// +// Requests in flight (the cost widening the messages did not address) +// ------------------------------------------------------------------- +// Wide messages fix how many bytes each *request* moves; they do not change +// how many requests a work-item has outstanding. This kernel walks K with a +// runtime trip count (`steps = K / (SG_SIZE * VEC)`) and folds every vector +// into the same `local_max` accumulator, so the loop reads as: issue one load, +// stall until it returns, `fmax` it, repeat. Xe cores execute in order and +// `fmax` is not reassociated without fast-math, so each thread keeps roughly +// *one* 256-byte load in flight. +// +// That is a Little's-law problem, not a bandwidth one: 640 concurrent +// sub-groups (20 Xe cores x 32 threads) x 256 bytes is ~160 KB of in-flight +// reads, well under the ~400 KB a ~400 GB/s device needs to stay busy across a +// ~1 us memory latency. The same argument is why the decode GEMV loads two +// chunks per iteration (`launch_w4a8_decode_ksplit`), and why the pair is +// spelled out there rather than left to the compiler. +// +// `UNROLL` gives the pass the same treatment: each iteration loads `UNROLL` +// *independent* vectors before consuming any of them, and reduces them into +// `UNROLL` separate partial maxima so the loads do not serialize behind the +// accumulator chain either. The quantize pass batches its loads the same way, +// and its stores are already independent. `steps % UNROLL` vectors are left to +// a tail loop -- `K = 768` (qwen3 down) gives `steps = 6`, so the tail is real +// code, not a formality. +// +// Nothing that rounds changes: the per-lane partial reduction is still `fmax` +// over the same values (exact and order-independent, so partial maxima merge +// to the same bits), and every element goes through the same `rint`/`clamp` +// expression. `UNROLL = 1` is the previous kernel instruction for instruction, +// so `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` is an exact A/B baseline; +// `test_act_quant_unroll_matches` asserts every depth is bit-identical and +// `test_perf_prefill_act_quant_unroll_sweep` times them. // --------------------------------------------------------------------------- // Fold the per-token expert scan (decode only) into the quantization kernel. @@ -248,38 +286,67 @@ inline void act_quant_write_scale(float* ascale, int token, float scale, int* ex expert_id_per_token[token] = expert; } -template +template void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, int total_tokens, int K, int* expert_id_per_token, const int* num_tokens_per_expert, int num_experts) { static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); + static_assert(UNROLL >= 1 && (UNROLL & (UNROLL - 1)) == 0, "UNROLL must be a power of two"); using ActVec = sycl::vec; using QVec = sycl::vec; // Vectors a lane walks over. `K % (SG_SIZE * VEC) == 0` is checked by the - // caller, so the loop needs no tail. + // caller, so the loop needs no tail -- but `steps` need not be a multiple of + // `UNROLL` (K = 768 gives 6 vectors at VEC = 8), hence the second loop. const int steps = K / (SG_SIZE * VEC); + const int main_steps = steps - (steps % UNROLL); sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; sycl::range<2> local{1, static_cast(SG_SIZE)}; - q->parallel_for>( + q->parallel_for>( sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { const int token = static_cast(it.get_global_id(0)); const int lane = static_cast(it.get_local_id(1)); const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); QVec* out = reinterpret_cast(qact + static_cast(token) * K); - float local_max = 0.0f; - for (int s = 0; s < steps; ++s) { + // One partial maximum per unrolled slot: `fmax` is exact, so merging + // them below gives the same absmax as a single chain, but the loads no + // longer wait on it. + float part_max[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) part_max[u] = 0.0f; + + for (int s = 0; s < main_steps; s += UNROLL) { + ActVec v[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + v[u] = row[static_cast(s + u) * SG_SIZE + lane]; + } +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); + part_max[u] = sycl::fmax(part_max[u], sycl::fabs(static_cast(a))); + } + } + } + for (int s = main_steps; s < steps; ++s) { const ActVec v = row[static_cast(s) * SG_SIZE + lane]; #pragma unroll - for (int u = 0; u < VEC; ++u) { - const ScalarT a = sycl::bit_cast(static_cast(v[u])); - local_max = sycl::fmax(local_max, sycl::fabs(static_cast(a))); + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[e])); + part_max[0] = sycl::fmax(part_max[0], sycl::fabs(static_cast(a))); } } + + float local_max = part_max[0]; +#pragma unroll + for (int u = 1; u < UNROLL; ++u) local_max = sycl::fmax(local_max, part_max[u]); + auto sg = it.get_sub_group(); const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); @@ -289,20 +356,74 @@ void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, in act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); } - for (int s = 0; s < steps; ++s) { + // Same batching on the way back: the re-read of a row the first pass + // just touched is served by the cache, but only if enough of it is + // requested at once. + for (int s = 0; s < main_steps; s += UNROLL) { + ActVec v[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + v[u] = row[static_cast(s + u) * SG_SIZE + lane]; + } +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s + u) * SG_SIZE + lane] = qv; + } + } + for (int s = main_steps; s < steps; ++s) { const ActVec v = row[static_cast(s) * SG_SIZE + lane]; QVec qv; #pragma unroll - for (int u = 0; u < VEC; ++u) { - const ScalarT a = sycl::bit_cast(static_cast(v[u])); + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[e])); const float x = sycl::rint(static_cast(a) * inv); - qv[u] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); } out[static_cast(s) * SG_SIZE + lane] = qv; } }); } +// Vectors a lane loads before it consumes any of them. `4` covers every +// shipped shape's `steps` (6 / 12 / 16 / 24 at VEC = 8) with at most a +// two-vector tail. `ARK_MOE_W4A8_ACT_QUANT_UNROLL` selects 1 (the previous +// kernel), 2 or 4; anything else falls back to the default. +constexpr int kActQuantUnrollDefault = 4; + +inline int moe_w4a8_act_quant_unroll() { + const char* env = std::getenv("ARK_MOE_W4A8_ACT_QUANT_UNROLL"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && (v == 1 || v == 2 || v == 4)) return static_cast(v); + } + return kActQuantUnrollDefault; +} + +// Runtime unroll depth -> compile-time bridge. +template +void launch_act_dynamic_quant_vec_unroll(int unroll, sycl::queue* q, const ScalarT* activations, int8_t* qact, + float* ascale, int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + if (unroll == 1) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } else if (unroll == 2) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } else { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } +} + template void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, int total_tokens, int K, int* expert_id_per_token = nullptr, @@ -317,14 +438,15 @@ void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t if (moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_VEC", true)) { const auto act_addr = reinterpret_cast(activations); const auto q_addr = reinterpret_cast(qact); + const int unroll = moe_w4a8_act_quant_unroll(); if (K % (SG_SIZE * 8) == 0 && act_addr % (8 * sizeof(ScalarT)) == 0 && q_addr % 8 == 0) { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, expert_id_per_token, - num_tokens_per_expert, num_experts); + launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); return; } if (K % (SG_SIZE * 4) == 0 && act_addr % (4 * sizeof(ScalarT)) == 0 && q_addr % 4 == 0) { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, expert_id_per_token, - num_tokens_per_expert, num_experts); + launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); return; } } @@ -460,30 +582,35 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // file stops holding the fragment plus the staged A/B tiles. // // `test_perf_prefill_tile_sweep` on BMG (256 rows/expert, bf16 act), re-run -// after the vectorized activation quantizer and the single-block epilogue -// landed: -// -// shape 128x128 256x128 128x256 256x256 -// qwen3 up 2.846 ms 2.914 ms 2.938 ms 3.057 ms -// qwen3 down 2.078 ms 2.049 ms 2.170 ms 2.387 ms -// minimax up 5.866 ms 5.856 ms 5.997 ms 5.861 ms -// minimax down 6.271 ms 5.676 ms 6.001 ms 6.250 ms -// -// The 35-50% cliff the previous sweep saw on every 256-wide N tile is gone -- -// it was the float C shadow the mainloop used to keep live (see -// `xe_gemm_w4a8`), which doubled the per-lane C footprint and made `TileN = -// 256` ask for the entire 256-register large-GRF file. What is left is a -// 3-8% deficit, so the ladder still stays 128 wide in N. -// -// In M the two down-projections now prefer the 256-row tile -- 1.4% (qwen3 -// down) and 9.5% (minimax down) faster than 128x128 -- while the two -// up-projections are a wash: 0.2% faster on minimax up and 2.4% slower on -// qwen3 up. The same run measures the ladder's own 128x128 choice 0.3-6% apart -// from the explicit `128x128` row, so only minimax down is clearly outside -// run-to-run noise -- hence the M rung is taken only where it measured faster -// (`N >= K`, the down-projection regime; see `moe_w4a8_prefill_dispatch`), and -// every policy stays reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a -// re-sweep on a device with a different register budget. +// after the vectorized activation quantizer, the single-block epilogue and the +// interior-tile epilogue landed: +// +// shape 128x128 256x128 128x256 256x256 auto +// qwen3 up 2.774 ms 2.719 ms 2.809 ms 2.808 ms 2.815 ms +// qwen3 down 1.904 ms 1.829 ms 2.041 ms 2.058 ms 2.007 ms +// minimax up 5.584 ms 5.595 ms 5.694 ms 5.477 ms 5.674 ms +// minimax down 5.681 ms 5.605 ms 5.541 ms 5.662 ms 5.535 ms +// +// The 35-50% cliff the first sweep saw on every 256-wide N tile is gone -- it +// was the float C shadow the mainloop used to keep live (see `xe_gemm_w4a8`), +// which doubled the per-lane C footprint and made `TileN = 256` ask for the +// entire 256-register large-GRF file. What is left in N is a 0-8% deficit on +// three of the four shapes, so the ladder still stays 128 wide: the one row +// where `256x256` leads (minimax up, by 1.9% over `256x128`) is inside the +// noise band below. +// +// In M the 256-row tile is now at least as fast as `128x128` on **all four** +// shapes -- +2.0% (qwen3 up), +3.9% (qwen3 down), +1.3% (minimax down) and +// -0.2% (minimax up) -- which is why the rung is no longer gated on `N >= K`. +// The earlier run that produced that gate had the up-projections at -2.4% / +// +0.2%; the disagreement is the measurement noise, not the shapes. The same +// run puts the ladder's own choice up to 9% away from the explicit row it +// resolves to (qwen3 down: 2.007 ms "auto" vs 1.829 ms "256x128" -- the same +// kernel), which bounds run-to-run spread at ~5-9% and is why the rung is +// taken on the sign of the effect across shapes rather than on any single +// shape's margin. Every policy stays reachable through +// `ARK_MOE_W4A8_PREFILL_TILE` for a re-sweep on a device with a different +// register budget. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: @@ -924,14 +1051,17 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // // The top rung is the 256-row tile, which halves how often each expert's // weights are pulled through L2/DRAM (B is read once per M tile). It is taken -// only when both conditions the sweep found it winning under hold: +// on the single condition the sweep supports: +// +// * `A_avg_M >= 256` -- below that the tile is half empty and the doubled +// `TileM` buys nothing but padding. // -// * `A_avg_M >= 256` -- otherwise the tile is half empty and the doubled -// `TileM` buys nothing but padding, and -// * `N >= K` -- the down-projection regime, where it measured 1.4% (qwen3 -// down) and 9.5% (minimax down) faster than 128x128. On the two -// up-projections (`N < K`) it measured 0.2% faster and 2.4% slower, i.e. -// inside the same run's noise, so those keep 128x128. +// The rung used to carry a second condition, `N >= K` (the down-projection +// regime), because the first sweep measured the up-projections 0.2% faster and +// 2.4% slower with it. The re-run after the epilogue work (see the tile-policy +// comment above) has the 256-row tile ahead on three of four shapes and level +// on the fourth, with the disagreement smaller than the ~5-9% run-to-run +// spread the same table shows -- so the gate was measuring noise and is gone. // // `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile // (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything @@ -986,7 +1116,7 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) } else if (A_avg_M < 128) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - } else if (A_avg_M >= 256 && N >= K) { + } else if (A_avg_M >= 256) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) } else { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index d15dbbc0bc..72a0b706cf 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -962,6 +962,20 @@ def run_perf( ("act-quant vec", {"ARK_MOE_W4A8_ACT_QUANT_VEC": "1"}), ] +# Prefill: activation-quantization loads in flight. Widening the messages set +# how many bytes each *request* moves; this sets how many requests a work-item +# has outstanding. ``UNROLL`` vectors are loaded before any is consumed, and +# reduced into ``UNROLL`` partial maxima so they do not serialize behind the +# accumulator either. ``1`` is the kernel as it was before the batching, so the +# first row is an exact A/B baseline. +_ACT_QUANT_UNROLL_CONFIGS = [ + ( + f"act-quant unroll {u}", + {"ARK_MOE_W4A8_ACT_QUANT_VEC": "1", "ARK_MOE_W4A8_ACT_QUANT_UNROLL": str(u)}, + ) + for u in (1, 2, 4) +] + # Prefill: epilogue guard. A tile that touches neither the M nor the N edge # needs no store predicate and no scale-index clamp, and whether it does is # uniform across the work-group. The guarded path is what every tile used to @@ -1224,6 +1238,28 @@ def test_perf_prefill_act_quant_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"act-quant config {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_act_quant_unroll_sweep(self, request): + """Time the activation quantizer's loads-in-flight depth. + + Widening the messages (the sweep above) set how many bytes each + request moves; it did not change how many requests a work-item has + outstanding. The pass walks K with a runtime trip count and folds + every vector into one ``local_max``, so an in-order thread keeps + about one 256-byte load in flight -- roughly 160 KB across the + device, well under what a ~400 GB/s part needs to stay busy over a + memory latency. ``UNROLL`` loads that many independent vectors + before consuming any of them. The first row is the kernel as it was + before the batching, so this is an exact A/B measurement; the + arithmetic is unchanged, which + ``test_act_quant_unroll_matches`` asserts bit-for-bit. + """ + rows = run_config_sweep("prefill", _ACT_QUANT_UNROLL_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"act-quant unroll {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_epilogue_sweep(self, request): """Time the epilogue guard at the compute-bound batch. @@ -1277,6 +1313,51 @@ def test_act_quant_vec_matches_scalar(self): f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" ) + def test_act_quant_unroll_matches(self): + """Batching the quantizer's loads must be bit-identical at every depth. + + ``UNROLL`` only changes how many vectors are in flight before any + is consumed: the per-lane reduction is still ``fmax`` over the same + values (exact and order-independent, so the partial maxima merge to + the same bits as one chain), and every element goes through the + same ``rint``/``clamp``. ``UNROLL = 1`` is the kernel as it was + before the batching, so it is the reference here. + + Both K are checked because the tail loop is what a wrong bound + would break: at ``VEC = 8`` a lane walks ``K / 128`` vectors, so + K = 2048 divides by the default depth of 4 and K = 768 leaves a + two-vector tail. A missed or double-counted tail changes the + absmax, and hence every element of the row. + """ + for nk_label, N, K in _QWEN3_NK: + case = _build_case( + N, + K, + _QWEN3_E, + _PREFILL_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for unroll in ("1", "2", "4"): + with _env_override(ARK_MOE_W4A8_ACT_QUANT_VEC="1", ARK_MOE_W4A8_ACT_QUANT_UNROLL=unroll): + outs[unroll] = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + for unroll in ("2", "4"): + assert torch.equal(outs["1"], outs[unroll]), ( + f"{nk_label.strip()} (K={K}): activation quantization at unroll {unroll} disagrees with " + f"unroll 1: max |diff| " + f"{(outs['1'].float() - outs[unroll].float()).abs().max().item():.6g}" + ) + case = weights_s8 = wscales = outs = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + def test_full_tile_epilogue_matches_predicated(self): """Skipping the epilogue guard on interior tiles must change nothing. From a6b07c5f25d7a02e06e5fb8644dc925c2fd59367 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:45:43 +0000 Subject: [PATCH 066/112] docs(ark): record the new sweep and the act-quant unroll knob (EN + CN) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 166 ++++++++++++------ .../ark/test/README_MOE_W4A8_CN.md | 131 +++++++++----- .../ark/test/test_moe_w4a8_perf.py | 9 +- 3 files changed, 212 insertions(+), 94 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 11a0cecea4..c92b25f72d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -166,6 +166,29 @@ sub-group reduce the same absmax and quantize every element identically. `test_act_quant_vec_matches_scalar` asserts the two are **bit-identical**, and `ARK_MOE_W4A8_ACT_QUANT_VEC=0` restores the scalar mapping for A/B measurement. +**Loads in flight.** Widening the messages fixed how many bytes each *request* +moves; it did not change how many requests a work-item has outstanding. The +pass walks K with a runtime trip count (`steps = K / (SG_SIZE × VEC)`) and folds +every vector into the same `local_max`, so the loop reads as: issue one load, +stall until it returns, `fmax` it, repeat. Xe cores execute in order and `fmax` +is not reassociated without fast-math, so a thread keeps roughly *one* 256-byte +load in flight. That is a Little's-law problem, not a bandwidth one: 640 +concurrent sub-groups × 256 bytes is ~160 KB of in-flight reads, well under the +~400 KB a ~400 GB/s device needs to stay busy across a ~1 µs memory latency. It +is the same argument that made the decode GEMV load two chunks per iteration. + +Each iteration now loads `UNROLL` *independent* vectors before consuming any of +them, and reduces them into `UNROLL` separate partial maxima so the loads do not +serialize behind the accumulator chain either; the quantize pass batches its +loads the same way. `steps % UNROLL` vectors are left to a tail loop — at +`K = 768` a lane walks 6 vectors, so with the default `UNROLL = 4` the tail is +real code rather than a formality. Nothing that rounds changes (`fmax` is exact +and order-independent, so the partial maxima merge to the same bits), and +`UNROLL = 1` is the previous kernel instruction for instruction, so +`ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` is an exact A/B baseline. +`test_act_quant_unroll_matches` asserts bit-identity at both a K that divides +the unroll depth and one that leaves a tail. + **The GEMM epilogue.** The mainloop kept two C fragments live: the int32 DPAS accumulator, cleared once per AUTO_S8 re-scale block, and a float shadow that had to survive across blocks because each block's weight scale is applied before @@ -182,7 +205,7 @@ alone reserved a quarter of the register file for the whole mainloop, and at `128x256` the two fragments together *are* the register file — leaving nothing for the staged A/B tiles. That was the 35–50% penalty the 256-wide tiles used to pay in [Tuned defaults](#tuned-defaults-measured); with the shadow gone the whole -tile table fits in a 3–8% band. At the default re-scale block the shadow was pure +tile table fits in a 0–8% band. At the default re-scale block the shadow was pure overhead: `blks == 1` (the AUTO_S8 `group=-1` default) has nothing to carry across blocks, so the scale can be folded on the way out instead. That path now runs without the float fragment, and applies @@ -320,14 +343,15 @@ pytest -v -s test_moe_w4a8_perf.py -k sweep ``` `test_perf_decode_config_sweep`, `test_perf_prefill_tile_sweep`, -`test_perf_prefill_act_quant_sweep` and `test_perf_prefill_epilogue_sweep` build -one workload, prepack it once, then time every dispatch configuration against -it — the decode lane mapping (legacy GEMV plus every `CH` × `NCOLS` -combination), the prefill work-group tile, the activation-quantization message -width, and the epilogue's edge guard. Each configuration is checked for -numerical equivalence with the first one, and the table is followed by a `best -configuration` block naming the winning environment variables per shape, so the -tuning knobs can be settled in a single on-hardware run. +`test_perf_prefill_act_quant_sweep`, `test_perf_prefill_act_quant_unroll_sweep` +and `test_perf_prefill_epilogue_sweep` build one workload, prepack it once, then +time every dispatch configuration against it — the decode lane mapping (legacy +GEMV plus every `CH` × `NCOLS` combination), the prefill work-group tile, the +activation-quantization message width and loads-in-flight depth, and the +epilogue's edge guard. Each configuration is checked for numerical equivalence +with the first one, and the table is followed by a `best configuration` block +naming the winning environment variables per shape, so the tuning knobs can be +settled in a single on-hardware run. The `-s` flag is required to see the printed tables. @@ -415,12 +439,12 @@ checked for numerical equivalence with the first one before it is timed. | shape | `128x128` | `256x128` | `128x256` | `256x256` | |---|---|---|---|---| -| qwen3 up | **2.846 ms** | 2.914 ms | 2.938 ms | 3.057 ms | -| qwen3 down | 2.078 ms | **2.049 ms** | 2.170 ms | 2.387 ms | -| minimax up | 5.866 ms | **5.856 ms** | 5.997 ms | 5.861 ms | -| minimax down | 6.271 ms | **5.676 ms** | 6.001 ms | 6.250 ms | +| qwen3 up | 2.774 ms | **2.719 ms** | 2.809 ms | 2.808 ms | +| qwen3 down | 1.904 ms | **1.829 ms** | 2.041 ms | 2.058 ms | +| minimax up | 5.584 ms | 5.595 ms | 5.694 ms | **5.477 ms** | +| minimax down | 5.681 ms | 5.605 ms | **5.541 ms** | 5.662 ms | -The spread across the whole table is now 3–8%. It used to be 35–50%, and the +The spread across the whole table is now 0–8%. It used to be 35–50%, and the cliff was not the tile shape: the mainloop kept a float shadow of the C fragment live next to the int32 one, so `TileN = 256` asked for 128 + 128 registers per SIMD16 lane — the entire large-GRF file. With the shadow gone (see @@ -432,41 +456,68 @@ occupancy rather than in spills. `TileM = 256` is the direction that pays. It keeps the per-lane fragment at 32×32 because `SGLayout` grows to 8×4 instead, while halving how often the expert's B panel is re-read: per-expert tile traffic is -`M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`. The win is real only where B is the larger -operand — the down-projections, `N ≥ K` — and there it is 1.5% (qwen3) to 9.5% -(minimax). On qwen3 up (`N < K`) it costs 2.4%. +`M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`. It is now at least as fast as `128x128` on +**all four** shapes — +2.0% (qwen3 up), +3.9% (qwen3 down), +1.3% (minimax +down), −0.2% (minimax up). The ladder therefore is: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, -`≥ 256` **and** `N ≥ K` → **`256x128`**, otherwise `128x128`. All six policies -stay compiled and selectable with `ARK_MOE_W4A8_PREFILL_TILE`. - -Two caveats on reading the table. "tile auto" and "tile `128x128`" run the same -kernel yet differ by up to 6% (2.212 vs 2.078 ms on qwen3 down), so anything -under ~5% here is inside run-to-run noise — only the minimax down row is clearly -outside it. And the ladder's rows/expert threshold is compared against -`total_tokens / E`, the *average*, so a skewed routing that averages 256 rows -can still leave many experts with ragged 256-row tiles. - -At the chosen tiles the swept shapes reach 72.7 / 50.3 / 79.2 / 81.7 TFLOPS +`≥ 256` → **`256x128`**, otherwise `128x128`. All six policies stay compiled and +selectable with `ARK_MOE_W4A8_PREFILL_TILE`. + +The `≥ 256` rung used to carry a second condition, `N ≥ K`, because the first +sweep had the two up-projections at +0.2% / −2.4%. That gate is gone: this run +disagrees with it by more than the noise floor below, so it was fitting a +measurement rather than the hardware. + +Two caveats on reading the table. "tile auto" and the explicit row it resolves +to run the same kernel yet differ by up to 9% (2.007 vs 1.829 ms on qwen3 +down), so anything under ~5–9% here is inside run-to-run noise — including the +one row where `256x256` leads. The rung is therefore taken on the sign of the +effect across shapes, not on any single shape's margin. And the ladder's +rows/expert threshold is compared against `total_tokens / E`, the *average*, so +a skewed routing that averages 256 rows can still leave many experts with ragged +256-row tiles. + +At the chosen tile the swept shapes reach 75.8 / 56.4 / 82.9 / 82.8 TFLOPS (qwen3 up / down, minimax up / down) — up from 61.1 / 39.4 / 68.8 / 65.3 before the C-shadow removal and the vectorized activation quantizer, but qwen3 down is -still far short of the 100 TFLOPS target: at `K = 768` a tile runs only 12 -k-tiles, so the epilogue and the prologue are a large share of it. +still short of the 100 TFLOPS target: at `K = 768` a tile runs only 12 k-tiles, +so the epilogue and the prologue are a large share of it. ### Prefill activation quantization | shape | scalar | vectorized (default) | speedup | |---|---|---|---| -| qwen3 up | 3.082 ms | **2.713 ms** | 1.14× | -| qwen3 down | 2.165 ms | **2.017 ms** | 1.07× | -| minimax up | 6.161 ms | **5.611 ms** | 1.10× | -| minimax down | 6.563 ms | **6.142 ms** | 1.07× | +| qwen3 up | 3.040 ms | **2.699 ms** | 1.13× | +| qwen3 down | 2.088 ms | **1.824 ms** | 1.14× | +| minimax up | 6.269 ms | **5.623 ms** | 1.12× | +| minimax down | 5.965 ms | **5.716 ms** | 1.04× | Quantizing the routed activations is a streaming pass over `[T, K]` next to a -GEMM that already moves ~400 MB, and it is worth 7–14% of the whole call purely +GEMM that already moves ~400 MB, and it is worth 4–14% of the whole call purely by issuing 256-byte loads and 128-byte stores instead of 32-byte and 16-byte ones. `ARK_MOE_W4A8_ACT_QUANT_VEC=0` restores the scalar mapping. +How many of those wide loads a work-item keeps *outstanding* is the separate +knob `ARK_MOE_W4A8_ACT_QUANT_UNROLL` (1 = the mapping as measured above, 2, or +4 = default); `test_perf_prefill_act_quant_unroll_sweep` times it and the row +above is its `unroll 1` baseline. + +### Prefill epilogue guard + +| shape | guarded | interior-tile (default) | speedup | +|---|---|---|---| +| qwen3 up | 2.774 ms | **2.634 ms** | 1.05× | +| qwen3 down | 2.214 ms | **1.924 ms** | 1.15× | +| minimax up | 5.884 ms | **5.785 ms** | 1.02× | +| minimax down | 5.968 ms | **5.700 ms** | 1.05× | + +The mainloop is identical in both columns; only the store differs, so this is +the cost of ~4 instructions per output element. It is largest exactly where the +mainloop is shortest — qwen3 down runs 12 k-tiles per tile at `K = 768` — which +is the shape the ordering argument predicted. `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` +restores the guarded epilogue; the two are bit-identical. + ### Decode chunk width and column blocking | shape | fastest equivalent config | default (`CH=16`, `NCOLS=2`) | `CH=32`, same `NCOLS` | @@ -491,9 +542,10 @@ worth 1.09–1.93× over the legacy GEMV. | `ARK_MOE_W4A8_DECODE_KSPLIT` | Coalesced K-split decode mapping; **on by default**. Set to `0` to fall back to the original one-work-item-per-output GEMV (useful for A/B measurements). Ignored when the shape doesn't qualify. | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. `2` is the measured default, see [Tuned defaults](#tuned-defaults-measured). | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. Measured slower than `16` on every swept shape, so it is a sweep point rather than a recommendation. | -| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which picks `256x128` at ≥ 256 rows/expert when `N ≥ K` and `128x128` otherwise (see [Tuned defaults](#tuned-defaults-measured)). The whole table is now within 3–8%, so this is a re-tuning knob rather than a cliff. | -| `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.07–1.14× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | -| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**. The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | +| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which picks `256x128` at ≥ 256 rows/expert and `128x128` otherwise (see [Tuned defaults](#tuned-defaults-measured)). The whole table is now within 0–8%, so this is a re-tuning knob rather than a cliff. | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.04–1.14× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | +| `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized mapping. | +| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth 1.02–1.15× on the swept shapes. The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | ## Shape constraints @@ -516,26 +568,34 @@ The W4A8 kernel is a new SYCL/CuTe port, marked `STATUS: PARTIALLY HARDWARE-VALIDATED` in `auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. The perf sweeps have been run on BMG — that run is where the tile ladder, the activation-quantization -default and the decode `CH` / `NCOLS` defaults come from (see -[Tuned defaults](#tuned-defaults-measured)) — and every swept configuration -passed the cross-configuration equivalence check. -`test_act_quant_vec_matches_scalar` and `test_decode_ksplit_matches_legacy` pass -on device, so both the vectorized activation quantizer and the K-split decode -mapping are checked against their predecessors as well as timed. +default, the interior-tile epilogue and the decode `CH` / `NCOLS` defaults come +from (see [Tuned defaults](#tuned-defaults-measured)) — and every swept +configuration passed the cross-configuration equivalence check. +`test_act_quant_vec_matches_scalar`, `test_full_tile_epilogue_matches_predicated` +and `test_decode_ksplit_matches_legacy` pass on device, so the vectorized +activation quantizer, the interior-tile epilogue and the K-split decode mapping +are each checked against their predecessors as well as timed. Still to run on device: the accuracy sweep against the fp32 reference, which will catch layout/scale bugs immediately. -The interior-tile epilogue is the one change that has been reasoned through but -neither timed nor run: a tile that touches neither the M nor the N edge stores -without a predicate and indexes the scales without clamping. Its device checks -are `test_full_tile_epilogue_matches_predicated` (bit-identity, at a batch that -gives every expert one interior and one ragged tile) and -`test_perf_prefill_epilogue_sweep`; `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` reverts -it without a rebuild. +The activation quantizer's batched loads are the one change that has been +reasoned through but neither timed nor run: `UNROLL` vectors are loaded before +any is consumed, so a work-item keeps that many requests outstanding instead of +one. Its device checks are `test_act_quant_unroll_matches` (bit-identity at both +a K that divides the depth and one that leaves a tail) and +`test_perf_prefill_act_quant_unroll_sweep`; `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` +reverts it without a rebuild. The next prefill step, once that lands, is the store itself: the epilogue still writes D one element per predicate through a scalar pointer, where the sibling int8 and fp8 kernels use a block-2D copy for D. At `K = 768` — the shape stuck -at 50 TFLOPS — a tile runs only 12 k-tiles, so the store is a large fraction of -its time. +at 56 TFLOPS — a tile runs only 12 k-tiles, so the store is a large fraction of +its time. Unlike everything above it is not a pure-C++ change: the Xe DPAS C +fragment gives each lane one *column*, so consecutive values of a lane are +strided by N and only the hardware 2D block-store message can widen them. The +port needs `partition_sg_fragment_C` / `partition_sg_fragment_S` and a +`reorder` through a float intermediate (the accumulator is int32 and must be +scaled per row *and* per column first), and no sibling kernel 2D-stores a +*scaled* int32 accumulator — so it wants an environment with a SYCL compiler and +a device, not a flag. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index eabfa82aac..11f7766d1a 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -149,6 +149,25 @@ absmax,再量化),写一遍 `[T, K]` 的 int8。在 32768 条路由行、`K 同。`test_act_quant_vec_matches_scalar` 断言两者**逐位相同**,而 `ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射以便做 A/B 对比测量。 +**在途请求数 (loads in flight)。** 加宽消息解决的是每个**请求**搬运多少字节,并没有 +改变一个 work-item 同时挂起多少个请求。这一遍扫描 K 的循环次数是运行期决定的 +(`steps = K / (SG_SIZE × VEC)`),且每个向量都折进同一个 `local_max`,因此循环读起来 +就是:发一条 load,停下来等它返回,做一次 `fmax`,再来一遍。Xe core 是顺序执行的,而 +`fmax` 在没有 fast-math 时不会被重结合,所以一个线程大约只保持**一条** 256 字节的 +load 在途。这是 Little 定律的问题,而不是带宽的问题:640 个并发 sub-group × 256 字节 +只有约 160 KB 的在途读取,而一块约 400 GB/s 的设备要在约 1 µs 的访存延迟下保持忙碌需 +要约 400 KB。这与 decode GEMV 每次迭代加载两个 chunk 是同一个论证。 + +现在每次迭代先加载 `UNROLL` 个**互相独立**的向量,然后才开始消费它们,并把它们归约到 +`UNROLL` 个各自独立的局部最大值上,使这些 load 也不必串行等待累加器链;量化那一遍同样 +按此批量化其 load。`steps % UNROLL` 个向量交给尾循环处理——`K = 768` 时一个 lane 要走 +6 个向量,因此在默认 `UNROLL = 4` 下尾循环是真实会执行的代码,而不是形式上的补充。任 +何会产生舍入的步骤都没有改变 (`fmax` 精确且与顺序无关,因此各局部最大值合并后逐位相 +同),且 `UNROLL = 1` 与批量化之前的 kernel 逐条指令一致,所以 +`ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` 是精确的 A/B 基线。 +`test_act_quant_unroll_matches` 会在"K 能整除展开深度"和"K 会留下尾巴"两种情况下断言 +逐位相同。 + **GEMM 尾声 (epilogue)。** 原先的主循环同时保持两份 C fragment:int32 的 DPAS 累加 器 (每个 AUTO_S8 重缩放 block 清零一次),以及一份必须跨 block 存活的 float 影子—— 因为每个 block 的权重 scale 必须在下一个 block 覆盖累加器之前应用。在**整个**主循 @@ -163,7 +182,7 @@ absmax,再量化),写一遍 `[T, K]` 的 int8。在 32768 条路由行、`K 在整个主循环里占掉了四分之一的寄存器堆,而 `128x256` 时两份 fragment 加起来*就是* 整个寄存器堆——留给暂存 A/B tile 的空间为零。这正是 N 方向 256 的 tile 过去在 [实测得到的默认值](#实测得到的默认值)中要多付 35–50% 的原因;去掉 float 影子之后, -整张 tile 表已经收敛到 3–8% 的区间内。而在默认重缩放 block 下它完全是白付的开销: +整张 tile 表已经收敛到 0–8% 的区间内。而在默认重缩放 block 下它完全是白付的开销: `blks == 1` (AUTO_S8 `group=-1` 默认值) 根本没有需要跨 block 携带的东西,scale 完全 可以在写出时再折进去。该路径现在不再分配 float fragment,而是一次遍历就应用 `scale_b[col] × scale_a[row]`——与参考的稠密 int8 GEMM 中 `AccumBlock == false` @@ -293,10 +312,11 @@ pytest -v -s test_moe_w4a8_perf.py -k sweep ``` `test_perf_decode_config_sweep`、`test_perf_prefill_tile_sweep`、 -`test_perf_prefill_act_quant_sweep` 和 `test_perf_prefill_epilogue_sweep` 只构造一 +`test_perf_prefill_act_quant_sweep`、`test_perf_prefill_act_quant_unroll_sweep` +和 `test_perf_prefill_epilogue_sweep` 只构造一 次 workload、只 prepack 一次,然后用同一份数据依次给每种 dispatch 配置计时——decode 的 lane 映射 (legacy GEMV 以及 `CH` × `NCOLS` 的全部组合)、prefill 的 work-group -tile、激活量化的消息宽度,以及 epilogue 的边界保护。每 +tile、激活量化的消息宽度与在途请求深度,以及 epilogue 的边界保护。每 种配置都会与第一种配置做数值等价性检查,表格之后还会打印一段 `best configuration`,按形状给出获胜配置对应的环境变量,因此在硬件上跑一次就能确定这些 调优开关。 @@ -383,12 +403,12 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 | 形状 | `128x128` | `256x128` | `128x256` | `256x256` | |---|---|---|---|---| -| qwen3 up | **2.846 ms** | 2.914 ms | 2.938 ms | 3.057 ms | -| qwen3 down | 2.078 ms | **2.049 ms** | 2.170 ms | 2.387 ms | -| minimax up | 5.866 ms | **5.856 ms** | 5.997 ms | 5.861 ms | -| minimax down | 6.271 ms | **5.676 ms** | 6.001 ms | 6.250 ms | +| qwen3 up | 2.774 ms | **2.719 ms** | 2.809 ms | 2.808 ms | +| qwen3 down | 1.904 ms | **1.829 ms** | 2.041 ms | 2.058 ms | +| minimax up | 5.584 ms | 5.595 ms | 5.694 ms | **5.477 ms** | +| minimax down | 5.681 ms | 5.605 ms | **5.541 ms** | 5.662 ms | -现在整张表的差距只有 3–8%。它曾经是 35–50%,而那道悬崖的成因并不是 tile 形状:当时 +现在整张表的差距只有 0–8%。它曾经是 35–50%,而那道悬崖的成因并不是 tile 形状:当时 主循环在 int32 累加器之外还全程保持一份 C fragment 的 float 影子,于是 `TileN = 256` 需要每个 SIMD16 lane 128 + 128 个寄存器,即整个 large-GRF 寄存器堆。去掉这份影子之后 (参见 [Prefill:访存消息宽度与寄存器压力](#prefill访存消息宽度与寄存器压力)),N 方向 @@ -397,37 +417,60 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 真正有收益的方向是 `TileM = 256`:它把 `SGLayout` 变成 8×4,从而让每个 lane 的 fragment 仍保持 32×32,同时把专家 B 面板的重复读取次数减半——每个专家的 tile 访存量 -为 `M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`。只有当 B 是较大的那个操作数时 (即 down 投影, -`N ≥ K`) 这个收益才成立,此时可得 1.5% (qwen3) 到 9.5% (minimax) 的提升;而在 -qwen3 up (`N < K`) 上则要倒亏 2.4%。 +为 `M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`。现在它在**全部四个**形状上都不慢于 +`128x128`——+2.0% (qwen3 up)、+3.9% (qwen3 down)、+1.3% (minimax down)、 +−0.2% (minimax up)。 -因此 tile 阶梯为:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,`≥ 256` **且** -`N ≥ K` → **`256x128`**,其余 → `128x128`。六个 policy 全部保留编译,并可通过 +因此 tile 阶梯为:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,`≥ 256` → +**`256x128`**,其余 → `128x128`。六个 policy 全部保留编译,并可通过 `ARK_MOE_W4A8_PREFILL_TILE` 手动选择。 -读这张表时有两点需要注意。"tile auto" 与 "tile `128x128`" 跑的是同一个 kernel,二者 -却相差最多 6% (qwen3 down 上 2.212 vs 2.078 ms),因此这里 ~5% 以内的差异都在运行间 -噪声范围内——只有 minimax down 那一行明显超出噪声。另外,阶梯里的每专家行数阈值比较 -的是 `total_tokens / E` 这个**平均值**,因此在路由不均衡时,即便平均为 256 行,仍可 -能有很多专家只有残缺的 256 行 tile。 +`≥ 256` 这一档过去还附带第二个条件 `N ≥ K`,因为上一次 sweep 中两个 up 投影分别是 ++0.2% / −2.4%。这个条件现在已经去掉:本次测量与它的分歧超出了下面所说的噪声下限,说 +明它拟合的是一次测量结果,而不是硬件本身的行为。 + +读这张表时有两点需要注意。"tile auto" 与它实际解析到的那一列跑的是同一个 kernel,二 +者却相差最多 9% (qwen3 down 上 2.007 vs 1.829 ms),因此这里 ~5–9% 以内的差异都在运行 +间噪声范围内——`256x256` 领先的那一行也在其中。所以这一档是依据各形状上效果的**符 +号**来决定的,而不是依据某一个形状的具体幅度。另外,阶梯里的每专家行数阈值比较的是 +`total_tokens / E` 这个**平均值**,因此在路由不均衡时,即便平均为 256 行,仍可能有很 +多专家只有残缺的 256 行 tile。 -在各自选中的 tile 下,被扫描的四个形状达到 72.7 / 50.3 / 79.2 / 81.7 TFLOPS +在选中的 tile 下,被扫描的四个形状达到 75.8 / 56.4 / 82.9 / 82.8 TFLOPS (qwen3 up / down、minimax up / down)——相比去掉 float 影子和引入向量化激活量化之前的 -61.1 / 39.4 / 68.8 / 65.3 有明显提升,但 qwen3 down 距离 100 TFLOPS 的目标仍然很远: +61.1 / 39.4 / 68.8 / 65.3 有明显提升,但 qwen3 down 距离 100 TFLOPS 的目标仍有差距: `K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此占了相当大的比例。 ### Prefill 激活量化 | 形状 | 标量 | 向量化 (默认) | 加速比 | |---|---|---|---| -| qwen3 up | 3.082 ms | **2.713 ms** | 1.14× | -| qwen3 down | 2.165 ms | **2.017 ms** | 1.07× | -| minimax up | 6.161 ms | **5.611 ms** | 1.10× | -| minimax down | 6.563 ms | **6.142 ms** | 1.07× | +| qwen3 up | 3.040 ms | **2.699 ms** | 1.13× | +| qwen3 down | 2.088 ms | **1.824 ms** | 1.14× | +| minimax up | 6.269 ms | **5.623 ms** | 1.12× | +| minimax down | 5.965 ms | **5.716 ms** | 1.04× | 量化 routed 激活只是对 `[T, K]` 的一次流式遍历,而与它并行的 GEMM 本身就要搬运约 400 MB;仅仅把 32 字节 load / 16 字节 store 换成 256 字节 / 128 字节,就能带来整次调 -用 7–14% 的收益。`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射。 +用 4–14% 的收益。`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射。 + +一个 work-item 能让多少条这样的宽 load 同时**在途**,则由另一个开关 +`ARK_MOE_W4A8_ACT_QUANT_UNROLL` 控制 (1 = 上表所测的映射、2、4 = 默认值); +`test_perf_prefill_act_quant_unroll_sweep` 会为它计时,上表就是其 `unroll 1` 基线。 + +### Prefill epilogue 边界保护 + +| 形状 | 带保护 | 内部 tile 快速路径 (默认) | 加速比 | +|---|---|---|---| +| qwen3 up | 2.774 ms | **2.634 ms** | 1.05× | +| qwen3 down | 2.214 ms | **1.924 ms** | 1.15× | +| minimax up | 5.884 ms | **5.785 ms** | 1.02× | +| minimax down | 5.968 ms | **5.700 ms** | 1.05× | + +两列的主循环完全相同,只有 store 不同,因此这就是每个输出元素约 4 条指令的代价。它在 +主循环最短的形状上占比最大——`K = 768` 的 qwen3 down 每个 tile 只跑 12 个 k-tile——正 +是当初按指令数推理所预期的那个形状。`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 可切回带保护 +的 epilogue;两者逐位相同。 ### Decode 的 chunk 宽度与列分块 @@ -452,9 +495,10 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_DECODE_KSPLIT` | 合并访存的 K-split decode 映射,**默认开启**。设为 `0` 可回退到原来每个输出一个 work-item 的 GEMV (便于 A/B 对比)。形状不满足条件时该开关无效。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。默认值 `2` 来自实测,参见[实测得到的默认值](#实测得到的默认值)。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。实测中它在所有形状上都慢于 `16`,因此只作为扫描项而非推荐值。 | -| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 ≥ 256 行且 `N ≥ K` 时取 `256x128`,否则取 `128x128` (参见[实测得到的默认值](#实测得到的默认值))。现在整张表的差距已收敛到 3–8%,因此这个开关是重新调优用的旋钮,而不再对应一道性能悬崖。 | -| `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.07–1.14× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | -| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | +| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 ≥ 256 行时取 `256x128`,否则取 `128x128` (参见[实测得到的默认值](#实测得到的默认值))。现在整张表的差距已收敛到 0–8%,因此这个开关是重新调优用的旋钮,而不再对应一道性能悬崖。 | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.04–1.14× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | +| `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化映射生效。 | +| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上带来 1.02–1.15× 的收益。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | ## 形状约束 @@ -475,22 +519,29 @@ decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 `auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp` 中被标记为 `STATUS: PARTIALLY HARDWARE-VALIDATED`。各项性能扫描都已在 BMG 上跑过——tile 阶梯、 -激活量化的默认值以及 decode 的 `CH` / `NCOLS` 默认值正是来自那些运行 (参见[实测得到 -的默认值](#实测得到的默认值)),并且所有被扫描的配置都通过了配置间的数值等价性检查。 -`test_act_quant_vec_matches_scalar` 与 `test_decode_ksplit_matches_legacy` 也已在设 -备上通过,因此向量化激活量化与 K-split decode 映射既有计时数据,也都与各自的前身做 -过比对。 +激活量化的默认值、内部 tile 的 epilogue 以及 decode 的 `CH` / `NCOLS` 默认值正是来自 +那些运行 (参见[实测得到的默认值](#实测得到的默认值)),并且所有被扫描的配置都通过了配 +置间的数值等价性检查。`test_act_quant_vec_matches_scalar`、 +`test_full_tile_epilogue_matches_predicated` 与 `test_decode_ksplit_matches_legacy` +也已在设备上通过,因此向量化激活量化、内部 tile 的 epilogue 与 K-split decode 映射既 +有计时数据,也都与各自的前身做过比对。 仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描,它能立刻暴露 layout / scale 相关的 bug。 -内部 tile 的 epilogue 是唯一一项只经过推导、既未实测计时也尚未在设备上运行的改动: -既不触及 M 边界也不触及 N 边界的 tile 在写出时不再使用谓词,读 scale 时也不再钳制下 -标。它在设备上的检查是 `test_full_tile_epilogue_matches_predicated` (逐位一致性,所 -用 batch 让每个专家恰好有一个完整 tile 和一个残缺 tile) 与 -`test_perf_prefill_epilogue_sweep`;设置 `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 即可在 -不重新编译的情况下回退。 +激活量化的批量 load 是唯一一项只经过推导、既未实测计时也尚未在设备上运行的改动:先加 +载 `UNROLL` 个向量再开始消费,使一个 work-item 同时挂起这么多请求,而不是只有一个。它 +在设备上的检查是 `test_act_quant_unroll_matches` (在"K 能整除展开深度"和"K 会留下尾 +巴"两种情况下的逐位一致性) 与 `test_perf_prefill_act_quant_unroll_sweep`;设置 +`ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` 即可在不重新编译的情况下回退。 这一项落地之后,prefill 的下一步是写出本身:epilogue 目前仍然通过标量指针、按谓词逐 -元素写 D,而同类的 int8 与 fp8 kernel 对 D 用的是 block-2D copy。在卡在 50 TFLOPS 的 +元素写 D,而同类的 int8 与 fp8 kernel 对 D 用的是 block-2D copy。在卡在 56 TFLOPS 的 `K = 768` 形状上,一个 tile 只有 12 个 k-tile,写出因此占据了其中相当大的一部分时间。 +与上面几项不同,它并不是纯 C++ 的改动:Xe DPAS 的 C fragment 给每个 lane 分配的是一 +*列*,因此同一个 lane 的相邻数值在内存中相隔 N,只有硬件的 2D block-store 消息才能把 +它们合并成宽消息。移植它需要用到 `partition_sg_fragment_C` / +`partition_sg_fragment_S` 以及经由 float 中间 fragment 的 `reorder` (累加器是 int32, +必须先按行、再按列施加 scale),而同类 kernel 中没有任何一个是对**带 scale 的** int32 +累加器做 2D 写出的——因此这项工作需要一个具备 SYCL 编译器和设备的环境,而不是一个开 +关。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 72a0b706cf..125fe60138 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -60,11 +60,18 @@ * ``ARK_MOE_W4A8_DECODE_MAX_TOKENS`` -- token count at or below which ``phase="auto"`` picks the GEMV (default 128). +The dispatch knobs the sweeps here drive -- ``ARK_MOE_W4A8_PREFILL_TILE``, +``ARK_MOE_W4A8_ACT_QUANT_VEC``, ``ARK_MOE_W4A8_ACT_QUANT_UNROLL``, +``ARK_MOE_W4A8_PREFILL_FULL_TILE`` and the ``ARK_MOE_W4A8_DECODE_KSPLIT*`` +family -- are documented in ``README_MOE_W4A8.md``; each sweep sets them itself +and restores the environment afterwards. + .. note:: The W4A8 kernel is a new SYCL/CuTe port; this script is the intended on-hardware validation vehicle for it (the kernel header is marked - ``STATUS: NEEDS-HARDWARE-VALIDATION``). + ``STATUS: PARTIALLY HARDWARE-VALIDATED`` and names the paths that still + need a device run). """ import argparse From 6d16b0098a21b451b14cb71f6cb9692934dbd1c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:26:17 +0000 Subject: [PATCH 067/112] docs(ark): pin the W4A8 roofline to the Arc Pro B60 it was measured on (EN + CN) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 34 ++++---- .../ark/test/README_MOE_W4A8.md | 80 +++++++++++++------ .../ark/test/README_MOE_W4A8_CN.md | 62 ++++++++++---- .../ark/test/test_moe_w4a8_perf.py | 7 +- 4 files changed, 123 insertions(+), 60 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 04ccc13c8b..03d7854fe8 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -4,9 +4,11 @@ // `test_perf_prefill_act_quant_sweep`, `test_perf_prefill_epilogue_sweep`, // `test_perf_decode_config_sweep`, `test_act_quant_vec_matches_scalar`, // `test_full_tile_epilogue_matches_predicated` and -// `test_decode_ksplit_matches_legacy` have been run on BMG, so both phases -// compile and run and every dispatch default (tile ladder, activation-quant -// message width, interior-tile epilogue, decode CH / NCOLS) comes from those +// `test_decode_ksplit_matches_legacy` have been run on an Intel Arc Pro B60 +// (Battlemage, BMG-G21 -- 20 Xe2 cores / 160 XVEs at ~2.4 GHz, ~197 int8 TOPS, +// 24 GB GDDR6 at 456 GB/s), so both phases compile and run and every dispatch +// default (tile ladder, activation-quant message width, interior-tile epilogue, +// decode CH / NCOLS) comes from those // measurements; every swept configuration also passed the cross-configuration // equivalence check. The accuracy gates against the fp32 reference still need // a device run. The authoring environment has no XPU and no SYCL compiler, so @@ -243,20 +245,22 @@ inline DeviceScratchPool& expert_map_pool() { // `fmax` is not reassociated without fast-math, so each thread keeps roughly // *one* 256-byte load in flight. // -// That is a Little's-law problem, not a bandwidth one: 640 concurrent -// sub-groups (20 Xe cores x 32 threads) x 256 bytes is ~160 KB of in-flight -// reads, well under the ~400 KB a ~400 GB/s device needs to stay busy across a -// ~1 us memory latency. The same argument is why the decode GEMV loads two -// chunks per iteration (`launch_w4a8_decode_ksplit`), and why the pair is -// spelled out there rather than left to the compiler. +// That is a Little's-law problem, not a bandwidth one: 1280 concurrent +// sub-groups (the B60's occupancy ceiling -- 160 XVEs x 8 thread slots) x 256 +// bytes is ~320 KB of in-flight reads, under the ~456 KB a 456 GB/s device +// needs to stay busy across a ~1 us memory latency, and a real launch rarely +// fills every slot. The same argument is why the decode GEMV loads two chunks +// per iteration (`launch_w4a8_decode_ksplit`), and why the pair is spelled out +// there rather than left to the compiler. // // `UNROLL` gives the pass the same treatment: each iteration loads `UNROLL` // *independent* vectors before consuming any of them, and reduces them into // `UNROLL` separate partial maxima so the loads do not serialize behind the -// accumulator chain either. The quantize pass batches its loads the same way, -// and its stores are already independent. `steps % UNROLL` vectors are left to -// a tail loop -- `K = 768` (qwen3 down) gives `steps = 6`, so the tail is real -// code, not a formality. +// accumulator chain either. At the default `UNROLL = 4` a thread holds 1 KB, +// which clears the 456 KB well before every slot is occupied. The quantize +// pass batches its loads the same way, and its stores are already independent. +// `steps % UNROLL` vectors are left to a tail loop -- `K = 768` (qwen3 down) +// gives `steps = 6`, so the tail is real code, not a formality. // // Nothing that rounds changes: the per-lane partial reduction is still `fmax` // over the same values (exact and order-independent, so partial maxima merge @@ -1204,8 +1208,8 @@ void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, // per sub-group -- the pattern DRAM row buffers handle worst. // 2. The grid is small: `total_tokens * N / 16` sub-groups, i.e. 768 SIMD16 // threads for a Qwen3-MoE batch-1 step (8 routed rows, N = 1536). That is -// below the thread slots of a BMG-class GPU, so there are never enough -// loads in flight to cover DRAM latency. +// below the thread slots of a BMG-class GPU (1280 on a B60), so there are +// never enough loads in flight to cover DRAM latency. // // This kernel transposes the mapping exactly like `launch_fp8_ksplit`: a whole // sub-group cooperates on one output element and lane `l` owns the `CH` diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index c92b25f72d..e62cadb20f 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -42,6 +42,22 @@ This kernel applies the same idea to the MoE grouped GEMM. The conversion runs ## Performance targets and the roofline +### The device every number in this document was measured on + +An **Intel Arc Pro B60** (Battlemage, `BMG-G21` — the default AOT target +`intel_gpu_bmg_g21`): 20 Xe2 cores / 160 XVEs at ~2.4 GHz, 24 GB GDDR6 on a +192-bit bus. The ceilings it sets: + +| Ceiling | Value | +|---|---| +| int8 XMX (DPAS) | 160 XVEs × 512 int8 ops/clk × 2.4 GHz ≈ **197 TOPS** | +| bf16 / fp16 XMX | ≈ **98 TFLOPS**, half the int8 rate — the reason W4A8 exists | +| DRAM pin bandwidth | **456 GB/s**; the harness' device-copy probe reads ~400 GB/s (88% of pin) | +| Occupancy ceiling | 160 XVEs × 8 thread slots = **1280 concurrent SIMD16 sub-groups** | + +So the two targets below are 51% of int8 peak and 66% of pin bandwidth. The +`Arc Pro B60 Dual` card exposes two such devices; the kernel sees one. + The goals for this kernel are **prefill > 100 TFLOPS** and **decode > 300 GB/s** of weight bandwidth. Whether the prefill goal is reachable at all is decided by the *routing*, not by the kernel. A W4A8 grouped GEMM reads every active @@ -68,10 +84,10 @@ So ~4.5 TFLOPS at the default batch is **not** a kernel deficiency: at 8 rows per expert and the ~285 GB/s of weight bandwidth the kernel actually achieves, the ceiling is `2 × 8 × 285e9 = 4.56 TFLOPS` — the measured value, i.e. the kernel is already running at the DRAM roofline. Reaching 100 TFLOPS there would -require 6.25 TB/s, more than 10× any current GPU. On a device streaming -~285 GB/s the target first becomes reachable at ~176 rows per expert (~2816 -model tokens), which is why `test_perf_prefill_compute_bound` measures at 4096 -model tokens. +require 6.25 TB/s, 13× the B60's 456 GB/s and more than 10× any current GPU. On +a device streaming ~285 GB/s the target first becomes reachable at ~176 rows per +expert (~2816 model tokens), which is why `test_perf_prefill_compute_bound` +measures at 4096 model tokens. The perf table therefore prints `rows/E` and `BW@100T` next to the measured numbers, and each sweep ends with a verdict block: @@ -98,11 +114,13 @@ exchange for ~2× the DPAS peak, so it only wins once the GEMM is compute bound: crossover rows/expert ~= int8_peak_TOPS / (4 × weight_bandwidth) ``` -With ~233 TOPS of int8 DPAS and ~285 GB/s that is ~200 rows per expert -(~3200 model tokens). Decode (1 row per expert) and small-batch prefill are far -below it, so readings of 0.55–0.71× are expected there: W4A8 is a large-batch -prefill optimization, and at decode it can only help by improving the *memory* -path. +With the B60's ~197 TOPS of int8 DPAS and the ~285 GB/s the kernel streams, that +is ~173 rows per expert (~2760 model tokens) — essentially the same routing at +which the bandwidth roofline first admits 100 TFLOPS (~176 rows above), so on +this part the two crossings coincide. Decode (1 row per expert) and small-batch +prefill are far below it, so readings of 0.55–0.71× are expected there: W4A8 is +a large-batch prefill optimization, and at decode it can only help by improving +the *memory* path. ## Decode: coalesced K-split mapping @@ -172,16 +190,20 @@ pass walks K with a runtime trip count (`steps = K / (SG_SIZE × VEC)`) and fold every vector into the same `local_max`, so the loop reads as: issue one load, stall until it returns, `fmax` it, repeat. Xe cores execute in order and `fmax` is not reassociated without fast-math, so a thread keeps roughly *one* 256-byte -load in flight. That is a Little's-law problem, not a bandwidth one: 640 -concurrent sub-groups × 256 bytes is ~160 KB of in-flight reads, well under the -~400 KB a ~400 GB/s device needs to stay busy across a ~1 µs memory latency. It -is the same argument that made the decode GEMV load two chunks per iteration. +load in flight. That is a Little's-law problem, not a bandwidth one: 1280 +concurrent sub-groups (the B60's occupancy ceiling — 160 XVEs × 8 thread slots) +× 256 bytes is ~320 KB of in-flight reads, under the ~456 KB a 456 GB/s device +needs to stay busy across a ~1 µs memory latency, and a real launch rarely fills +every slot. It is the same argument that made the decode GEMV load two chunks +per iteration. Each iteration now loads `UNROLL` *independent* vectors before consuming any of them, and reduces them into `UNROLL` separate partial maxima so the loads do not serialize behind the accumulator chain either; the quantize pass batches its -loads the same way. `steps % UNROLL` vectors are left to a tail loop — at -`K = 768` a lane walks 6 vectors, so with the default `UNROLL = 4` the tail is +loads the same way. At the default `UNROLL = 4` a thread holds 1 KB, which +clears the 456 KB well before every slot is occupied. `steps % UNROLL` vectors +are left to a tail loop — at `K = 768` a lane walks 6 vectors, so with the +default `UNROLL = 4` the tail is real code rather than a formality. Nothing that rounds changes (`fmax` is exact and order-independent, so the partial maxima merge to the same bits), and `UNROLL = 1` is the previous kernel instruction for instruction, so @@ -431,9 +453,10 @@ it for a given deployment. ## Tuned defaults (measured) -The defaults below come from one `-k sweep` run on BMG (bf16 activations, 8 -routed rows for decode, 256 rows/expert for prefill). Every configuration is -checked for numerical equivalence with the first one before it is timed. +The defaults below come from one `-k sweep` run on the Arc Pro B60 above (bf16 +activations, 8 routed rows for decode, 256 rows/expert for prefill). Every +configuration is checked for numerical equivalence with the first one before it +is timed. ### Prefill tile @@ -479,10 +502,11 @@ a skewed routing that averages 256 rows can still leave many experts with ragged 256-row tiles. At the chosen tile the swept shapes reach 75.8 / 56.4 / 82.9 / 82.8 TFLOPS -(qwen3 up / down, minimax up / down) — up from 61.1 / 39.4 / 68.8 / 65.3 before -the C-shadow removal and the vectorized activation quantizer, but qwen3 down is -still short of the 100 TFLOPS target: at `K = 768` a tile runs only 12 k-tiles, -so the epilogue and the prologue are a large share of it. +(qwen3 up / down, minimax up / down) — 39% / 29% / 42% / 42% of the B60's +~197 TOPS of int8 peak, up from 61.1 / 39.4 / 68.8 / 65.3 before the C-shadow +removal and the vectorized activation quantizer, but qwen3 down is still short +of the 100 TFLOPS target (51% of peak): at `K = 768` a tile runs only 12 +k-tiles, so the epilogue and the prologue are a large share of it. ### Prefill activation quantization @@ -533,6 +557,11 @@ on the other two, while `1` loses 47% on qwen3 up and `4` loses 14% on minimax up, so it stays the default as well. At those defaults the K-split mapping is worth 1.09–1.93× over the legacy GEMV. +Those readings are 59–69% of the B60's 456 GB/s of pin bandwidth (68–79% of what +the device-copy probe actually reaches), so only minimax down clears the +300 GB/s target. A decode step reads one weight byte per multiply-add and +nothing else, so the remaining gap is message efficiency, not arithmetic. + ## Environment variables | Variable | Effect | @@ -567,9 +596,10 @@ failing. The W4A8 kernel is a new SYCL/CuTe port, marked `STATUS: PARTIALLY HARDWARE-VALIDATED` in `auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. The perf sweeps have -been run on BMG — that run is where the tile ladder, the activation-quantization -default, the interior-tile epilogue and the decode `CH` / `NCOLS` defaults come -from (see [Tuned defaults](#tuned-defaults-measured)) — and every swept +been run on an Intel Arc Pro B60 — that run is where the tile ladder, the +activation-quantization default, the interior-tile epilogue and the decode +`CH` / `NCOLS` defaults come from (see +[Tuned defaults](#tuned-defaults-measured)) — and every swept configuration passed the cross-configuration equivalence check. `test_act_quant_vec_matches_scalar`, `test_full_tile_epilogue_matches_predicated` and `test_decode_ksplit_matches_legacy` pass on device, so the vectorized diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 11f7766d1a..ba41674869 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -40,6 +40,22 @@ w8[e][n][k] = round( w4[e][n][k] * s[e][n][k / group_size] / sxt[e][n][j] ) ## 性能目标与 roofline +### 本文所有实测数据所用的设备 + +一块 **Intel Arc Pro B60** (Battlemage,`BMG-G21` — 也就是默认的 AOT 目标 +`intel_gpu_bmg_g21`):20 个 Xe2 core / 160 个 XVE,频率约 2.4 GHz,24 GB GDDR6, +192-bit 位宽。它给出的各条上限: + +| 上限 | 数值 | +|---|---| +| int8 XMX (DPAS) | 160 XVE × 512 int8 ops/clk × 2.4 GHz ≈ **197 TOPS** | +| bf16 / fp16 XMX | ≈ **98 TFLOPS**,是 int8 速率的一半 — 这正是 W4A8 存在的理由 | +| DRAM 引脚带宽 | **456 GB/s**;测试脚本的 device-copy 探测读到约 400 GB/s (引脚带宽的 88%) | +| 占用率上限 | 160 XVE × 8 个线程槽 = **1280 个并发 SIMD16 sub-group** | + +因此下面两个目标分别是 int8 峰值的 51% 和引脚带宽的 66%。`Arc Pro B60 Dual` 卡会 +暴露两个这样的设备,kernel 只看到其中一个。 + 本 kernel 的目标是 **prefill > 100 TFLOPS**、**decode 权重带宽 > 300 GB/s**。 prefill 目标是否*可能*达到,取决于**路由**而不是 kernel 本身:W4A8 grouped GEMM 对每个活跃专家的 int8 权重只读一次,而每读一个权重字节要做 `2 × rows_per_expert` @@ -64,9 +80,9 @@ rows_per_expert = batch × top_k / active_experts 因此默认 batch 下约 4.5 TFLOPS **并不是 kernel 的缺陷**:在每专家 8 行、kernel 实 测约 285 GB/s 权重带宽的条件下,上限就是 `2 × 8 × 285e9 = 4.56 TFLOPS` — 正好等于 实测值,说明 kernel 已经跑在 DRAM roofline 上。要在该形状上达到 100 TFLOPS 需要 -6.25 TB/s,比当前任何 GPU 都高 10 倍以上。在带宽约 285 GB/s 的设备上,该目标最早 -在每专家约 176 行 (约 2816 个模型 token) 时才变得可达,这正是 -`test_perf_prefill_compute_bound` 使用 4096 个模型 token 的原因。 +6.25 TB/s,是 B60 那 456 GB/s 的 13 倍,也比当前任何 GPU 高 10 倍以上。在带宽约 +285 GB/s 的设备上,该目标最早在每专家约 176 行 (约 2816 个模型 token) 时才变得可达, +这正是 `test_perf_prefill_compute_bound` 使用 4096 个模型 token 的原因。 因此性能表在实测值旁边额外打印 `rows/E` 和 `BW@100T`,并在每次扫描后输出结论: @@ -91,8 +107,10 @@ targets [prefill]: prefill compute > 100 TFLOPS 交叉点 rows/expert ~= int8 峰值 TOPS / (4 × 权重带宽) ``` -按约 233 TOPS 的 int8 DPAS 和约 285 GB/s 计算,交叉点约为每专家 200 行 (约 3200 个 -模型 token)。decode (每专家 1 行) 和小 batch prefill 都远低于该点,所以 +按 B60 的约 197 TOPS int8 DPAS 和 kernel 实测约 285 GB/s 计算,交叉点约为每专家 +173 行 (约 2760 个模型 token) — 这与带宽 roofline 首次允许 100 TFLOPS 的路由 (上面 +的约 176 行) 基本重合,也就是说在这块卡上两个交叉点落在同一处。decode (每专家 1 行) +和小 batch prefill 都远低于该点,所以 0.55–0.71× 是预期结果:W4A8 是面向大 batch prefill 的优化,在 decode 阶段只能通过 改善**访存**路径来获益。 @@ -154,14 +172,17 @@ absmax,再量化),写一遍 `[T, K]` 的 int8。在 32768 条路由行、`K (`steps = K / (SG_SIZE × VEC)`),且每个向量都折进同一个 `local_max`,因此循环读起来 就是:发一条 load,停下来等它返回,做一次 `fmax`,再来一遍。Xe core 是顺序执行的,而 `fmax` 在没有 fast-math 时不会被重结合,所以一个线程大约只保持**一条** 256 字节的 -load 在途。这是 Little 定律的问题,而不是带宽的问题:640 个并发 sub-group × 256 字节 -只有约 160 KB 的在途读取,而一块约 400 GB/s 的设备要在约 1 µs 的访存延迟下保持忙碌需 -要约 400 KB。这与 decode GEMV 每次迭代加载两个 chunk 是同一个论证。 +load 在途。这是 Little 定律的问题,而不是带宽的问题:1280 个并发 sub-group (B60 的 +占用率上限 — 160 个 XVE × 8 个线程槽) × 256 字节只有约 320 KB 的在途读取,而一块 +456 GB/s 的设备要在约 1 µs 的访存延迟下保持忙碌需要约 456 KB,何况实际 launch 很少 +能填满每一个线程槽。这与 decode GEMV 每次迭代加载两个 chunk 是同一个论证。 现在每次迭代先加载 `UNROLL` 个**互相独立**的向量,然后才开始消费它们,并把它们归约到 `UNROLL` 个各自独立的局部最大值上,使这些 load 也不必串行等待累加器链;量化那一遍同样 -按此批量化其 load。`steps % UNROLL` 个向量交给尾循环处理——`K = 768` 时一个 lane 要走 -6 个向量,因此在默认 `UNROLL = 4` 下尾循环是真实会执行的代码,而不是形式上的补充。任 +按此批量化其 load。在默认 `UNROLL = 4` 下每个线程持有 1 KB,因此远在填满线程槽之前 +就已经越过了那 456 KB 的门槛。`steps % UNROLL` 个向量交给尾循环处理——`K = 768` 时一 +个 lane 要走 6 个向量,因此在默认 `UNROLL = 4` 下尾循环是真实会执行的代码,而不是形 +式上的补充。任 何会产生舍入的步骤都没有改变 (`fmax` 精确且与顺序无关,因此各局部最大值合并后逐位相 同),且 `UNROLL = 1` 与批量化之前的 kernel 逐条指令一致,所以 `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` 是精确的 A/B 基线。 @@ -395,8 +416,8 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 ## 实测得到的默认值 -下面的默认值来自 BMG 上的一次 `-k sweep` (bf16 激活,decode 为 8 条 routed -行,prefill 为每专家 256 行)。每种配置在计时之前都会先与第一种配置做数值等价性 +下面的默认值来自上文那块 Arc Pro B60 上的一次 `-k sweep` (bf16 激活,decode 为 8 条 +routed 行,prefill 为每专家 256 行)。每种配置在计时之前都会先与第一种配置做数值等价性 检查。 ### Prefill tile @@ -437,9 +458,11 @@ fragment 仍保持 32×32,同时把专家 B 面板的重复读取次数减半 多专家只有残缺的 256 行 tile。 在选中的 tile 下,被扫描的四个形状达到 75.8 / 56.4 / 82.9 / 82.8 TFLOPS -(qwen3 up / down、minimax up / down)——相比去掉 float 影子和引入向量化激活量化之前的 -61.1 / 39.4 / 68.8 / 65.3 有明显提升,但 qwen3 down 距离 100 TFLOPS 的目标仍有差距: -`K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此占了相当大的比例。 +(qwen3 up / down、minimax up / down)——分别是 B60 那约 197 TOPS int8 峰值的 +39% / 29% / 42% / 42%,相比去掉 float 影子和引入向量化激活量化之前的 +61.1 / 39.4 / 68.8 / 65.3 有明显提升,但 qwen3 down 距离 100 TFLOPS (峰值的 51%) +的目标仍有差距:`K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此 +占了相当大的比例。 ### Prefill 激活量化 @@ -486,6 +509,10 @@ fragment 仍保持 32×32,同时把专家 B 面板的重复读取次数减半 minimax up 上慢 14%,因此 `2` 同样保持为默认值。在这组默认值下,K-split 映射相对 legacy GEMV 的收益为 1.09–1.93×。 +这些读数相当于 B60 那 456 GB/s 引脚带宽的 59–69% (若以 device-copy 探测实际达到的 +带宽为基准则是 68–79%),因此只有 minimax down 越过了 300 GB/s 的目标。decode 每做 +一次乘加就要读一个权重字节、别无其他,所以剩下的差距在访存消息效率,而不在算力。 + ## 环境变量 | 变量 | 作用 | @@ -518,8 +545,9 @@ decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 `auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp` 中被标记为 -`STATUS: PARTIALLY HARDWARE-VALIDATED`。各项性能扫描都已在 BMG 上跑过——tile 阶梯、 -激活量化的默认值、内部 tile 的 epilogue 以及 decode 的 `CH` / `NCOLS` 默认值正是来自 +`STATUS: PARTIALLY HARDWARE-VALIDATED`。各项性能扫描都已在一块 Intel Arc Pro B60 上 +跑过——tile 阶梯、激活量化的默认值、内部 tile 的 epilogue 以及 decode 的 +`CH` / `NCOLS` 默认值正是来自 那些运行 (参见[实测得到的默认值](#实测得到的默认值)),并且所有被扫描的配置都通过了配 置间的数值等价性检查。`test_act_quant_vec_matches_scalar`、 `test_full_tile_epilogue_matches_predicated` 与 `test_decode_ksplit_matches_legacy` diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 125fe60138..faa711fed7 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1252,9 +1252,10 @@ def test_perf_prefill_act_quant_unroll_sweep(self, request): request moves; it did not change how many requests a work-item has outstanding. The pass walks K with a runtime trip count and folds every vector into one ``local_max``, so an in-order thread keeps - about one 256-byte load in flight -- roughly 160 KB across the - device, well under what a ~400 GB/s part needs to stay busy over a - memory latency. ``UNROLL`` loads that many independent vectors + about one 256-byte load in flight -- ~320 KB even at a B60's + 1280-thread occupancy ceiling, under what a 456 GB/s part needs to + stay busy over a memory latency. ``UNROLL`` loads that many + independent vectors before consuming any of them. The first row is the kernel as it was before the batching, so this is an exact A/B measurement; the arithmetic is unchanged, which From d8f3e298c035d163a4e9504b76ecbec0892275d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:28:50 +0000 Subject: [PATCH 068/112] docs(ark): note the 24 GB B60 ceiling on the W4A8 prepack cache (EN + CN) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- auto_round_extension/ark/test/README_MOE_W4A8.md | 7 +++++++ auto_round_extension/ark/test/README_MOE_W4A8_CN.md | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index e62cadb20f..89d37fe278 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -451,6 +451,13 @@ otherwise collide with another layer's weights). Use `cache_prepack=False` on `ark.moe_w4a8` (or `clear_moe_w4a8_prepack_cache()`) if that trade isn't worth it for a given deployment. +On the 24 GB B60 that trade has a hard limit: the two GEMMs above are ~0.6 GB of +int8 per MoE layer on top of the ~0.3 GB of int4 they pin, so a 48-layer +Qwen3-MoE stack would ask for ~29 GB of prepacked weights and does not fit. +Caching the whole model is a multi-card or larger-VRAM configuration; on one +B60, cache the layers that are prefill-bound and leave the rest on +`cache_prepack=False`. + ## Tuned defaults (measured) The defaults below come from one `-k sweep` run on the Arc Pro B60 above (bf16 diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index ba41674869..49bd19b568 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -414,6 +414,11 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 在 `ark.moe_w4a8` 上传 `cache_prepack=False` (或调用 `clear_moe_w4a8_prepack_cache()`)。 +在 24 GB 的 B60 上这个权衡有一条硬上限:上表两个 GEMM 每个 MoE 层约需 0.6 GB int8, +外加它所持有的约 0.3 GB int4,因此 48 层的 Qwen3-MoE 需要约 29 GB 预处理权重,放不 +下。要缓存整个模型就得用多卡或更大显存的配置;在单块 B60 上,只缓存受 prefill 支配 +的那些层,其余传 `cache_prepack=False`。 + ## 实测得到的默认值 下面的默认值来自上文那块 Arc Pro B60 上的一次 `-k sweep` (bf16 激活,decode 为 8 条 From b56f301b867ecf803daf4bac9f1211e82628d127 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:03:35 +0000 Subject: [PATCH 069/112] perf(ark): 2D block store epilogue + single-pass activation quantizer for W4A8 MoE prefill Replaces the W4A8 prefill epilogue's 64 scalar 32-byte stores per sub-group fragment with the hardware 2D block store (`make_block_2d_copy_D` + `copy(copy_d, tCrD, tCgC)`, the sequence already compiled in `sycl_tla_dense_gemm.hpp` for the same accumulator/output widths), and removes the activation quantizer's second read of every row by holding it in registers between the absmax and the quantize pass. Also corrects the harness roofline, which counted only weight bytes and so understated the bandwidth a shape needs to hit 100 TFLOPS by up to 2.2x. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 295 ++++++++++++++++- .../ark/test/test_moe_w4a8_perf.py | 301 ++++++++++++++++-- 2 files changed, 551 insertions(+), 45 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 03d7854fe8..d29535050f 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -17,7 +17,17 @@ // `sycl_tla_moe_prefill_fp8_dpas.hpp` -- currently the activation quantizer's // batched loads (`ARK_MOE_W4A8_ACT_QUANT_UNROLL`), which are pure C++ around // the same arithmetic and are swept by -// `test_perf_prefill_act_quant_unroll_sweep`. +// `test_perf_prefill_act_quant_unroll_sweep`; the register-resident +// single-pass activation quantizer +// (`ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS`, swept by +// `test_perf_prefill_act_quant_single_pass_sweep`), likewise; and the 2D block +// store epilogue (`ARK_MOE_W4A8_PREFILL_STORE_2D`, swept by +// `test_perf_prefill_store_sweep`), which is the D-side port of the +// `make_block_2d_copy_D` + `copy(copy_d, tCrD, tCgC)` sequence already +// compiled in `sycl_tla_dense_gemm.hpp` for the same accumulator and output +// widths. All three keep their predecessor one environment variable away and +// are covered by bit-identity tests, because none of them has been run on a +// device yet. // --------------------------------------------------------------------------- // // What this file implements @@ -141,6 +151,9 @@ class MoEW4A8ActQuant; template class MoEW4A8ActQuantVec; +template +class MoEW4A8ActQuantSingle; + template class MoEW4A8ScaleReduce; @@ -269,6 +282,42 @@ inline DeviceScratchPool& expert_map_pool() { // so `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` is an exact A/B baseline; // `test_act_quant_unroll_matches` asserts every depth is bit-identical and // `test_perf_prefill_act_quant_unroll_sweep` times them. +// +// Reading the row once (the traffic the two passes duplicate) +// ----------------------------------------------------------- +// Batching the loads did not change how many there are. The absmax has to see +// the whole row before the first element can be quantized, so the kernel reads +// `[T, K]`, reduces, then reads `[T, K]` again -- and at 256 rows per expert +// the activation matrix is 1.0 MB for K = 2048, against 3.1 MB of weights for +// the whole GEMM. The re-read is L2-resident when the row is still there, but +// the rows a work-group quantizes second are evicted by the ones it quantized +// first well before the pass ends: at 8 MB of L2 and 4 KB per bf16 row of +// K = 2048, only ~2000 of 2048 tokens' rows fit *if nothing else is resident*, +// and the GEMM's weights are competing for the same cache immediately after. +// +// A row is small enough to keep in registers instead: a lane owns `K / 16` +// elements, so `K = 2048` is 256 bytes -- 64 of the 128 dwords per lane the +// quantizer gets (it launches without `grf_size<256>`, unlike the GEMM). Load +// the row once, reduce it, then quantize out of the registers. The second read +// disappears, and every load is issued before any of them is consumed, which +// subsumes what `UNROLL` was doing (`UNROLL = steps`, effectively) rather than +// competing with it. +// +// `MAX_STEPS` is the compile-time cap that makes the fragment a register array +// rather than scratch: the loop is `#pragma unroll` over `MAX_STEPS` with an +// `if (s < steps)` guard, so every index is a constant and SROA can promote it. +// Two rungs are instantiated -- 8 vectors (K <= 1024 at VEC = 8, 32 dwords) and +// 16 (K <= 2048, 64 dwords) -- and anything longer keeps the two-pass kernel, +// which is why minimax's K = 3072 up-projection still takes the old path. The +// partial maxima stay at four accumulators, as in the two-pass kernel, so the +// reduction chain is unchanged in both cost and value. +// +// This is a register-pressure gamble that the authoring environment cannot +// settle: if 64 dwords of row plus addressing spills, the pass gets slower, not +// faster. Hence `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` restores the two-pass +// kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times the +// pair, and `test_act_quant_single_pass_matches` asserts they agree bit for bit +// (same `fmax` set, same `inv`, same `rint`/`clamp`). // --------------------------------------------------------------------------- // Fold the per-token expert scan (decode only) into the quantization kernel. @@ -401,6 +450,96 @@ void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, in // kernel), 2 or 4; anything else falls back to the default. constexpr int kActQuantUnrollDefault = 4; +// Partial maxima the single-pass kernel reduces into, matching the two-pass +// kernel's default `UNROLL` so the two produce the same value bit for bit. +constexpr int kActQuantSinglePartials = 4; + +// Longest row a lane keeps in registers, in vectors. 16 vectors is 64 dwords +// per lane at VEC = 8 (K = 2048), half the default 128-dword budget; longer +// rows take the two-pass kernel rather than risk a spill. +constexpr int kActQuantSingleMaxSteps = 16; + +// Single-pass variant: the row is loaded once into registers, reduced, then +// quantized out of them. `MAX_STEPS` bounds the register array at compile time +// (see the design note above); `steps <= MAX_STEPS` is the caller's contract. +template +void launch_act_dynamic_quant_vec_single(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); + static_assert(MAX_STEPS >= kActQuantSinglePartials, "MAX_STEPS must cover the partial accumulators"); + using ActVec = sycl::vec; + using QVec = sycl::vec; + + const int steps = K / (SG_SIZE * VEC); + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); + QVec* out = reinterpret_cast(qact + static_cast(token) * K); + + // The whole row, one load per slot, every one issued before the first + // is consumed. Constant indices under the unroll keep it in registers. + ActVec v[MAX_STEPS]; +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { + v[s] = row[static_cast(s) * SG_SIZE + lane]; + } + } + + float part_max[kActQuantSinglePartials]; +#pragma unroll + for (int u = 0; u < kActQuantSinglePartials; ++u) part_max[u] = 0.0f; + +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); + part_max[s % kActQuantSinglePartials] = + sycl::fmax(part_max[s % kActQuantSinglePartials], sycl::fabs(static_cast(a))); + } + } + } + + float local_max = part_max[0]; +#pragma unroll + for (int u = 1; u < kActQuantSinglePartials; ++u) local_max = sycl::fmax(local_max, part_max[u]); + + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + // No second read of the row: it is already here. +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s) * SG_SIZE + lane] = qv; + } + } + }); +} + inline int moe_w4a8_act_quant_unroll() { const char* env = std::getenv("ARK_MOE_W4A8_ACT_QUANT_UNROLL"); if (env != nullptr) { @@ -416,6 +555,22 @@ template void launch_act_dynamic_quant_vec_unroll(int unroll, sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, int total_tokens, int K, int* expert_id_per_token, const int* num_tokens_per_expert, int num_experts) { + // Register-resident single pass when the row fits, the two-pass kernel + // otherwise. The smallest rung that covers `steps` is chosen so a short row + // does not reserve registers for slots it never loads. + const int steps = K / (SG_SIZE * VEC); + if (steps <= kActQuantSingleMaxSteps && + moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS", true)) { + if (steps <= kActQuantSingleMaxSteps / 2) { + launch_act_dynamic_quant_vec_single( + q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); + } else { + launch_act_dynamic_quant_vec_single( + q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); + } + return; + } + if (unroll == 1) { launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); @@ -726,11 +881,59 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // (`test_full_tile_epilogue_matches_predicated`), and // `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` forces the guarded path for A/B // measurement. +// +// The store itself: one 2D block message instead of `size(tCrC)` scalar ones +// ----------------------------------------------------------------------- +// Removing instructions from around the store left the store. The Xe DPAS C +// fragment gives a lane one *column* of each 8x16 atom, so the 16 lanes of a +// sub-group hold 16 *consecutive columns of one row*: a scalar +// `c[row * n + col] = ...` is a 32-byte message for 16-bit `ElementD`, half a +// cache line, and a 32x32 sub-group fragment issues **64** of them. The same +// bytes go out in 4 messages through the hardware 2D block store, which is +// what every sibling prefill kernel already uses for D +// (`sycl_tla_moe_prefill_{fp8,int,s4}_dpas.hpp`) and what the dense GEMM in +// `sycl_tla_dense_gemm.hpp` uses on this exact accumulator shape. +// +// D is the reason this is worth doing at prefill sizes rather than a tidy-up: +// at 256 rows per expert the qwen3 down-projection writes `M*N` fp16 (0.79 MB +// per expert) against `N*K` int8 of weights (1.5 MB) -- a third of the tile +// traffic -- because N (2048) is larger than K (768) there. It is the same +// shape whose mainloop is shortest, so it pays the epilogue twice. +// +// The port follows `dense_gemm_detail::gemm_device_impl` rather than the +// sibling MoE kernels, because those `reorder(tCrC, tCrC_out)` from the MMA +// fragment into an explicitly chosen `XE_STORE_2D` atom's fragment, and +// `reorder` moves *registers*: with a `float` accumulator that is free, but +// this kernel's accumulator is `int32` (`FrgTypeC` of +// `XE_DPAS_TT<8, int32_t, int8_t, int8_t>`) and has to be scaled and +// numerically converted first, which `reorder` does not do. `dense_gemm`'s +// shape is the one that fits: `make_block_2d_copy_D(mma, D)` derives its +// layout from the MMA's own C partition, so the scaled `ElementD` fragment +// (`make_tensor_like(tCrC)`, filled through the same `tCgC(i)` +// coordinates the scalar path uses) can be handed straight to +// `copy(copy_d, tCrD, tCgC)` with no `reorder` in between. +// +// It also *removes* the store predicate rather than skipping it: the 2D block +// message clips to the surface (`m` rows x `n` columns) described by the D +// tensor, so a partial tile at the M edge drops its out-of-range rows in +// hardware -- exactly how the sibling grouped GEMMs handle their ragged +// experts. Only the scale *loads* still need their index clamps, and only on +// edge tiles. The value written is computed by the same expression in the same +// order as the scalar path, so the two are bit-identical +// (`test_prefill_2d_store_matches_scalar`); `ARK_MOE_W4A8_PREFILL_STORE_2D=0` +// restores the scalar store for A/B measurement. +// +// The block 2D descriptor wants a 64-byte aligned base and a row pitch that is +// a multiple of 16 bytes. The base here is the expert's slice +// `Outputs + pre_rows * N`, with `pre_rows` a runtime routing value, so the +// dispatcher gates on `N * sizeof(ElementD) % 64 == 0` (which makes *every* +// expert's base 64-byte aligned given an aligned tensor) and on the base +// pointer itself; anything else keeps the scalar store. // --------------------------------------------------------------------------- template CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, const float* scale_a, const float* scale_b, int m, int n, int k, int blocksize, int blks, int m_coord, - int n_coord, bool allow_full_tile, TiledMMA const& mma) { + int n_coord, bool allow_full_tile, bool allow_block_2d_store, TiledMMA const& mma) { auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); const int local_id = static_cast(item.get_local_linear_id()); @@ -739,10 +942,11 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con auto A = make_tensor(make_gmem_ptr(const_cast(a)), make_shape(m, k), make_stride(k, _1{})); auto B = make_tensor(make_gmem_ptr(const_cast(b)), make_shape(n, k), make_stride(k, _1{})); + auto D = make_tensor(make_gmem_ptr(c), make_shape(m, n), make_stride(n, _1{})); Tensor cA = make_identity_tensor(A.shape()); Tensor cB = make_identity_tensor(B.shape()); - Tensor cC = make_identity_tensor(make_shape(m, n)); + Tensor cC = make_identity_tensor(D.shape()); Tensor gA = local_tile(cA, select<0, 2>(wg_tile), make_coord(m_coord, _)); Tensor gB = local_tile(cB, select<1, 2>(wg_tile), make_coord(n_coord, _)); @@ -750,6 +954,7 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con auto copy_a = get_block_2d_copy_A(mma, A); auto copy_b = get_block_2d_copy_B(mma, B); + auto copy_d = make_block_2d_copy_D(mma, D); auto thr_mma = mma.get_slice(local_id); auto thr_copy_a = copy_a.get_slice(local_id); @@ -850,7 +1055,36 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con } }; - if (full_tile) { + // Same values in the same order, through the hardware 2D block store. The + // element predicate is gone because the message clips to the `m x n` + // surface; only the scale loads still clamp their indices. + auto store_scaled_2d = [&](auto full) { + Tensor tCrD = make_tensor_like(tCrC); + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + if constexpr (decltype(full)::value) { + tCrD(i) = static_cast(static_cast(tCrC(i)) * scale_b[static_cast(col)] * + scale_a[row]); + } else { + const int row_in = row < m ? row : m - 1; + const int col_in = col < n ? col : n - 1; + tCrD(i) = static_cast(static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * + scale_a[row_in]); + } + } + copy(copy_d, tCrD, tCgC); + }; + + if (allow_block_2d_store) { + if (full_tile) { + store_scaled_2d(std::true_type{}); + } else { + store_scaled_2d(std::false_type{}); + } + } else if (full_tile) { store_scaled(std::true_type{}); } else { store_scaled(std::false_type{}); @@ -888,6 +1122,26 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con } } + if (allow_block_2d_store) { + Tensor tCrD = make_tensor_like(tFrC); + if (full_tile) { + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + const int row = static_cast(get<0>(tCgC(i))); + tCrD(i) = static_cast(tFrC(i) * scale_a[row]); + } + } else { + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + const int row = static_cast(get<0>(tCgC(i))); + const int row_in = row < m ? row : m - 1; + tCrD(i) = static_cast(tFrC(i) * scale_a[row_in]); + } + } + copy(copy_d, tCrD, tCgC); + return; + } + if (full_tile) { CUTE_UNROLL for (int i = 0; i < size(tFrC); ++i) { @@ -928,7 +1182,7 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const float* ScaleB, ElementD* Outputs, TiledMMA const& mma, const int* rows_per_expert, const int32_t num_experts, const int32_t gemm_n, const int32_t gemm_k, const int32_t blocksize, const int32_t blks, - const bool allow_full_tile, int32_t* atomic_buffer, + const bool allow_full_tile, const bool allow_block_2d_store, int32_t* atomic_buffer, const sycl::local_accessor& slm_mem_const) { auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); auto wg_tile = mma.tile_mnk(); @@ -982,7 +1236,8 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, xe_gemm_w4a8(ptr_A_curr_batch, ptr_B_curr_batch, ptr_D_curr_batch, ptr_SA_curr_batch, ptr_SB_curr_batch, gemm_m, gemm_n, gemm_k, - blocksize, blks, m_coord, n_coord, allow_full_tile, mma); + blocksize, blks, m_coord, n_coord, allow_full_tile, + allow_block_2d_store, mma); if (local_id == 0) { slm_mem[0] = cutlass::atomicAdd(atomic_buffer, 1); @@ -1004,7 +1259,8 @@ template void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const int8_t* weights, const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, - const int blks, const bool allow_full_tile, int32_t* atomic_buffer) { + const int blks, const bool allow_full_tile, const bool allow_block_2d_store, + int32_t* atomic_buffer) { using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; using WGTile = typename Policy::WGTile; using SGLayout = typename Policy::SGLayout; @@ -1036,7 +1292,8 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, - blks, allow_full_tile, atomic_buffer, local_mem); + blks, allow_full_tile, allow_block_2d_store, atomic_buffer, + local_mem); }); }); @@ -1075,6 +1332,16 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` makes every tile take the guarded // epilogue (see `xe_gemm_w4a8`), which is the A/B baseline for the interior- // tile fast path; it is read here, once per call, rather than on the device. +// +// `ARK_MOE_W4A8_PREFILL_STORE_2D=0` puts the epilogue back on the scalar +// predicated store instead of the hardware 2D block store, the A/B baseline +// for that change. The block message needs a 64-byte aligned surface base and +// a 16-byte multiple row pitch; D's per-expert base is `outputs + pre_rows * N` +// for a routing-dependent `pre_rows`, so the gate is on the row stride itself +// (`N * sizeof(ElementD) % 64 == 0`, which covers the pitch as well) plus the +// tensor base. Every shipped N (1536 / 2048 / 3072 with 16-bit D) clears it; +// anything that does not keeps the scalar store rather than risking a +// misaligned descriptor. // --------------------------------------------------------------------------- template void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, @@ -1086,12 +1353,16 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* const int A_avg_M = total_tokens / E; const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); + const bool store_2d_aligned = (static_cast(N) * sizeof(ElementD)) % 64 == 0 && + reinterpret_cast(outputs) % 64 == 0; + const bool allow_block_2d_store = + store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); int32_t* atomic_buffer = moe_dpas_fp8::get_persistent_atomic_buffer(q); -#define ARK_MOE_W4A8_LAUNCH(policy) \ - MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ - num_tokens_per_expert, E, blocksize, blks, allow_full_tile, \ - atomic_buffer); +#define ARK_MOE_W4A8_LAUNCH(policy) \ + MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ + num_tokens_per_expert, E, blocksize, blks, allow_full_tile, \ + allow_block_2d_store, atomic_buffer); const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); if (tile_env != nullptr) { diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index faa711fed7..90c163d687 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -504,23 +504,43 @@ def _weight_bytes(E, N, K, bits) -> float: return float(E) * N * K * bits / 8.0 +def _dtype_bytes(dtype) -> int: + return torch.empty((), dtype=dtype).element_size() + + # --------------------------------------------------------------------------- # Targets and the roofline they have to be read against # -# A W4A8 MoE grouped GEMM reads every *active* expert's int8 weights exactly -# once (1 byte per element) and does ``2 * rows_per_expert`` FLOPs per weight -# byte, so its arithmetic intensity is fixed by the routing alone: +# The weights are the largest single stream, but they are not the only one. A +# W4A8 MoE call moves, per invocation: +# +# * ``T * K * sizeof(act)`` -- the routed activations, read by the quantizer +# * ``T * K`` -- the int8 quantized copy, written +# * ``T * K`` -- and read back by the GEMM +# * ``E_active * N * K`` -- every active expert's int8 weights, once +# * ``T * N * sizeof(out)`` -- the output # -# TFLOPS <= 2 * rows_per_expert * weight_bandwidth +# Only the fourth line is what ``W GB/s`` reports, and it is a *minority* of +# the total whenever K is small: at 256 rows per expert the qwen3 +# down-projection (N = 2048, K = 768) moves 201 MB of weights inside 436 MB of +# traffic, so a roofline built on weights alone understates the bandwidth a +# shape needs by more than 2x. This matters for the verdict, not just the +# bookkeeping: that shape needs ~423 GB/s to reach 100 TFLOPS, which is past +# what a 456 GB/s part delivers in practice, so it is bandwidth bound and no +# kernel change reaches the target there. # -# Equivalently, the DRAM bandwidth a shape would need to reach the prefill -# target is ``_bw_needed_for_tflops``: ``50 TB/s / rows_per_expert`` for the -# 100 TFLOPS goal. At 8 rows per expert (batch 128 x top_k 8 over 128 experts) -# that is 6.25 TB/s -- more than an order of magnitude past any current GPU -- -# so the 100 TFLOPS target only becomes physically reachable from roughly 176 -# rows per expert upward on a device that streams ~285 GB/s. This is why the -# perf table prints ``rows/E`` and ``BW@target`` next to the measured numbers, -# and why the prefill target sweep uses a compute-bound batch. +# The old model -- ``TFLOPS <= 2 * rows_per_expert * weight_bandwidth``, i.e. +# ``BW@100T = 50 TB/s / rows_per_expert`` -- is the weights-only special case +# and is what the earlier tuning rounds were judged against. It is kept in mind +# here only as the reason those rounds read as "close to target": the target +# was measured against half the traffic. +# +# The intensity still rises with ``rows/E`` (the weight term is the only one +# that does not grow with T), which is why the perf table prints ``rows/E`` and +# why the prefill target sweep uses a compute-bound batch -- but the ceiling +# now saturates instead of growing without bound, because the activation, +# quantized-activation and output streams all scale with T exactly as the +# FLOPs do. # --------------------------------------------------------------------------- _TARGET_PREFILL_TFLOPS = 100.0 @@ -531,21 +551,38 @@ def _rows_per_expert(total_tokens, active_experts) -> float: return float(total_tokens) / float(active_experts) if active_experts else 0.0 -def _bw_needed_for_tflops(total_tokens, active_experts, tflops_target=_TARGET_PREFILL_TFLOPS) -> float: - """GB/s of weight traffic a shape needs to hit ``tflops_target``. +def _traffic_bytes(total_tokens, active_experts, N, K, act_bytes=2, out_bytes=2) -> float: + """Compulsory DRAM traffic of one ``moe_gemm_w4a8`` call, in bytes. - ``bytes / (flops / target) = target * active_experts / (2 * rows)`` -- the - N/K factors cancel, so this depends only on the routing. + Counts each byte once: re-reads of A across the N tiles are L2 hits at any + launch this kernel produces (~20 concurrent work-groups against 8 MB of + L2), so they are not DRAM traffic. This is a lower bound, which keeps the + derived ceiling optimistic and therefore never excuses a slow kernel. """ - rows = _rows_per_expert(total_tokens, active_experts) - if rows <= 0.0: + act_read = float(total_tokens) * K * act_bytes + qact_write_read = 2.0 * float(total_tokens) * K + weights = float(active_experts) * N * K + out_write = float(total_tokens) * N * out_bytes + return act_read + qact_write_read + weights + out_write + + +def _bw_needed_for_tflops( + total_tokens, active_experts, N, K, act_bytes=2, out_bytes=2, tflops_target=_TARGET_PREFILL_TFLOPS +) -> float: + """GB/s of DRAM traffic a shape needs to hit ``tflops_target``.""" + flops = _flops(total_tokens, N, K) + if flops <= 0.0: return float("inf") - return tflops_target * 1e12 / (2.0 * rows) / 1e9 + seconds_at_target = flops / (tflops_target * 1e12) + return _traffic_bytes(total_tokens, active_experts, N, K, act_bytes, out_bytes) / seconds_at_target / 1e9 -def _tflops_ceiling(total_tokens, active_experts, gbps) -> float: - """Best TFLOPS this shape can reach at ``gbps`` of weight bandwidth.""" - return 2.0 * _rows_per_expert(total_tokens, active_experts) * gbps * 1e9 / 1e12 +def _tflops_ceiling(total_tokens, active_experts, N, K, gbps, act_bytes=2, out_bytes=2) -> float: + """Best TFLOPS this shape can reach at ``gbps`` of DRAM bandwidth.""" + traffic = _traffic_bytes(total_tokens, active_experts, N, K, act_bytes, out_bytes) + if traffic <= 0.0: + return float("inf") + return _flops(total_tokens, N, K) / (traffic / (gbps * 1e9)) / 1e12 _DEVICE_BW_GBPS = None @@ -591,7 +628,7 @@ def _device_bandwidth_gbps(): # --------------------------------------------------------------------------- _ACC_WIDTH = 150 -_PERF_WIDTH = 168 +_PERF_WIDTH = 179 def _print_acc_header(title: str) -> None: @@ -639,10 +676,14 @@ def _print_perf_header(title: str) -> None: * ``w4a8(ms)`` / ``TFLOPS`` / ``W GB/s``: the new int8-compute path. ``W GB/s`` counts only the expert weight traffic actually touched by the routed tokens, which is what a memory-bound decode is limited by. - * ``rows/E``: routed tokens per active expert -- the arithmetic intensity - of the grouped GEMM is ``2 * rows/E`` FLOPs per weight byte, so this - single number decides whether a shape can be compute bound at all. - * ``BW@100T``: weight bandwidth the shape would need to reach 100 TFLOPS. + * ``DRAM GB/s``: *all* the traffic the call moves -- the activations the + quantizer reads, the int8 copy it writes, the GEMM's read of that copy, + the weights and the output. On the small-K shapes the weights are under + half of this, so it is the number to compare against the device probe. + * ``rows/E``: routed tokens per active expert. The weight stream is the + only one that does not grow with the token count, so this is what raises + the arithmetic intensity -- but the other four streams keep it bounded. + * ``BW@100T``: DRAM bandwidth the shape would need to reach 100 TFLOPS. When it exceeds what the device can stream, ``TFLOPS`` is capped by memory and no kernel change can reach the target at that shape. * ``vs torch`` / ``vs w4a16``: speedups (``other / w4a8``). @@ -653,13 +694,27 @@ def _print_perf_header(title: str) -> None: print( f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'rows/E':>8}" f"{'torch(ms)':>12}{'w4a16(ms)':>12}{'w4a8(ms)':>12}" - f"{'TFLOPS':>10}{'W GB/s':>10}{'BW@100T':>10}{'vs torch':>11}{'vs w4a16':>11}{'prepack(ms)':>13}" + f"{'TFLOPS':>10}{'W GB/s':>10}{'DRAM GB/s':>11}{'BW@100T':>10}" + f"{'vs torch':>11}{'vs w4a16':>11}{'prepack(ms)':>13}" ) print("-" * _PERF_WIDTH) def _print_perf_row( - label, E, N, K, tokens, torch_ms, w4a16_ms, w4a8_ms, tflops, gbps, prepack_ms, rows_per_expert=None, bw_at_100t=None + label, + E, + N, + K, + tokens, + torch_ms, + w4a16_ms, + w4a8_ms, + tflops, + gbps, + prepack_ms, + rows_per_expert=None, + bw_at_100t=None, + dram_gbps=None, ): def _fmt(v, digits=3): if v is None: @@ -673,7 +728,7 @@ def _fmt(v, digits=3): print( f"{label:<14}{E:>5}{N:>7}{K:>7}{tokens:>8}{_fmt(rows_per_expert, 1):>8}" f"{_fmt(torch_ms):>12}{_fmt(w4a16_ms):>12}{_fmt(w4a8_ms):>12}" - f"{_fmt(tflops, 2):>10}{_fmt(gbps, 1):>10}{_fmt(bw_at_100t, 0):>10}" + f"{_fmt(tflops, 2):>10}{_fmt(gbps, 1):>10}{_fmt(dram_gbps, 1):>11}{_fmt(bw_at_100t, 0):>10}" f"{(_fmt(vs_torch, 2) + 'x') if vs_torch else '--':>11}" f"{(_fmt(vs_w4a16, 2) + 'x') if vs_w4a16 else '--':>11}" f"{_fmt(prepack_ms, 2):>13}" @@ -688,6 +743,11 @@ def _print_targets(phase: str, rows) -> None: ``N/A`` rather than ``FAIL``: at that routing the target is unreachable by construction (see the roofline note above ``_TARGET_PREFILL_TFLOPS``), and the row's measured bandwidth is what should be judged instead. + + The ceiling counts every stream the call moves, not just the weights, so a + small-K shape can be ``N/A`` here while the weights-only model of earlier + rounds called it reachable. A reachable row also prints how much of the + ceiling it actually reaches, which is the number a kernel change can move. """ if not rows: return @@ -705,10 +765,12 @@ def _print_targets(phase: str, rows) -> None: if is_prefill and ceiling is not None and ceiling < target: verdict = ( f"N/A (bandwidth bound: ceiling {ceiling:.1f} {unit} at {row['rows_per_expert']:.0f} rows/expert; " - f"reaching {target:g} would need {row['bw_at_100t']:.0f} GB/s)" + f"reaching {target:g} would need {row['bw_at_100t']:.0f} GB/s of DRAM traffic)" ) else: verdict = "PASS" if measured > target else "FAIL" + if is_prefill and ceiling: + verdict += f" ({measured / ceiling * 100:.0f}% of the {ceiling:.0f} {unit} bandwidth ceiling)" print( f" {row['label']:<12} tokens={row['tokens']:<6} rows/E={row['rows_per_expert']:<6.1f} " f"{measured:8.2f} {unit} vs {target:g} -> {verdict}" @@ -871,8 +933,14 @@ def run_perf( # W4A8 streams int8 weights: 1 byte per element, only for the # experts that actually received tokens. gbps = _weight_bytes(active_experts, N, K, 8) / (w4a8_ms * 1e-3) / 1e9 + # Everything the call moves, not just the weights: the quantizer's read + # of A, the int8 copy it writes, the GEMM's read of that copy and the + # output. On the small-K shapes the weights are under half of it. + act_bytes = _dtype_bytes(dtype) + traffic = _traffic_bytes(total_tokens, active_experts, N, K, act_bytes, act_bytes) + dram_gbps = traffic / (w4a8_ms * 1e-3) / 1e9 rows_per_expert = _rows_per_expert(total_tokens, active_experts) - bw_at_100t = _bw_needed_for_tflops(total_tokens, active_experts) + bw_at_100t = _bw_needed_for_tflops(total_tokens, active_experts, N, K, act_bytes, act_bytes) row = { "label": nk_label, @@ -886,13 +954,18 @@ def run_perf( "w4a8_ms": w4a8_ms, "tflops": tflops, "gbps": gbps, + "dram_gbps": dram_gbps, "prepack_ms": prepack_ms, "active_experts": active_experts, "rows_per_expert": rows_per_expert, "bw_at_100t": bw_at_100t, # Hard ceiling for this routing on this device (``None`` when # the bandwidth probe is unavailable). - "tflops_ceiling": (None if device_bw is None else _tflops_ceiling(total_tokens, active_experts, device_bw)), + "tflops_ceiling": ( + None + if device_bw is None + else _tflops_ceiling(total_tokens, active_experts, N, K, device_bw, act_bytes, act_bytes) + ), "device_bw_gbps": device_bw, } rows.append(row) @@ -911,6 +984,7 @@ def run_perf( prepack_ms, rows_per_expert=rows_per_expert, bw_at_100t=bw_at_100t, + dram_gbps=dram_gbps, ) # Drop the (large) int8 weights before the next shape allocates. @@ -983,6 +1057,24 @@ def run_perf( for u in (1, 2, 4) ] +# Prefill: activation-quantization passes over the row. The two-pass kernel +# reads ``[T, K]`` twice -- once for the absmax, once to quantize -- because the +# scale is not known until the row has been seen. The single-pass kernel keeps +# the row in registers between the two, which removes the second read at the +# cost of ``K / 16`` elements of register pressure per lane. Whether that trade +# pays depends on whether it spills, which only hardware can say; rows longer +# than the register budget take the two-pass kernel either way. +_ACT_QUANT_SINGLE_PASS_CONFIGS = [ + ( + "act-quant two-pass", + {"ARK_MOE_W4A8_ACT_QUANT_VEC": "1", "ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS": "0"}, + ), + ( + "act-quant single-pass", + {"ARK_MOE_W4A8_ACT_QUANT_VEC": "1", "ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS": "1"}, + ), +] + # Prefill: epilogue guard. A tile that touches neither the M nor the N edge # needs no store predicate and no scale-index clamp, and whether it does is # uniform across the work-group. The guarded path is what every tile used to @@ -993,6 +1085,17 @@ def run_perf( ("epilogue interior", {"ARK_MOE_W4A8_PREFILL_FULL_TILE": "1"}), ] +# Prefill: how D leaves the registers. The DPAS C fragment gives a lane one +# column of each 8x16 atom, so a scalar store is a 32-byte message for 16-bit D +# and a 32x32 sub-group fragment issues 64 of them; the hardware 2D block store +# moves the same bytes in a handful of messages and needs no predicate, because +# it clips to the output surface. D is a third of the tile traffic on the +# down-projection shapes, so this is where the store width should show. +_PREFILL_STORE_CONFIGS = [ + ("store scalar", {"ARK_MOE_W4A8_PREFILL_STORE_2D": "0"}), + ("store block2d", {"ARK_MOE_W4A8_PREFILL_STORE_2D": "1"}), +] + _SWEEP_MIN_SNR_DB = 40.0 @@ -1268,6 +1371,53 @@ def test_perf_prefill_act_quant_unroll_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"act-quant unroll {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_act_quant_single_pass_sweep(self, request): + """Time the quantizer against its own second read of the row. + + The absmax has to see the whole row before the first element can + be quantized, so the two-pass kernel reads ``[T, K]``, reduces, + then reads ``[T, K]`` again. The single-pass kernel keeps the row + in registers across the reduction and deletes the second read -- + ~0.5 MB per expert at 256 rows and K = 2048, against 3.1 MB of + weights for the whole GEMM. It costs ``K / 16`` elements of + register pressure per lane (64 dwords of the default 128-dword + budget at K = 2048), which is exactly the risk this sweep exists to + settle: if it spills, the single-pass row is *slower*, and the + default should be flipped. Rows too long for the budget take the + two-pass kernel in both rows of the sweep, so those shapes should + read as noise. + """ + rows = run_config_sweep("prefill", _ACT_QUANT_SINGLE_PASS_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert row["snr_db"] >= _SWEEP_MIN_SNR_DB, ( + f"act-quant pass count {row['config']} disagrees with {rows[0]['config']}: " + f"SNR {row['snr_db']:.2f} dB" + ) + + def test_perf_prefill_store_sweep(self, request): + """Time the D store width at the compute-bound batch. + + The mainloop is identical in both rows. The DPAS C fragment hands a + lane one column of each 8x16 atom, so the 16 lanes of a sub-group + hold 16 consecutive columns of one row: a scalar store is a + 32-byte message for 16-bit D, and a 32x32 sub-group fragment issues + 64 of them. The 2D block store moves the same bytes in a handful of + messages and needs no predicate, because it clips to the output + surface in hardware. + + Expect the largest gain where D is the largest share of tile + traffic and the mainloop the shortest -- the small-K + down-projections, which are also the shapes furthest from the + compute target. + """ + rows = run_config_sweep("prefill", _PREFILL_STORE_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"store config {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_epilogue_sweep(self, request): """Time the epilogue guard at the compute-bound batch. @@ -1404,6 +1554,91 @@ def test_full_tile_epilogue_matches_predicated(self): f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" ) + def test_act_quant_single_pass_matches(self): + """Holding the row in registers must be bit-identical to re-reading it. + + The single-pass kernel changes *when* the row is read, not what is + computed from it: the per-lane reduction is still ``fmax`` over the + same values into the same four partial maxima (exact and + order-independent, so any grouping merges to the same bits), the + sub-group reduce and the reciprocal are untouched, and every + element goes through the same ``rint``/``clamp``. So the two-pass + kernel is an exact reference, not an approximate one. + + Both K are checked because the register array is bounded at compile + time and the rung is chosen from ``K``: at ``VEC = 8`` a lane walks + ``K / 128`` vectors, so K = 768 (6 vectors) takes the 8-slot rung + and K = 2048 (16) fills the 16-slot one exactly. An off-by-one in + the ``s < steps`` guard would either drop a vector from the absmax + or quantize past the end of the row, and both show up here. + """ + for nk_label, N, K in _QWEN3_NK: + case = _build_case( + N, + K, + _QWEN3_E, + _PREFILL_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for flag in ("0", "1"): + with _env_override(ARK_MOE_W4A8_ACT_QUANT_VEC="1", ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=flag): + outs[flag] = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + assert torch.equal(outs["0"], outs["1"]), ( + f"{nk_label.strip()} (K={K}): the single-pass activation quantizer disagrees with the " + f"two-pass one: max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" + ) + case = weights_s8 = wscales = outs = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + + def test_prefill_2d_store_matches_scalar(self): + """The 2D block store must write exactly what the scalar store wrote. + + Only the store mechanism changes: the scaled value is computed by + the same convert and the same two multiplies in the same order, and + the fragment is handed to ``copy`` through the same coordinates the + scalar path indexes with. The store predicate is gone because the + block message clips to the ``m x n`` output surface in hardware, so + the interesting failure is a *ragged* tile writing rows that belong + to the next expert -- which is silent corruption, not a crash. + + The batch is therefore the one from the interior-tile test: 300 + rows on every expert against a 256-row tile gives each expert one + interior tile and one ragged tile, and the experts are adjacent in + the output, so anything spilling past an expert's last row lands in + the comparison. + """ + rows_per_expert = _PREFILL_TARGET_ROWS_PER_EXPERT + 44 + case = _build_case( + _QWEN3_NK[1][1], + _QWEN3_NK[1][2], + _QWEN3_E, + rows_per_expert * _QWEN3_E, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for flag in ("0", "1"): + with _env_override(ARK_MOE_W4A8_PREFILL_STORE_2D=flag): + outs[flag] = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + assert torch.equal(outs["0"], outs["1"]), ( + "the 2D block store disagrees with the scalar store: " + f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" + ) + def test_decode_ksplit_matches_legacy(self): """The K-split decode mapping must agree with the legacy one. From 3660ad4321bca896f120cd69e0537938fb807666 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:12:56 +0000 Subject: [PATCH 070/112] docs: document the single-pass quantizer, the 2D store and the corrected roofline Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 247 ++++++++++++++---- .../ark/test/README_MOE_W4A8_CN.md | 206 ++++++++++++--- .../ark/test/test_moe_w4a8_perf.py | 61 +++-- 3 files changed, 404 insertions(+), 110 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 89d37fe278..ec289791a4 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -60,9 +60,13 @@ So the two targets below are 51% of int8 peak and 66% of pin bandwidth. The The goals for this kernel are **prefill > 100 TFLOPS** and **decode > 300 GB/s** of weight bandwidth. Whether the prefill goal is reachable at all is decided by -the *routing*, not by the kernel. A W4A8 grouped GEMM reads every active -expert's int8 weights exactly once and does `2 × rows_per_expert` FLOPs per -weight byte, so +the *routing*, not by the kernel. + +### The weights are not the only stream + +Earlier rounds of this document modelled the prefill roofline on the weights +alone: a W4A8 grouped GEMM reads every active expert's int8 weights exactly once +and does `2 × rows_per_expert` FLOPs per weight byte, so ``` arithmetic intensity = 2 × rows_per_expert [FLOP / byte] @@ -70,39 +74,78 @@ TFLOPS <= 2 × rows_per_expert × weight_bandwidth rows_per_expert = batch × top_k / active_experts ``` -The `N` and `K` factors cancel — only the routing matters: +and the `N`/`K` factors cancel. That is correct about the weight stream and +wrong about the total. One `moe_gemm_w4a8` call moves five streams, not one +(`T = batch × top_k` routed rows): + +| Stream | Bytes | Scales with | +|---|---|---| +| activations, read by the quantizer | `T × K × sizeof(act)` | `T` | +| the int8 copy, written | `T × K` | `T` | +| the int8 copy, read back by the GEMM | `T × K` | `T` | +| every active expert's weights | `E_active × N × K` | `E_active` | +| the output | `T × N × sizeof(out)` | `T` | + +Only the fourth line is what `W GB/s` reports and what the old model counted. +Because it is the only one that does *not* grow with the token count, it +dominates at small batches — where the old formula is very nearly exact — and +becomes a *minority* of the traffic exactly in the compute-bound regime the +target is measured in. At 256 rows per expert (the batch this harness used to +measure at): + +| Shape | Weights | Total traffic | Weights' share | BW for 100 TFLOPS (old model) | Ceiling at 400 GB/s | +|---|---|---|---|---|---| +| qwen3 up (N=1536, K=2048) | 403 MB | 772 MB | 52% | **374** GB/s (195) | 107 TFLOPS | +| qwen3 down (N=2048, K=768) | 201 MB | 436 MB | 46% | **423** GB/s (195) | **94 TFLOPS** | +| minimax up (N=1536, K=3072) | 906 MB | 1661 MB | 55% | **358** GB/s (195) | 112 TFLOPS | +| minimax down (N=3072, K=1536) | 906 MB | 1510 MB | 60% | **326** GB/s (195) | 123 TFLOPS | + +The old model printed 195 GB/s for all four. The real requirement is 1.7×–2.2× +that — and for the qwen3 down-projection it is **past what the device +delivers**: 423 GB/s against a 456 GB/s pin rate that probes at ~400 GB/s. Its +ceiling at that routing is 94 TFLOPS, so **that shape could not reach 100 TFLOPS +at the batch it was being measured at, whatever the kernel did**. It is also the +shape that has read furthest from target in every sweep (50–56 TFLOPS), which +is not a coincidence: smallest K means the largest non-weight share. + +So `_PREFILL_TARGET_ROWS_PER_EXPERT` moved from 256 to **384**, the smallest +round routing whose ceiling clears 100 TFLOPS on all four shapes (112 / 130 / +137 / 154 TFLOPS at a 400 GB/s probe; qwen3 down alone needs ≥ 290 rows per +expert). In model tokens that is 6144 for Qwen3-MoE and 9216 for MiniMax. + +The small-batch conclusions are unchanged, because the weight term dominates +there: | Model tokens | Routed rows | rows/expert | Bandwidth needed for 100 TFLOPS | |---|---|---|---| -| 128 (default prefill batch) | 1024 | 8 | 6250 GB/s | -| 512 | 4096 | 32 | 1563 GB/s | -| 2048 | 16384 | 128 | 391 GB/s | -| 4096 (`test_perf_prefill_compute_bound`) | 32768 | 256 | 195 GB/s | -| 8192 (`--all-shapes`) | 65536 | 512 | 98 GB/s | - -So ~4.5 TFLOPS at the default batch is **not** a kernel deficiency: at 8 rows -per expert and the ~285 GB/s of weight bandwidth the kernel actually achieves, -the ceiling is `2 × 8 × 285e9 = 4.56 TFLOPS` — the measured value, i.e. the -kernel is already running at the DRAM roofline. Reaching 100 TFLOPS there would -require 6.25 TB/s, 13× the B60's 456 GB/s and more than 10× any current GPU. On -a device streaming ~285 GB/s the target first becomes reachable at ~176 rows per -expert (~2816 model tokens), which is why `test_perf_prefill_compute_bound` -measures at 4096 model tokens. - -The perf table therefore prints `rows/E` and `BW@100T` next to the measured -numbers, and each sweep ends with a verdict block: +| 128 (default prefill batch) | 1024 | 8 | ~6300 GB/s | +| 512 | 4096 | 32 | ~1600 GB/s | +| 2048 | 16384 | 128 | ~440 GB/s | +| 6144 (`test_perf_prefill_compute_bound`) | 49152 | 384 | ~310 GB/s | + +~4.5 TFLOPS at the default batch is **not** a kernel deficiency: at 8 rows per +expert and the ~285 GB/s the kernel achieves, the ceiling is +`2 × 8 × 285e9 = 4.56 TFLOPS` — the measured value, i.e. the kernel is already +running at the DRAM roofline. Reaching 100 TFLOPS there would require more than +6 TB/s, over 13× the B60's 456 GB/s. + +The perf table therefore prints `rows/E`, `DRAM GB/s` (all five streams) and +`BW@100T` next to the measured numbers, and each sweep ends with a verdict +block: ``` targets [prefill]: prefill compute > 100 TFLOPS device copy bandwidth probe: 400 GB/s qwen3 up tokens=1024 rows/E=8.0 4.56 TFLOPS vs 100 -> N/A (bandwidth bound: ...) - qwen3 down tokens=32768 rows/E=256.0 102.40 TFLOPS vs 100 -> PASS + qwen3 down tokens=49152 rows/E=384.0 102.40 TFLOPS vs 100 -> PASS (92% of the 112 TFLOPS bandwidth ceiling) ``` A row is reported `N/A` rather than `FAIL` when the device bandwidth probe (one large device-to-device copy, measured once per run) shows the target is -unreachable at that routing. The verdict is informational by default; pass -`--enforce-targets` to turn it into a hard assertion. +unreachable at that routing; a reachable row also prints how much of its ceiling +it reaches, which is the part a kernel change can move. The verdict is +informational by default; pass `--enforce-targets` to turn it into a hard +assertion. ### Why `vs w4a16` is below 1.0 at small batches @@ -211,6 +254,36 @@ and order-independent, so the partial maxima merge to the same bits), and `test_act_quant_unroll_matches` asserts bit-identity at both a K that divides the unroll depth and one that leaves a tail. +**Reading the row once.** Batching the loads did not change how many there are. +The absmax has to see the whole row before the first element can be quantized, +so the pass reads `[T, K]`, reduces, then reads `[T, K]` again. The re-read is +L2-resident while the row is still there, but the rows a work-group quantizes +second evict the ones it quantized first well before the pass ends — at 8 MB of +L2 and 4 KB per bf16 row of `K = 2048`, only ~2000 rows fit *if nothing else is +resident*, and the GEMM's weights compete for the same cache immediately after. + +A row is small enough to keep in registers instead: a lane owns `K / 16` +elements, so `K = 2048` is 256 bytes — 64 of the 128 dwords per lane the +quantizer gets (it launches without `grf_size<256>`, unlike the GEMM). The +single-pass kernel loads the row once, reduces it, and quantizes out of the +registers; the second read disappears, and every load is issued before any is +consumed, which subsumes what `UNROLL` was doing rather than competing with it. + +`MAX_STEPS` is the compile-time cap that makes the fragment a register array +rather than scratch: the loop is `#pragma unroll` over `MAX_STEPS` with an +`if (s < steps)` guard, so every index is constant and SROA can promote it. Two +rungs are instantiated — 8 vectors (`K ≤ 1024` at `VEC = 8`, 32 dwords) and 16 +(`K ≤ 2048`, 64 dwords) — and anything longer keeps the two-pass kernel, which +is why minimax's `K = 3072` up-projection still takes the old path. The partial +maxima stay at four accumulators, so the reduction is unchanged in both cost and +value. + +This is a register-pressure gamble: if 64 dwords of row plus addressing spills, +the pass gets *slower*. `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` restores the +two-pass kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times +the pair, and `test_act_quant_single_pass_matches` asserts they agree bit for +bit at both a K that fills a rung exactly (2048) and one that does not (768). + **The GEMM epilogue.** The mainloop kept two C fragments live: the int32 DPAS accumulator, cleared once per AUTO_S8 re-scale block, and a float shadow that had to survive across blocks because each block's weight scale is applied before @@ -257,6 +330,51 @@ so the two are bit-identical and `test_full_tile_epilogue_matches_predicated` asserts exactly that at a batch that gives every expert one interior tile and one ragged one. +**The store itself.** Removing instructions from around the store left the +store. The Xe DPAS C fragment gives a lane one *column* of each 8×16 atom, so +the 16 lanes of a sub-group hold 16 *consecutive columns of one row*: a scalar +`c[row * n + col] = ...` is a 32-byte message for 16-bit `ElementD` — half a +cache line — and a 32×32 sub-group fragment issues **64** of them. The same +bytes go out in a handful of messages through the hardware 2D block store, which +is what every sibling prefill kernel already uses for D +(`sycl_tla_moe_prefill_{fp8,int,s4}_dpas.hpp`) and what the dense GEMM in +`sycl_tla_dense_gemm.hpp` uses on this exact accumulator shape. + +D is why this is worth doing at prefill sizes rather than as a tidy-up: at 384 +rows per expert the qwen3 down-projection writes 0.79 MB of fp16 per expert +against 1.5 MB of int8 weights — a third of the tile traffic, because N (2048) +is larger than K (768) there — and it is the same shape whose mainloop is +shortest, so it pays the epilogue twice. + +The port follows `dense_gemm_detail::gemm_device_impl` rather than the sibling +MoE kernels. Those `reorder(tCrC, tCrC_out)` from the MMA fragment into an +explicitly chosen `XE_STORE_2D` atom's fragment, and `reorder` moves *registers*: +free with a `float` accumulator, but this kernel accumulates in `int32` +(`FrgTypeC` of `XE_DPAS_TT<8, int32_t, int8_t, int8_t>`) and has to scale and +numerically convert first, which `reorder` does not do. `make_block_2d_copy_D` +derives its layout from the MMA's own C partition, so the scaled `ElementD` +fragment — `make_tensor_like(tCrC)`, filled through the same `tCgC(i)` +coordinates the scalar path indexes with — goes straight to +`copy(copy_d, tCrD, tCgC)` with no `reorder` in between. + +It also *removes* the store predicate rather than skipping it: the 2D block +message clips to the surface described by the D tensor, so a partial tile at the +M edge drops its out-of-range rows in hardware, exactly as the sibling grouped +GEMMs rely on for their ragged experts. Only the scale loads still clamp, and +only on edge tiles. + +The descriptor wants a 64-byte aligned base and a row pitch that is a multiple +of 16 bytes. D's per-expert base is `outputs + pre_rows × N` for a +routing-dependent `pre_rows`, so the dispatcher gates on +`N × sizeof(ElementD) % 64 == 0` — which makes *every* expert's base aligned +given an aligned tensor, and covers the pitch too — plus the tensor base itself. +Every shipped N (1536 / 2048 / 3072 with 16-bit D) clears it; anything else keeps +the scalar store. `ARK_MOE_W4A8_PREFILL_STORE_2D=0` also keeps it, for A/B +measurement, and `test_prefill_2d_store_matches_scalar` asserts the two write +identical bits at a batch that gives every expert one interior and one ragged +tile — the case where a store that did *not* clip would corrupt the next +expert's rows. + ## What the script measures ### Accuracy table @@ -285,7 +403,8 @@ block, transposed layout, wrong expert offset) rather than mere lossiness. | `rows/E` | Routed tokens per **active** expert. Arithmetic intensity is `2 × rows/E` FLOPs per weight byte, so this single number decides whether a shape can be compute bound at all | | `TFLOPS` | `total_tokens × N × K × 2 / time` | | `W GB/s` | Expert-weight bandwidth actually touched by the routed tokens (`active_experts × N × K × 1 byte / time`) — the limiter for memory-bound decode | -| `BW@100T` | Weight bandwidth this shape would need to reach 100 TFLOPS. When it exceeds what the device can stream, `TFLOPS` is capped by memory and no kernel change can hit the target at that shape | +| `DRAM GB/s` | *All* the traffic the call moves: the fp16 activations read, the int8 copy written and read back, the expert weights, and the output — see [The weights are not the only stream](#the-weights-are-not-the-only-stream). This is the number to compare against the device's 456 GB/s | +| `BW@100T` | DRAM bandwidth (all five streams) this shape would need to reach 100 TFLOPS. When it exceeds what the device can stream, `TFLOPS` is capped by memory and no kernel change can hit the target at that shape | | `vs torch` / `vs w4a16` | Speedups (`other / w4a8`) | | `prepack(ms)` | One-shot int4 → int8 AUTO_S8 conversion cost. Paid once at model load, **not** per forward. | @@ -309,8 +428,10 @@ Routed expert-token rows are `batch × top_k`, spread round-robin over the 128 experts. Default batches: `128` for prefill and `1` for decode; `--all-shapes` widens them to `{128, 512, 2048, 8192}` and `{1, 2, 8, 16}` respectively. `test_perf_prefill_compute_bound` adds a single batch sized so every expert -gets 256 rows (4096 model tokens for Qwen3-MoE) — the smallest sweep point where -the 100 TFLOPS goal is not capped by weight bandwidth. +gets 384 rows (6144 model tokens for Qwen3-MoE) — the smallest round sweep point +where the 100 TFLOPS goal is under the device's bandwidth ceiling on *every* +shipped shape, counting [all five streams](#the-weights-are-not-the-only-stream) +and not just the weights. A second shape group covers MiniMax-M2, matching `test_moe_prefill_perf.py`: @@ -326,7 +447,7 @@ It matters because both targets are shape dependent: 192 experts spread a given batch over 1.5× more experts (fewer rows per expert, so a *lower* compute ceiling at the same batch), while the longer K gives the decode GEMV a longer sequential stream and the prefill tile more K per tile-load. The compute-bound -batch is derived per model, so MiniMax runs 6144 model tokens for the same 256 +batch is derived per model, so MiniMax runs 9216 model tokens for the same 384 rows per expert. Shape groups are selected with `--models` (`qwen3` — the default —, `minimax`, a comma-separated list, or `all`); the heavy-tailed empirical routing for MiniMax lives in `test_moe_prefill_perf.py`. @@ -351,7 +472,7 @@ pytest -v -s test_moe_w4a8_perf.py -k perf # One phase pytest -v -s test_moe_w4a8_perf.py -k decode -# The compute-bound prefill case (4096 model tokens), where the TFLOPS goal is reachable +# The compute-bound prefill case (6144 model tokens), where the TFLOPS goal is reachable pytest -v -s test_moe_w4a8_perf.py -k compute_bound # Make the performance goals hard assertions instead of a printed verdict @@ -384,7 +505,7 @@ python test_moe_w4a8_perf.py # both phases, smallest batch python test_moe_w4a8_perf.py --all-shapes # full sweep python test_moe_w4a8_perf.py --phase decode # decode only python test_moe_w4a8_perf.py --skip-accuracy # perf only -python test_moe_w4a8_perf.py --compute-bound # add the 4096-token prefill case +python test_moe_w4a8_perf.py --compute-bound # add the 6144-token prefill case python test_moe_w4a8_perf.py --dtype fp16 # fp16 activations python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 @@ -465,6 +586,12 @@ activations, 8 routed rows for decode, 256 rows/expert for prefill). Every configuration is checked for numerical equivalence with the first one before it is timed. +Two defaults are *not* in this section because they have not been measured yet: +`ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` and `ARK_MOE_W4A8_PREFILL_STORE_2D` are +both on, on reasoning alone. `test_perf_prefill_act_quant_single_pass_sweep` and +`test_perf_prefill_store_sweep` are the runs that fill them in; see +[Status](#status). + ### Prefill tile | shape | `128x128` | `256x128` | `128x256` | `256x256` | @@ -581,7 +708,9 @@ nothing else, so the remaining gap is message efficiency, not arithmetic. | `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which picks `256x128` at ≥ 256 rows/expert and `128x128` otherwise (see [Tuned defaults](#tuned-defaults-measured)). The whole table is now within 0–8%, so this is a re-tuning knob rather than a cliff. | | `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.04–1.14× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized mapping. | +| `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets). Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. Not yet measured on hardware — if the register array spills, this is a slowdown, and the sweep is what decides. | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth 1.02–1.15× on the swept shapes. The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | +| `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape). Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Not yet measured on hardware. | ## Shape constraints @@ -616,23 +745,41 @@ are each checked against their predecessors as well as timed. Still to run on device: the accuracy sweep against the fp32 reference, which will catch layout/scale bugs immediately. -The activation quantizer's batched loads are the one change that has been -reasoned through but neither timed nor run: `UNROLL` vectors are loaded before -any is consumed, so a work-item keeps that many requests outstanding instead of -one. Its device checks are `test_act_quant_unroll_matches` (bit-identity at both -a K that divides the depth and one that leaves a tail) and -`test_perf_prefill_act_quant_unroll_sweep`; `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` -reverts it without a rebuild. - -The next prefill step, once that lands, is the store itself: the epilogue still -writes D one element per predicate through a scalar pointer, where the sibling -int8 and fp8 kernels use a block-2D copy for D. At `K = 768` — the shape stuck -at 56 TFLOPS — a tile runs only 12 k-tiles, so the store is a large fraction of -its time. Unlike everything above it is not a pure-C++ change: the Xe DPAS C -fragment gives each lane one *column*, so consecutive values of a lane are -strided by N and only the hardware 2D block-store message can widen them. The -port needs `partition_sg_fragment_C` / `partition_sg_fragment_S` and a -`reorder` through a float intermediate (the accumulator is int32 and must be -scaled per row *and* per column first), and no sibling kernel 2D-stores a -*scaled* int32 accumulator — so it wants an environment with a SYCL compiler and -a device, not a flag. +Three prefill changes have been reasoned through but neither timed nor run, +because the authoring environment has no XPU and no SYCL compiler. Each keeps +its predecessor one environment variable away, and each has a bit-identity test +that runs on device: + +| Change | Revert with | Device checks | +|---|---|---| +| Activation quantizer's batched loads — `UNROLL` vectors in flight instead of one | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | `test_act_quant_unroll_matches`, `test_perf_prefill_act_quant_unroll_sweep` | +| Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | `test_act_quant_single_pass_matches`, `test_perf_prefill_act_quant_single_pass_sweep` | +| 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | `test_prefill_2d_store_matches_scalar`, `test_perf_prefill_store_sweep` | + +The 2D store was previously listed here as needing a device rather than a flag, +on the grounds that the sibling MoE kernels reach it through +`partition_sg_fragment_S` + `reorder` and no sibling 2D-stores a *scaled int32* +accumulator. That turned out to be the wrong reference: `reorder` moves +registers and does not convert, so it could never have carried an int32→fp16 +epilogue. `sycl_tla_dense_gemm.hpp` — in the same translation unit — already +compiles the sequence that does (`make_block_2d_copy_D(mma, D)` + +`make_tensor_like(tCrC)` + `copy(copy_d, tCrD, tCgC)`, with a 32-bit +accumulator and a 16-bit output), so the port is a pure-C++ change after all. + +The single-pass quantizer is the one with a real downside risk: if the +register-resident row spills, the pass gets slower rather than faster, and only +`test_perf_prefill_act_quant_single_pass_sweep` can say which. Flip the default +in `launch_act_dynamic_quant_vec_unroll` if it does. + +What the sweeps should be read against has also changed. The prefill roofline in +this document used to count weight bytes only, which understated the bandwidth +these shapes need by 1.7–2.2× and made a 94-TFLOPS-ceiling shape look like a +kernel deficiency (see [the roofline](#the-weights-are-not-the-only-stream)). +With every stream counted, the four compute-bound shapes were running at 60–74% +of their true ceilings, and the compute-bound batch moved from 256 to 384 rows +per expert so that 100 TFLOPS is reachable on all of them. The remaining gap is +traffic, not arithmetic: the largest single win still on the table is fusing the +activation quantization into the GEMM's A-tile load, which would delete the int8 +copy's write *and* read — 2 of the 5 streams, ~25% of the traffic on the +small-K shapes — but that is a mainloop change and wants a device to develop +against. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 49bd19b568..0b1056e70f 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -57,9 +57,13 @@ w8[e][n][k] = round( w4[e][n][k] * s[e][n][k / group_size] / sxt[e][n][j] ) 暴露两个这样的设备,kernel 只看到其中一个。 本 kernel 的目标是 **prefill > 100 TFLOPS**、**decode 权重带宽 > 300 GB/s**。 -prefill 目标是否*可能*达到,取决于**路由**而不是 kernel 本身:W4A8 grouped GEMM -对每个活跃专家的 int8 权重只读一次,而每读一个权重字节要做 `2 × rows_per_expert` -次浮点运算,因此 +prefill 目标是否*可能*达到,取决于**路由**而不是 kernel 本身。 + +### 权重并不是唯一的数据流 + +本文早期版本用**权重这一条流**来建立 prefill 的 roofline:W4A8 grouped GEMM 对每个 +活跃专家的 int8 权重只读一次,每读一个权重字节要做 `2 × rows_per_expert` 次浮点运算, +因此 ``` 计算强度 = 2 × rows_per_expert [FLOP / byte] @@ -67,34 +71,67 @@ TFLOPS <= 2 × rows_per_expert × 权重带宽 rows_per_expert = batch × top_k / active_experts ``` -其中 `N`、`K` 因子相互抵消 — 只有路由起作用: +其中 `N`、`K` 因子相互抵消。这个式子对**权重流**是对的,对**总流量**是错的。一次 +`moe_gemm_w4a8` 调用要搬运的是五条流,而不是一条 (`T = batch × top_k` 为路由行数): + +| 数据流 | 字节数 | 随谁增长 | +|---|---|---| +| 量化 kernel 读入的激活 | `T × K × sizeof(act)` | `T` | +| 写出的 int8 副本 | `T × K` | `T` | +| GEMM 再读回的同一份 int8 副本 | `T × K` | `T` | +| 所有活跃专家的权重 | `E_active × N × K` | `E_active` | +| 输出 | `T × N × sizeof(out)` | `T` | + +只有第四行是 `W GB/s` 统计的、也是旧模型唯一计入的部分。因为它是唯一**不**随 token +数增长的一条,所以它在小 batch 下占主导 (那里旧公式几乎是精确的),而恰恰在用于衡量 +目标的计算受限区间里变成了**少数**。在每专家 256 行 (本测试脚本此前使用的 batch) 时: + +| 形状 | 权重 | 总流量 | 权重占比 | 达到 100 TFLOPS 所需带宽 (旧模型) | 400 GB/s 下的上限 | +|---|---|---|---|---|---| +| qwen3 up (N=1536, K=2048) | 403 MB | 772 MB | 52% | **374** GB/s (195) | 107 TFLOPS | +| qwen3 down (N=2048, K=768) | 201 MB | 436 MB | 46% | **423** GB/s (195) | **94 TFLOPS** | +| minimax up (N=1536, K=3072) | 906 MB | 1661 MB | 55% | **358** GB/s (195) | 112 TFLOPS | +| minimax down (N=3072, K=1536) | 906 MB | 1510 MB | 60% | **326** GB/s (195) | 123 TFLOPS | + +旧模型给这四个形状打印的都是 195 GB/s。真实需求是它的 1.7–2.2 倍 — 而对 qwen3 +down-projection 来说,这个需求**超过了设备能提供的带宽**:需要 423 GB/s,而引脚带宽 +456 GB/s 的实测拷贝只有约 400 GB/s。该路由下它的上限是 94 TFLOPS,也就是说 +**无论 kernel 怎么改,在此前测量所用的 batch 上这个形状都不可能达到 100 TFLOPS**。 +它也正是历次扫描中离目标最远的形状 (50–56 TFLOPS),这并非巧合:K 最小意味着非权重 +流量占比最大。 + +因此 `_PREFILL_TARGET_ROWS_PER_EXPERT` 从 256 提高到 **384** — 这是能让四个形状的 +上限全部越过 100 TFLOPS 的最小整数路由 (在 400 GB/s 探测值下分别为 112 / 130 / +137 / 154 TFLOPS;其中 qwen3 down 单独要求 ≥ 290 行/专家)。换算成模型 token 数, +Qwen3-MoE 为 6144,MiniMax 为 9216。 + +小 batch 的结论不变,因为那里权重项占主导: | 模型 token 数 | 路由行数 | 每专家行数 | 达到 100 TFLOPS 所需带宽 | |---|---|---|---| -| 128 (prefill 默认 batch) | 1024 | 8 | 6250 GB/s | -| 512 | 4096 | 32 | 1563 GB/s | -| 2048 | 16384 | 128 | 391 GB/s | -| 4096 (`test_perf_prefill_compute_bound`) | 32768 | 256 | 195 GB/s | -| 8192 (`--all-shapes`) | 65536 | 512 | 98 GB/s | +| 128 (prefill 默认 batch) | 1024 | 8 | 约 6300 GB/s | +| 512 | 4096 | 32 | 约 1600 GB/s | +| 2048 | 16384 | 128 | 约 440 GB/s | +| 6144 (`test_perf_prefill_compute_bound`) | 49152 | 384 | 约 310 GB/s | -因此默认 batch 下约 4.5 TFLOPS **并不是 kernel 的缺陷**:在每专家 8 行、kernel 实 -测约 285 GB/s 权重带宽的条件下,上限就是 `2 × 8 × 285e9 = 4.56 TFLOPS` — 正好等于 -实测值,说明 kernel 已经跑在 DRAM roofline 上。要在该形状上达到 100 TFLOPS 需要 -6.25 TB/s,是 B60 那 456 GB/s 的 13 倍,也比当前任何 GPU 高 10 倍以上。在带宽约 -285 GB/s 的设备上,该目标最早在每专家约 176 行 (约 2816 个模型 token) 时才变得可达, -这正是 `test_perf_prefill_compute_bound` 使用 4096 个模型 token 的原因。 +默认 batch 下约 4.5 TFLOPS **并不是 kernel 的缺陷**:在每专家 8 行、kernel 实测约 +285 GB/s 的条件下,上限就是 `2 × 8 × 285e9 = 4.56 TFLOPS` — 正好等于实测值,说明 +kernel 已经跑在 DRAM roofline 上。要在该形状上达到 100 TFLOPS 需要 6 TB/s 以上, +是 B60 那 456 GB/s 的 13 倍以上。 -因此性能表在实测值旁边额外打印 `rows/E` 和 `BW@100T`,并在每次扫描后输出结论: +因此性能表在实测值旁边额外打印 `rows/E`、`DRAM GB/s` (五条流的总和) 和 `BW@100T`, +并在每次扫描后输出结论: ``` targets [prefill]: prefill compute > 100 TFLOPS device copy bandwidth probe: 400 GB/s qwen3 up tokens=1024 rows/E=8.0 4.56 TFLOPS vs 100 -> N/A (bandwidth bound: ...) - qwen3 down tokens=32768 rows/E=256.0 102.40 TFLOPS vs 100 -> PASS + qwen3 down tokens=49152 rows/E=384.0 102.40 TFLOPS vs 100 -> PASS (92% of the 112 TFLOPS bandwidth ceiling) ``` 当设备带宽探测 (每次运行执行一次的大块 device-to-device 拷贝) 表明该路由下目标不 -可达时,该行显示 `N/A` 而不是 `FAIL`。该结论默认只用于提示;加上 +可达时,该行显示 `N/A` 而不是 `FAIL`;可达的行还会额外打印它达到了自身上限的百分之 +多少 — 这才是 kernel 改动能够撬动的部分。该结论默认只用于提示;加上 `--enforce-targets` 可以把它变成硬断言。 ### 为什么小 batch 下 `vs w4a16` 小于 1.0 @@ -189,6 +226,32 @@ load 在途。这是 Little 定律的问题,而不是带宽的问题:1280 `test_act_quant_unroll_matches` 会在"K 能整除展开深度"和"K 会留下尾巴"两种情况下断言 逐位相同。 +**只读一遍这一行。** 批量化只改变了加载的方式,没有减少加载的次数。absmax 必须先看 +完整行才能量化第一个元素,因此这一遍要先读一次 `[T, K]`、做归约、再读一次 `[T, K]`。 +只要行还在缓存里,第二次读就由 L2 提供;但一个 work-group 后量化的那些行,会在这一遍 +结束之前把它先量化的那些行挤出去——在 8 MB 的 L2 和 `K = 2048` 时每行 4 KB 的条件下, +*在没有任何其它数据驻留*的前提下也只装得下约 2000 行,而紧接着 GEMM 的权重还要争抢同 +一块缓存。 + +一行数据小到足以放进寄存器:一个 lane 拥有 `K / 16` 个元素,`K = 2048` 时是 256 字节 +——占量化 kernel 每 lane 128 个 dword 预算中的 64 个 (与 GEMM 不同,它没有用 +`grf_size<256>` 启动)。单遍 kernel 把整行一次性读入、做归约、再直接从寄存器里量化写 +出;第二次读消失了,而且所有 load 都在第一次被消费之前就发出,这等于把 `UNROLL` 想做 +的事情一并做了,而不是与它冲突。 + +`MAX_STEPS` 是让这段 fragment 落在寄存器而不是 scratch 上的编译期上界:循环是对 +`MAX_STEPS` 的 `#pragma unroll`,配合 `if (s < steps)` 保护,因此所有下标都是常量, +SROA 可以把数组提升为标量。共实例化两档——8 个向量 (`VEC = 8` 时 `K ≤ 1024`,32 个 +dword) 与 16 个向量 (`K ≤ 2048`,64 个 dword)——更长的行仍走两遍 kernel,这也是 +minimax 的 `K = 3072` up-projection 依旧走旧路径的原因。局部最大值仍然是 4 个累加器, +因此归约的代价和结果都没有变化。 + +这是一次寄存器压力上的赌博:如果 64 个 dword 的行数据加上寻址导致溢出,这一遍会变得 +**更慢**。`ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` 可以精确地切回两遍 kernel, +`test_perf_prefill_act_quant_single_pass_sweep` 负责给两者计时, +`test_act_quant_single_pass_matches` 则在"恰好填满一档的 K (2048)"和"填不满的 +K (768)"两种情况下断言二者逐位相同。 + **GEMM 尾声 (epilogue)。** 原先的主循环同时保持两份 C fragment:int32 的 DPAS 累加 器 (每个 AUTO_S8 重缩放 block 清零一次),以及一份必须跨 block 存活的 float 影子—— 因为每个 block 的权重 scale 必须在下一个 block 覆盖累加器之前应用。在**整个**主循 @@ -227,6 +290,43 @@ load 在途。这是 Little 定律的问题,而不是带宽的问题:1280 `test_full_tile_epilogue_matches_predicated` 正是在"每个专家恰好有一个完整 tile 和一 个残缺 tile"的 batch 上断言这一点。 +**store 本身。** 把 store 周围的指令削减之后,剩下的就是 store 本身。Xe DPAS 的 C +fragment 让一个 lane 持有每个 8×16 atom 的一*列*,因此一个 sub-group 的 16 个 lane +持有的是*同一行的 16 个连续列*:对 16 位的 `ElementD` 来说,一次标量 +`c[row * n + col] = ...` 只是一条 32 字节的消息——半条 cache line——而一个 32×32 的 +sub-group fragment 要发 **64** 条。通过硬件 2D block store,同样的字节只需要少数几条 +消息,这也正是所有同族 prefill kernel 写 D 时采用的方式 +(`sycl_tla_moe_prefill_{fp8,int,s4}_dpas.hpp`),以及 `sycl_tla_dense_gemm.hpp` 中的 +稠密 GEMM 在完全相同的累加器形状上采用的方式。 + +D 才是这件事在 prefill 尺寸下值得做、而不只是"顺手整理"的原因:每专家 384 行时, +qwen3 down-projection 每个专家要写 0.79 MB 的 fp16,而权重只有 1.5 MB 的 int8——占 +tile 总流量的三分之一,因为那里的 N (2048) 比 K (768) 更大;而它恰好又是主循环最短的 +形状,等于把 epilogue 的代价付了两遍。 + +移植参考的是 `dense_gemm_detail::gemm_device_impl`,而不是同族的 MoE kernel。后者用 +`reorder(tCrC, tCrC_out)` 把 MMA fragment 搬进显式选定的 `XE_STORE_2D` atom 的 +fragment,而 `reorder` 搬的是**寄存器**:在 `float` 累加器下这是免费的,但本 kernel +用 `int32` 累加 (`XE_DPAS_TT<8, int32_t, int8_t, int8_t>` 的 `FrgTypeC`),必须先缩放 +并做数值转换,而 `reorder` 并不做转换。`make_block_2d_copy_D` 的布局直接来自 MMA 自身 +的 C 划分,因此缩放后的 `ElementD` fragment——`make_tensor_like(tCrC)`,用 +标量路径所用的同一组 `tCgC(i)` 坐标填充——可以直接交给 +`copy(copy_d, tCrD, tCgC)`,中间不需要任何 `reorder`。 + +它还**去掉**了 store 谓词,而不只是跳过它:2D block 消息会裁剪到 D tensor 所描述的表 +面,因此 M 边缘的部分 tile 由硬件丢弃越界的行——同族 grouped GEMM 处理残缺专家时依赖 +的正是这一点。只有 scale 的 load 仍需钳制下标,而且只在边缘 tile 上。 + +该描述符要求基址 64 字节对齐、行 pitch 是 16 字节的倍数。D 的每专家基址是 +`outputs + pre_rows × N`,其中 `pre_rows` 是运行期的路由值,因此 dispatcher 检查的是 +`N × sizeof(ElementD) % 64 == 0`——在 tensor 本身对齐的前提下,这能保证*每一个*专家的 +基址都对齐,同时也覆盖了 pitch——外加 tensor 基址本身。所有已支持的 N (16 位 D 下的 +1536 / 2048 / 3072) 都满足;不满足的形状继续走标量 store。 +`ARK_MOE_W4A8_PREFILL_STORE_2D=0` 同样会切回标量 store 以便 A/B 测量, +`test_prefill_2d_store_matches_scalar` 则在"每个专家恰好有一个完整 tile 和一个残缺 +tile"的 batch 上断言两者写出的比特完全一致——这正是一个不做裁剪的 store 会污染下一个 +专家行数据的场景。 + ## 脚本测量的内容 ### 精度表 @@ -255,7 +355,8 @@ int8 激活大约损失 7 bit 尾数,正常情况下会明显高于该门限 | `rows/E` | 每个**活跃**专家分到的路由 token 数。计算强度为每权重字节 `2 × rows/E` 次浮点运算,因此该数值单独决定了某个形状是否可能成为计算受限 | | `TFLOPS` | `total_tokens × N × K × 2 / time` | | `W GB/s` | 被路由 token 实际访问到的专家权重带宽 (`active_experts × N × K × 1 byte / time`) — decode 访存瓶颈的衡量指标 | -| `BW@100T` | 该形状达到 100 TFLOPS 所需的权重带宽。当它超过设备实际能提供的带宽时,`TFLOPS` 就被访存限制,任何 kernel 改动都无法在该形状上达标 | +| `DRAM GB/s` | 本次调用搬运的**全部**流量:读入的 fp16 激活、写出并再读回的 int8 副本、专家权重,以及输出——参见[权重并不是唯一的数据流](#权重并不是唯一的数据流)。这才是应当与设备 456 GB/s 相比较的数值 | +| `BW@100T` | 该形状达到 100 TFLOPS 所需的 DRAM 带宽 (计入全部五条数据流)。当它超过设备实际能提供的带宽时,`TFLOPS` 就被访存限制,任何 kernel 改动都无法在该形状上达标 | | `vs torch` / `vs w4a16` | 加速比 (`other / w4a8`) | | `prepack(ms)` | 一次性的 int4 → int8 AUTO_S8 转换开销。只在模型加载时支付,**不是**每次前向都支付。 | @@ -278,9 +379,10 @@ qwen3 down (down-proj) : N = 2048, K = 768 被路由的 expert-token 行数为 `batch × top_k`,以 round-robin 方式分布到 128 个专 家上。默认 batch:prefill 为 `128`,decode 为 `1`;`--all-shapes` 会分别扩展为 `{128, 512, 2048, 8192}` 和 `{1, 2, 8, 16}`。 -`test_perf_prefill_compute_bound` 额外增加一个 batch,其大小保证每个专家拿到 256 -行 (Qwen3-MoE 为 4096 个模型 token) — 这是 100 TFLOPS 目标不再被权重带宽限制的最 -小扫描点。 +`test_perf_prefill_compute_bound` 额外增加一个 batch,其大小保证每个专家拿到 384 行 +(Qwen3-MoE 为 6144 个模型 token) — 这是在计入 +[全部五条数据流](#权重并不是唯一的数据流)而不只是权重之后,100 TFLOPS 目标在*所有*已 +支持形状上都低于设备带宽天花板的最小整值扫描点。 第二个形状组是 MiniMax-M2,与 `test_moe_prefill_perf.py` 保持一致: @@ -295,7 +397,7 @@ minimax down : N = 3072, K = 1536 之所以需要它,是因为两个目标都与形状相关:192 个专家会把同样的 batch 摊到 1.5 倍 的专家上 (每专家行数更少,因此相同 batch 下的算力上限*更低*),而更长的 K 则让 decode GEMV 的顺序访存流更长、也让 prefill 的 tile 每次加载覆盖更多 K。compute- -bound 的 batch 按模型推导,因此 MiniMax 用 6144 个模型 token 达到同样的每专家 256 +bound 的 batch 按模型推导,因此 MiniMax 用 9216 个模型 token 达到同样的每专家 384 行。形状组通过 `--models` 选择 (`qwen3` — 默认 —、`minimax`、逗号分隔的列表或 `all`);MiniMax 的重尾真实路由分布仍在 `test_moe_prefill_perf.py` 中。 @@ -319,7 +421,7 @@ pytest -v -s test_moe_w4a8_perf.py -k perf # 单个阶段 pytest -v -s test_moe_w4a8_perf.py -k decode -# 计算受限的 prefill 用例 (4096 个模型 token),TFLOPS 目标在此可达 +# 计算受限的 prefill 用例 (6144 个模型 token),TFLOPS 目标在此可达 pytest -v -s test_moe_w4a8_perf.py -k compute_bound # 把性能目标从提示信息变成硬断言 @@ -351,7 +453,7 @@ python test_moe_w4a8_perf.py # 两个阶段,最小 batch python test_moe_w4a8_perf.py --all-shapes # 完整扫描 python test_moe_w4a8_perf.py --phase decode # 仅 decode python test_moe_w4a8_perf.py --skip-accuracy # 仅性能 -python test_moe_w4a8_perf.py --compute-bound # 追加 4096 token 的 prefill 用例 +python test_moe_w4a8_perf.py --compute-bound # 追加 6144 token 的 prefill 用例 python test_moe_w4a8_perf.py --dtype fp16 # fp16 激活 python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 @@ -425,6 +527,11 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 routed 行,prefill 为每专家 256 行)。每种配置在计时之前都会先与第一种配置做数值等价性 检查。 +有两项默认值**不在**本节中,因为它们尚未被实测: +`ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` 与 `ARK_MOE_W4A8_PREFILL_STORE_2D` 都是仅凭推导 +就默认开启的。`test_perf_prefill_act_quant_single_pass_sweep` 与 +`test_perf_prefill_store_sweep` 正是用来补齐这两项数据的运行;参见[状态](#状态)。 + ### Prefill tile | 形状 | `128x128` | `256x128` | `128x256` | `256x256` | @@ -530,7 +637,9 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 ≥ 256 行时取 `256x128`,否则取 `128x128` (参见[实测得到的默认值](#实测得到的默认值))。现在整张表的差距已收敛到 0–8%,因此这个开关是重新调优用的旋钮,而不再对应一道性能悬崖。 | | `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.04–1.14× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化映射生效。 | +| `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个)。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。尚未在硬件上实测:如果寄存器数组发生溢出,这反而是减速,最终由扫描结果决定。 | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上带来 1.02–1.15× 的收益。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | +| `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。尚未在硬件上实测。 | ## 形状约束 @@ -562,19 +671,34 @@ W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描,它能立刻暴露 layout / scale 相关的 bug。 -激活量化的批量 load 是唯一一项只经过推导、既未实测计时也尚未在设备上运行的改动:先加 -载 `UNROLL` 个向量再开始消费,使一个 work-item 同时挂起这么多请求,而不是只有一个。它 -在设备上的检查是 `test_act_quant_unroll_matches` (在"K 能整除展开深度"和"K 会留下尾 -巴"两种情况下的逐位一致性) 与 `test_perf_prefill_act_quant_unroll_sweep`;设置 -`ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` 即可在不重新编译的情况下回退。 - -这一项落地之后,prefill 的下一步是写出本身:epilogue 目前仍然通过标量指针、按谓词逐 -元素写 D,而同类的 int8 与 fp8 kernel 对 D 用的是 block-2D copy。在卡在 56 TFLOPS 的 -`K = 768` 形状上,一个 tile 只有 12 个 k-tile,写出因此占据了其中相当大的一部分时间。 -与上面几项不同,它并不是纯 C++ 的改动:Xe DPAS 的 C fragment 给每个 lane 分配的是一 -*列*,因此同一个 lane 的相邻数值在内存中相隔 N,只有硬件的 2D block-store 消息才能把 -它们合并成宽消息。移植它需要用到 `partition_sg_fragment_C` / -`partition_sg_fragment_S` 以及经由 float 中间 fragment 的 `reorder` (累加器是 int32, -必须先按行、再按列施加 scale),而同类 kernel 中没有任何一个是对**带 scale 的** int32 -累加器做 2D 写出的——因此这项工作需要一个具备 SYCL 编译器和设备的环境,而不是一个开 -关。 +有三项 prefill 改动只经过推导,既未实测计时也尚未在设备上运行,因为编写它们的环境既没 +有 XPU 也没有 SYCL 编译器。每一项都只隔着一个环境变量就能退回其前身,并且都配有可在设 +备上运行的逐位一致性测试: + +| 改动 | 回退方式 | 设备上的检查 | +|---|---|---| +| 激活量化的批量 load——同时挂起 `UNROLL` 个请求而不是一个 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | `test_act_quant_unroll_matches`、`test_perf_prefill_act_quant_unroll_sweep` | +| 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | `test_act_quant_single_pass_matches`、`test_perf_prefill_act_quant_single_pass_sweep` | +| D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | `test_prefill_2d_store_matches_scalar`、`test_perf_prefill_store_sweep` | + +本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 +`partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** +int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reorder` 搬的是寄存器、并不做 +数值转换,因此它本来就承载不了 int32→fp16 的 epilogue。而同一个编译单元里的 +`sycl_tla_dense_gemm.hpp` 早就在编译真正可用的那套序列 (`make_block_2d_copy_D(mma, D)` ++ `make_tensor_like(tCrC)` + `copy(copy_d, tCrD, tCgC)`,且正是 32 位累加器 +配 16 位输出),所以这项移植终究是纯 C++ 的改动。 + +单遍量化 kernel 是其中唯一存在真实下行风险的一项:如果留在寄存器里的行数据发生溢出, +这一遍会变慢而不是变快,而只有 +`test_perf_prefill_act_quant_single_pass_sweep` 能给出结论。真是如此的话,把 +`launch_act_dynamic_quant_vec_unroll` 里的默认值翻转即可。 + +这些扫描该拿什么作为参照,也已经改变了。本文档中 prefill 的 roofline 此前只统计了权重 +字节数,把这些形状真正需要的带宽低估了 1.7–2.2×,让一个天花板只有 94 TFLOPS 的形状看 +起来像是 kernel 的缺陷 (参见 [roofline](#权重并不是唯一的数据流))。把所有数据流都计入 +之后,四个受算力约束的形状实际上跑在各自真实天花板的 60–74%,而受算力约束的 batch 也 +从每专家 256 行提高到 384 行,好让 100 TFLOPS 在所有形状上都是可达的。剩下的差距在访 +存而不是算术:目前仍摆在桌面上的最大一项收益,是把激活量化融合进 GEMM 的 A-tile 加载 +中,这将同时消掉 int8 副本的写与读——5 条数据流中的 2 条,在 K 较小的形状上约占 25% +的流量——但那是主循环的改动,需要在有设备的环境里开发。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 90c163d687..216f51bd05 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -349,12 +349,32 @@ def _models_option(request): _DECODE_BATCHES_EXTENDED = [1, 2, 8, 16] _PREFILL_BATCHES = [128] _PREFILL_BATCHES_EXTENDED = [128, 512, 2048, 8192] -# Rows per expert at which a prefill TOPS target is physically reachable: the -# ceiling is ``2 * rows/E * weight_bandwidth``, so 256 rows/expert needs only -# ~195 GB/s for 100 TFLOPS -- unlike the 6.25 TB/s a batch of 128 would need. -# The batch is derived per model (``rows/E * E / top_k``): 4096 model tokens for -# Qwen3-MoE (128 experts), 6144 for MiniMax (192). -_PREFILL_TARGET_ROWS_PER_EXPERT = 256 +# Rows per expert at which the prefill TFLOPS target is physically reachable. +# +# This was 256, chosen from the weights-only roofline (``2 * rows/E * +# weight_bandwidth``, i.e. "256 rows/expert needs only ~195 GB/s"). Counting +# every stream the call moves (see ``_traffic_bytes``) puts the real +# requirement at 374-423 GB/s, and 423 GB/s -- the qwen3 down-projection, the +# shape with the smallest K and therefore the largest non-weight share -- is +# past what a 456 GB/s part delivers: its ceiling at 256 rows/expert is 94 +# TFLOPS, so the target was *unreachable at the batch it was being measured +# at*, whatever the kernel did. +# +# 384 is the smallest round value that clears 100 TFLOPS on every shipped shape +# with margin (ceilings 112 / 130 / 137 / 154 TFLOPS at a 400 GB/s probe; +# qwen3 down alone needs >= 290). The batch is derived per model +# (``rows/E * E / top_k``): 6144 model tokens for Qwen3-MoE (128 experts), +# 9216 for MiniMax (192). +_PREFILL_TARGET_ROWS_PER_EXPERT = 384 + + +# Rows per expert for the epilogue equivalence tests: enough to give every +# expert one *interior* tile and one *ragged* tile against the 256-row prefill +# tile, which is what makes a single launch exercise both epilogue paths. It is +# deliberately not tied to the perf batch above -- these tests need a specific +# tile geometry, not a compute-bound routing, and the smaller batch keeps them +# quick. +_RAGGED_TILE_ROWS_PER_EXPERT = 300 def _compute_bound_batches(model: dict) -> list: @@ -521,13 +541,14 @@ def _dtype_bytes(dtype) -> int: # * ``T * N * sizeof(out)`` -- the output # # Only the fourth line is what ``W GB/s`` reports, and it is a *minority* of -# the total whenever K is small: at 256 rows per expert the qwen3 -# down-projection (N = 2048, K = 768) moves 201 MB of weights inside 436 MB of +# the total whenever K is small: at 384 rows per expert the qwen3 +# down-projection (N = 2048, K = 768) moves 201 MB of weights inside 554 MB of # traffic, so a roofline built on weights alone understates the bandwidth a -# shape needs by more than 2x. This matters for the verdict, not just the -# bookkeeping: that shape needs ~423 GB/s to reach 100 TFLOPS, which is past -# what a 456 GB/s part delivers in practice, so it is bandwidth bound and no -# kernel change reaches the target there. +# shape needs by ~2.7x. This matters for the verdict, not just the bookkeeping: +# at the 256 rows per expert this harness used to measure at, the same shape +# needs 423 GB/s to reach 100 TFLOPS -- past what a 456 GB/s part delivers, so +# the target was unreachable there whatever the kernel did. See +# ``_PREFILL_TARGET_ROWS_PER_EXPERT``. # # The old model -- ``TFLOPS <= 2 * rows_per_expert * weight_bandwidth``, i.e. # ``BW@100T = 50 TB/s / rows_per_expert`` -- is the weights-only special case @@ -1288,10 +1309,12 @@ def test_perf_prefill_compute_bound(self, request): ``test_perf_prefill`` runs 128 model tokens, i.e. 8 rows per expert: at that routing the grouped GEMM only does 16 FLOPs per weight byte, so it is pinned to the DRAM roofline and no amount of - kernel work can push it to 100 TFLOPS. This case routes 4096 model - tokens (256 rows per expert), which needs only ~195 GB/s of weight - bandwidth for 100 TFLOPS and is therefore the shape the compute - target should actually be measured at. + kernel work can push it to 100 TFLOPS. This case routes enough + tokens to put ``_PREFILL_TARGET_ROWS_PER_EXPERT`` rows on every + expert, which is the smallest routing where 100 TFLOPS is under the + device's bandwidth ceiling for *all* the shipped shapes -- counting + the activation, quantized-activation and output streams, not just + the weights. """ rows = run_perf("prefill", None, torch_baseline=False, compute_bound=True, models=_models_option(request)) assert rows and all(r["w4a8_ms"] > 0 for r in rows) @@ -1378,7 +1401,7 @@ def test_perf_prefill_act_quant_single_pass_sweep(self, request): be quantized, so the two-pass kernel reads ``[T, K]``, reduces, then reads ``[T, K]`` again. The single-pass kernel keeps the row in registers across the reduction and deletes the second read -- - ~0.5 MB per expert at 256 rows and K = 2048, against 3.1 MB of + ~0.8 MB per expert at 384 rows and K = 2048, against 3.1 MB of weights for the whole GEMM. It costs ``K / 16`` elements of register pressure per lane (64 dwords of the default 128-dword budget at K = 2048), which is exactly the risk this sweep exists to @@ -1531,7 +1554,7 @@ def test_full_tile_epilogue_matches_predicated(self): small-batch case would leave every tile ragged and the test would pass without the fast path ever running. """ - rows_per_expert = _PREFILL_TARGET_ROWS_PER_EXPERT + 44 + rows_per_expert = _RAGGED_TILE_ROWS_PER_EXPERT case = _build_case( _QWEN3_NK[1][1], _QWEN3_NK[1][2], @@ -1616,7 +1639,7 @@ def test_prefill_2d_store_matches_scalar(self): the output, so anything spilling past an expert's last row lands in the comparison. """ - rows_per_expert = _PREFILL_TARGET_ROWS_PER_EXPERT + 44 + rows_per_expert = _RAGGED_TILE_ROWS_PER_EXPERT case = _build_case( _QWEN3_NK[1][1], _QWEN3_NK[1][2], From d7b09b11189e3a860f465a299e5527580c7bee77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:19:23 +0000 Subject: [PATCH 071/112] docs: correct the D-vs-weight byte figures for the 384 rows/expert batch Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 13 +++++++------ auto_round_extension/ark/test/README_MOE_W4A8.md | 8 ++++---- auto_round_extension/ark/test/README_MOE_W4A8_CN.md | 6 +++--- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index d29535050f..fccfeca760 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -287,8 +287,8 @@ inline DeviceScratchPool& expert_map_pool() { // ----------------------------------------------------------- // Batching the loads did not change how many there are. The absmax has to see // the whole row before the first element can be quantized, so the kernel reads -// `[T, K]`, reduces, then reads `[T, K]` again -- and at 256 rows per expert -// the activation matrix is 1.0 MB for K = 2048, against 3.1 MB of weights for +// `[T, K]`, reduces, then reads `[T, K]` again -- and at 384 rows per expert +// the activation matrix is 1.5 MB for K = 2048, against 3.1 MB of weights for // the whole GEMM. The re-read is L2-resident when the row is still there, but // the rows a work-group quantizes second are evicted by the ones it quantized // first well before the pass ends: at 8 MB of L2 and 4 KB per bf16 row of @@ -895,10 +895,11 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // `sycl_tla_dense_gemm.hpp` uses on this exact accumulator shape. // // D is the reason this is worth doing at prefill sizes rather than a tidy-up: -// at 256 rows per expert the qwen3 down-projection writes `M*N` fp16 (0.79 MB -// per expert) against `N*K` int8 of weights (1.5 MB) -- a third of the tile -// traffic -- because N (2048) is larger than K (768) there. It is the same -// shape whose mainloop is shortest, so it pays the epilogue twice. +// at 384 rows per expert the qwen3 down-projection writes `M*N` fp16 (1.5 MB +// per expert) -- exactly as many bytes as the `N*K` int8 weights it reads, +// because N (2048) is larger than K (768) there, and over a third of the +// expert's traffic. It is the same shape whose mainloop is shortest, so it +// pays the epilogue twice. // // The port follows `dense_gemm_detail::gemm_device_impl` rather than the // sibling MoE kernels, because those `reorder(tCrC, tCrC_out)` from the MMA diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index ec289791a4..2db0b2f22c 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -341,10 +341,10 @@ is what every sibling prefill kernel already uses for D `sycl_tla_dense_gemm.hpp` uses on this exact accumulator shape. D is why this is worth doing at prefill sizes rather than as a tidy-up: at 384 -rows per expert the qwen3 down-projection writes 0.79 MB of fp16 per expert -against 1.5 MB of int8 weights — a third of the tile traffic, because N (2048) -is larger than K (768) there — and it is the same shape whose mainloop is -shortest, so it pays the epilogue twice. +rows per expert the qwen3 down-projection writes 1.5 MB of fp16 per expert — +exactly as many bytes as the int8 weights it reads, because N (2048) is larger +than K (768) there, and over a third of that expert's traffic — and it is the +same shape whose mainloop is shortest, so it pays the epilogue twice. The port follows `dense_gemm_detail::gemm_device_impl` rather than the sibling MoE kernels. Those `reorder(tCrC, tCrC_out)` from the MMA fragment into an diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 0b1056e70f..dac4db171f 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -300,9 +300,9 @@ sub-group fragment 要发 **64** 条。通过硬件 2D block store,同样的 稠密 GEMM 在完全相同的累加器形状上采用的方式。 D 才是这件事在 prefill 尺寸下值得做、而不只是"顺手整理"的原因:每专家 384 行时, -qwen3 down-projection 每个专家要写 0.79 MB 的 fp16,而权重只有 1.5 MB 的 int8——占 -tile 总流量的三分之一,因为那里的 N (2048) 比 K (768) 更大;而它恰好又是主循环最短的 -形状,等于把 epilogue 的代价付了两遍。 +qwen3 down-projection 每个专家要写 1.5 MB 的 fp16——与它读入的 int8 权重字节数恰好相 +等,因为那里的 N (2048) 比 K (768) 更大,占该专家流量的三分之一以上;而它恰好又是主 +循环最短的形状,等于把 epilogue 的代价付了两遍。 移植参考的是 `dense_gemm_detail::gemm_device_impl`,而不是同族的 MoE kernel。后者用 `reorder(tCrC, tCrC_out)` 把 MMA fragment 搬进显式选定的 `XE_STORE_2D` atom 的 From 038f02edd32934b88077073ee231fcd8d144f185 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:18:44 +0000 Subject: [PATCH 072/112] perf: re-tune the W4A8 prefill tile ladder on the 384 rows/expert sweep Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 221 ++++++++++-------- .../ark/test/test_moe_w4a8_perf.py | 23 +- 2 files changed, 141 insertions(+), 103 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index fccfeca760..c7c57d9b32 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -1,33 +1,26 @@ // SYCL MoE W4A8 -- INT4 weights / INT8 compute (prefill + decode) // -// STATUS: PARTIALLY HARDWARE-VALIDATED -- `test_perf_prefill_tile_sweep`, -// `test_perf_prefill_act_quant_sweep`, `test_perf_prefill_epilogue_sweep`, -// `test_perf_decode_config_sweep`, `test_act_quant_vec_matches_scalar`, -// `test_full_tile_epilogue_matches_predicated` and -// `test_decode_ksplit_matches_legacy` have been run on an Intel Arc Pro B60 +// STATUS: PARTIALLY HARDWARE-VALIDATED -- every perf sweep in +// `test_moe_w4a8_perf.py` (`test_perf_prefill_tile_sweep`, +// `test_perf_prefill_act_quant_sweep`, `..._unroll_sweep`, +// `..._single_pass_sweep`, `test_perf_prefill_store_sweep`, +// `test_perf_prefill_epilogue_sweep`, `test_perf_decode_config_sweep`) and +// every cross-configuration equivalence test (`test_act_quant_vec_matches_- +// scalar`, `test_act_quant_unroll_matches`, `test_act_quant_single_pass_- +// matches`, `test_full_tile_epilogue_matches_predicated`, +// `test_prefill_2d_store_matches_scalar`, `test_decode_ksplit_matches_legacy`) +// has been run on an Intel Arc Pro B60 // (Battlemage, BMG-G21 -- 20 Xe2 cores / 160 XVEs at ~2.4 GHz, ~197 int8 TOPS, -// 24 GB GDDR6 at 456 GB/s), so both phases compile and run and every dispatch -// default (tile ladder, activation-quant message width, interior-tile epilogue, -// decode CH / NCOLS) comes from those -// measurements; every swept configuration also passed the cross-configuration -// equivalence check. The accuracy gates against the fp32 reference still need -// a device run. The authoring environment has no XPU and no SYCL compiler, so -// anything added since follows the porting conventions of its siblings +// 24 GB GDDR6 at 456 GB/s), so both phases compile and run and **every** +// dispatch default -- tile ladder, activation-quant message width / unroll / +// single-pass, interior-tile epilogue, 2D block store, decode CH / NCOLS -- +// comes from those measurements at the compute-bound batch (384 rows/expert), +// with each configuration checked numerically against the others before it was +// timed. The accuracy gates against the fp32 reference still need a device +// run. The authoring environment has no XPU and no SYCL compiler, so anything +// added *since* that run follows the porting conventions of its siblings // `sycl_tla_moe_prefill_int_dpas.hpp` / -// `sycl_tla_moe_prefill_fp8_dpas.hpp` -- currently the activation quantizer's -// batched loads (`ARK_MOE_W4A8_ACT_QUANT_UNROLL`), which are pure C++ around -// the same arithmetic and are swept by -// `test_perf_prefill_act_quant_unroll_sweep`; the register-resident -// single-pass activation quantizer -// (`ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS`, swept by -// `test_perf_prefill_act_quant_single_pass_sweep`), likewise; and the 2D block -// store epilogue (`ARK_MOE_W4A8_PREFILL_STORE_2D`, swept by -// `test_perf_prefill_store_sweep`), which is the D-side port of the -// `make_block_2d_copy_D` + `copy(copy_d, tCrD, tCgC)` sequence already -// compiled in `sycl_tla_dense_gemm.hpp` for the same accumulator and output -// widths. All three keep their predecessor one environment variable away and -// are covered by bit-identity tests, because none of them has been run on a -// device yet. +// `sycl_tla_moe_prefill_fp8_dpas.hpp`; nothing is currently in that state. // --------------------------------------------------------------------------- // // What this file implements @@ -235,13 +228,14 @@ inline DeviceScratchPool& expert_map_pool() { // allocations or the scratch pool) falls back to the scalar kernel, and // `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. // -// `test_perf_prefill_act_quant_sweep` on BMG (256 rows/expert, bf16 act) puts -// the widened messages at 1.13x (qwen3 up), 1.14x (qwen3 down), 1.12x (minimax +// `test_perf_prefill_act_quant_sweep` on BMG (384 rows/expert, bf16 act) puts +// the widened messages at 1.05x (qwen3 up), 1.13x (qwen3 down), 1.11x (minimax // up) and 1.04x (minimax down) of the scalar mapping on the *whole* // `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of -// prefill than that, since the GEMM around it is unchanged. (An earlier run of -// the same sweep read 1.14 / 1.07 / 1.10 / 1.07: the ranking is stable, the -// individual ratios move by a few percent between runs.) +// prefill than that, since the GEMM around it is unchanged. (Earlier runs of +// the same sweep read 1.13 / 1.14 / 1.12 / 1.04 and 1.14 / 1.07 / 1.10 / 1.07: +// the ranking is stable, the individual ratios move by a few percent between +// runs.) // // `sycl::vec` is used rather than `sycl::vec` // because `sycl::vec` of `bfloat16` is not universally available; the elements @@ -283,6 +277,12 @@ inline DeviceScratchPool& expert_map_pool() { // `test_act_quant_unroll_matches` asserts every depth is bit-identical and // `test_perf_prefill_act_quant_unroll_sweep` times them. // +// That sweep confirms the default: only minimax up is a real A/B in it (the +// other three shapes take the single-pass kernel below, where `UNROLL` is dead +// code, so their rows are three sets of identical kernels -- a useful noise +// probe, spreading 0.4-3.9%), and there `UNROLL = 4` is fastest: 8.959 ms +// against 8.967 ms at 2 and 9.139 ms at 1. +// // Reading the row once (the traffic the two passes duplicate) // ----------------------------------------------------------- // Batching the loads did not change how many there are. The absmax has to see @@ -312,9 +312,12 @@ inline DeviceScratchPool& expert_map_pool() { // partial maxima stay at four accumulators, as in the two-pass kernel, so the // reduction chain is unchanged in both cost and value. // -// This is a register-pressure gamble that the authoring environment cannot -// settle: if 64 dwords of row plus addressing spills, the pass gets slower, not -// faster. Hence `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` restores the two-pass +// This was a register-pressure gamble -- if 64 dwords of row plus addressing +// spilled, the pass would get slower, not faster -- and the sweep settled it in +// its favour: at 384 rows/expert the single-pass kernel is 1.05x (qwen3 down, +// K = 768) and 1.01x (qwen3 up, K = 2048, the rung filled exactly) against the +// two-pass one, and level on minimax down (K = 1536, 0.1% apart). Nothing +// spills. `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` still restores the two-pass // kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times the // pair, and `test_act_quant_single_pass_matches` asserts they agree bit for bit // (same `fmax` set, same `inv`, same `rint`/`clamp`). @@ -731,45 +734,53 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // // M*K * ceil(N/TileN) + N*K * ceil(M/TileM) ~= M*N*K * (1/TileN + 1/TileM) // -// i.e. A is re-read once per N tile and B once per M tile. Doubling `TileM` is -// the half of that argument which survives measurement: at 256 rows/expert it -// takes B from "read twice per expert" to "read once", i.e. -25% tile traffic, -// while leaving the per-lane C fragment at 32x32 (`SGLayout` grows to 8x4 with -// the tile). Doubling `TileN` does not: `SGLayout` stays 4 sub-groups wide, so -// `TileN = 256` gives every sub-group a 32x64 fragment -- 128 int32 -// accumulators per SIMD16 lane instead of 64 -- and that is where the register -// file stops holding the fragment plus the staged A/B tiles. -// -// `test_perf_prefill_tile_sweep` on BMG (256 rows/expert, bf16 act), re-run -// after the vectorized activation quantizer, the single-block epilogue and the -// interior-tile epilogue landed: -// -// shape 128x128 256x128 128x256 256x256 auto -// qwen3 up 2.774 ms 2.719 ms 2.809 ms 2.808 ms 2.815 ms -// qwen3 down 1.904 ms 1.829 ms 2.041 ms 2.058 ms 2.007 ms -// minimax up 5.584 ms 5.595 ms 5.694 ms 5.477 ms 5.674 ms -// minimax down 5.681 ms 5.605 ms 5.541 ms 5.662 ms 5.535 ms -// +// i.e. A is re-read once per N tile and B once per M tile. Both halvings are +// real, and both are cancelled by *padding*: an expert launches +// `ceil(M/TileM) * ceil(N/TileN)` full tiles, so a `TileM` that does not +// divide the rows/expert pays for rows that do not exist. +// +// `test_perf_prefill_tile_sweep` on BMG at the compute-bound batch the suite +// now runs (384 rows/expert, bf16 act), with the 2D block store and the +// single-pass activation quantizer in: +// +// shape 128x128 128x256 256x128 256x256 auto (was) +// qwen3 up 3.457 ms 3.507 ms 4.507 ms 3.952 ms 4.423 ms +// qwen3 down 2.512 ms 2.488 ms 2.777 ms 2.696 ms 2.933 ms +// minimax up 6.940 ms 6.978 ms 8.895 ms 8.111 ms 9.043 ms +// minimax down 7.255 ms 6.710 ms 9.166 ms 7.779 ms 9.211 ms +// +// M: the 256-row tile is 1.11-1.30x *behind* the 128-row one here, and that is +// arithmetic, not a register effect. 384 rows/expert take `ceil(384/256) = 2` +// 256-row tiles -- 512 rows scheduled for 384 rows of data, a third of the +// MACs spent on padding -- against exactly 3 full 128-row tiles. The measured +// ratio (1.30x on qwen3 up, 1.28x on minimax up, both long-K shapes where the +// mainloop dominates) is the padding ratio 512/384 = 1.33 to within noise. +// It is *not* an argument against `TileM = 256` as such: the earlier run at +// 256 rows/expert, where the tile divides the rows exactly, had it 1.3-3.9% +// ahead on all four shapes. The ladder therefore gates it on padding rather +// than on a row threshold (see `moe_w4a8_prefill_dispatch`). +// +// N: the 256-wide tile is now ahead or level everywhere the table can compare +// it -- at `TileM = 128` it takes minimax down by 1.08x and ties the other +// three (within 1.4% either way), and at `TileM = 256` it takes all four, by +// 2.9% to 15.1%. // The 35-50% cliff the first sweep saw on every 256-wide N tile is gone -- it // was the float C shadow the mainloop used to keep live (see `xe_gemm_w4a8`), // which doubled the per-lane C footprint and made `TileN = 256` ask for the -// entire 256-register large-GRF file. What is left in N is a 0-8% deficit on -// three of the four shapes, so the ladder still stays 128 wide: the one row -// where `256x256` leads (minimax up, by 1.9% over `256x128`) is inside the -// noise band below. -// -// In M the 256-row tile is now at least as fast as `128x128` on **all four** -// shapes -- +2.0% (qwen3 up), +3.9% (qwen3 down), +1.3% (minimax down) and -// -0.2% (minimax up) -- which is why the rung is no longer gated on `N >= K`. -// The earlier run that produced that gate had the up-projections at -2.4% / -// +0.2%; the disagreement is the measurement noise, not the shapes. The same -// run puts the ladder's own choice up to 9% away from the explicit row it -// resolves to (qwen3 down: 2.007 ms "auto" vs 1.829 ms "256x128" -- the same -// kernel), which bounds run-to-run spread at ~5-9% and is why the rung is -// taken on the sign of the effect across shapes rather than on any single -// shape's margin. Every policy stays reachable through -// `ARK_MOE_W4A8_PREFILL_TILE` for a re-sweep on a device with a different -// register budget. +// entire 256-register large-GRF file -- and what remained of it in the second +// sweep (0-8% behind on three shapes, measured with the *scalar* epilogue +// store) is gone too, now that a 32x64 fragment goes out in a handful of block +// messages instead of 128 scalar ones. So the ladder is 256 wide in N wherever +// N divides into it. +// +// Noise floor for reading all of this: `test_perf_prefill_act_quant_unroll_- +// sweep` in the same run times three shapes whose K puts them on the +// single-pass quantizer, where `UNROLL` is dead code -- i.e. three sets of +// *identical* kernels -- and they spread 0.4% / 2.1% / 3.9%. Anything under +// ~4% here is run-to-run variation; the padding effect above is 11-30%. +// +// Every policy stays reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a +// re-sweep on a device with a different register budget. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: @@ -875,12 +886,15 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // these shapes. A 128x128 tile runs `K / 64` k-tiles -- 12 of them for the // qwen3 down-projection (K = 768) -- while it always writes `TileM * TileN` // elements, and qwen3 down is exactly the shape the sweep reports furthest -// from the compute target (50 TFLOPS against 73-82 for the other three). The +// from the compute target (62 TFLOPS against 89-104 for the other three). The // fast path emits the same expression in the same order for every element it // stores, so it is bit-identical to the guarded one // (`test_full_tile_epilogue_matches_predicated`), and // `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` forces the guarded path for A/B -// measurement. +// measurement. `test_perf_prefill_epilogue_sweep` at 384 rows/expert has it at +// 1.08x (qwen3 down) and 1.02x (minimax up), level on the other two -- the +// shape ordering the instruction-count argument predicted, with the gain +// concentrated where the mainloop is shortest. // // The store itself: one 2D block message instead of `size(tCrC)` scalar ones // ----------------------------------------------------------------------- @@ -922,7 +936,10 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // edge tiles. The value written is computed by the same expression in the same // order as the scalar path, so the two are bit-identical // (`test_prefill_2d_store_matches_scalar`); `ARK_MOE_W4A8_PREFILL_STORE_2D=0` -// restores the scalar store for A/B measurement. +// restores the scalar store for A/B measurement. `test_perf_prefill_store_- +// sweep` at 384 rows/expert makes it the largest single prefill win of the +// set: 1.16x (qwen3 up), 1.35x (qwen3 down -- the shape that pays the epilogue +// twice), 1.12x (minimax up) and 1.20x (minimax down). // // The block 2D descriptor wants a 64-byte aligned base and a row pitch that is // a multiple of 16 bytes. The base here is the expert's slice @@ -1307,23 +1324,31 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // // The rungs match the tile ladder of `launch_igemm_kblock` in // `sycl_tla_s8_gemm.hpp`: a grouped GEMM's M is *per expert*, so the ladder -// walks the average rows/expert rather than the total token count. It stays -// 128 wide in N at every rung -- the on-hardware sweep (see the tile-policy -// comment above) never measures a 256-wide N tile ahead. -// -// The top rung is the 256-row tile, which halves how often each expert's -// weights are pulled through L2/DRAM (B is read once per M tile). It is taken -// on the single condition the sweep supports: -// -// * `A_avg_M >= 256` -- below that the tile is half empty and the doubled -// `TileM` buys nothing but padding. -// -// The rung used to carry a second condition, `N >= K` (the down-projection -// regime), because the first sweep measured the up-projections 0.2% faster and -// 2.4% slower with it. The re-run after the epilogue work (see the tile-policy -// comment above) has the 256-row tile ahead on three of four shapes and level -// on the fourth, with the disagreement smaller than the ~5-9% run-to-run -// spread the same table shows -- so the gate was measuring noise and is gone. +// walks the average rows/expert rather than the total token count. +// +// Both the M and the N rung are gated on the same thing -- that the wider tile +// does not schedule work the shape does not have: +// +// * `TileM = 256` halves how often each expert's B panel is pulled through +// L2/DRAM (B is read once per M tile), but an expert launches +// `ceil(M / TileM)` *full* tiles. It is taken only where that rounds to the +// same row count the 128-row tile would launch -- +// `ceil(M/256)*256 == ceil(M/128)*128`, true at M = 256, 512, 400, ... and +// false at M = 384, where the 256-row tile computes 512 rows for 384 rows +// of data. The sweep in the tile-policy comment measures exactly that +// third of wasted MACs (1.26-1.37x in favour of `TileM = 128` at M = 384). +// `A_avg_M >= 256` is implied by the equality but kept for readability. +// +// * `TileN = 256` halves how often A is re-read (once per N tile) and is +// ahead or level everywhere the sweep can compare it, so it is taken +// whenever N divides into it exactly. `N % 256 != 0` would pad the last +// tile the same way a ragged M does, and no shipped shape needs it: every +// N here (1536 / 2048 / 3072) is a multiple of 256. +// +// The rung used to be `A_avg_M >= 256 -> 256x128` with no padding test and a +// 128-wide N at every rung, which is what made the 384 rows/expert batch -- +// the compute-bound batch the perf suite now runs -- land on the slowest +// column of its own sweep. // // `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile // (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything @@ -1353,6 +1378,12 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* compat::set_default_queue(*q); const int A_avg_M = total_tokens / E; + // Rows a whole expert actually schedules at each candidate `TileM`. The + // 256-row tile only pays where it does not launch more rows than the 128-row + // one would: see the tile-policy comment for the measurement. + const auto padded_rows = [](int m, int tile) { return (m + tile - 1) / tile * tile; }; + const bool tile_m_256 = A_avg_M >= 256 && padded_rows(A_avg_M, 256) == padded_rows(A_avg_M, 128); + const bool tile_n_256 = (N % 256) == 0; const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); const bool store_2d_aligned = (static_cast(N) * sizeof(ElementD)) % 64 == 0 && reinterpret_cast(outputs) % 64 == 0; @@ -1392,8 +1423,14 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) } else if (A_avg_M < 128) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - } else if (A_avg_M >= 256) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) + } else if (tile_m_256) { + if (tile_n_256) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) + } else { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) + } + } else if (tile_n_256) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) } else { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) } diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 216f51bd05..1a7ba1c401 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -369,8 +369,9 @@ def _models_option(request): # Rows per expert for the epilogue equivalence tests: enough to give every -# expert one *interior* tile and one *ragged* tile against the 256-row prefill -# tile, which is what makes a single launch exercise both epilogue paths. It is +# expert interior *and* ragged tiles against the prefill tile the ladder picks +# for this shape (128 rows: 300 is two full tiles plus a 44-row remainder), +# which is what makes a single launch exercise both epilogue paths. It is # deliberately not tied to the perf batch above -- these tests need a specific # tile geometry, not a compute-bound routing, and the smaller batch keeps them # quick. @@ -1548,11 +1549,11 @@ def test_full_tile_epilogue_matches_predicated(self): multiplies and their order are untouched, so the results must be bit-identical, not merely close. - The shape matters: the batch puts 300 rows on every expert against - a 256-row tile, so each expert has one interior tile *and* one - ragged tile and a single launch exercises both paths. A - small-batch case would leave every tile ragged and the test would - pass without the fast path ever running. + The shape matters: the batch puts 300 rows on every expert, which + the tile ladder resolves to a 128-row tile, so each expert has two + interior tiles *and* one ragged 44-row tile and a single launch + exercises both paths. A small-batch case would leave every tile + ragged and the test would pass without the fast path ever running. """ rows_per_expert = _RAGGED_TILE_ROWS_PER_EXPERT case = _build_case( @@ -1634,10 +1635,10 @@ def test_prefill_2d_store_matches_scalar(self): to the next expert -- which is silent corruption, not a crash. The batch is therefore the one from the interior-tile test: 300 - rows on every expert against a 256-row tile gives each expert one - interior tile and one ragged tile, and the experts are adjacent in - the output, so anything spilling past an expert's last row lands in - the comparison. + rows on every expert against the ladder's 128-row tile gives each + expert two interior tiles and one ragged one, and the experts are + adjacent in the output, so anything spilling past an expert's last + row lands in the comparison. """ rows_per_expert = _RAGGED_TILE_ROWS_PER_EXPERT case = _build_case( From a561aa0ed93d761d378572d8d6677dd83a8891af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:24:39 +0000 Subject: [PATCH 073/112] docs: refresh the W4A8 prefill tuning tables from the 384 rows/expert sweep Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 245 ++++++++++-------- .../ark/test/README_MOE_W4A8_CN.md | 202 +++++++++------ 2 files changed, 259 insertions(+), 188 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 2db0b2f22c..26995e08cf 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -299,8 +299,9 @@ With `grf_size<256>` a lane has 256 registers, so at `128x128` the float shadow alone reserved a quarter of the register file for the whole mainloop, and at `128x256` the two fragments together *are* the register file — leaving nothing for the staged A/B tiles. That was the 35–50% penalty the 256-wide tiles used to -pay in [Tuned defaults](#tuned-defaults-measured); with the shadow gone the whole -tile table fits in a 0–8% band. At the default re-scale block the shadow was pure +pay in [Tuned defaults](#tuned-defaults-measured); with the shadow gone — and +with the epilogue's scalar store gone after it — the 256-wide tiles are now the +ladder's default choice. At the default re-scale block the shadow was pure overhead: `blks == 1` (the AUTO_S8 `group=-1` default) has nothing to carry across blocks, so the scale can be folded on the way out instead. That path now runs without the float fragment, and applies @@ -581,100 +582,140 @@ B60, cache the layers that are prefill-bound and leave the rest on ## Tuned defaults (measured) -The defaults below come from one `-k sweep` run on the Arc Pro B60 above (bf16 -activations, 8 routed rows for decode, 256 rows/expert for prefill). Every -configuration is checked for numerical equivalence with the first one before it -is timed. +Every default below comes from `-k sweep` runs on the Arc Pro B60 above (bf16 +activations, 8 routed rows for decode, **384 rows/expert** for prefill — the +compute-bound batch the suite uses). Each configuration is checked for numerical +equivalence with the first one before it is timed. -Two defaults are *not* in this section because they have not been measured yet: -`ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` and `ARK_MOE_W4A8_PREFILL_STORE_2D` are -both on, on reasoning alone. `test_perf_prefill_act_quant_single_pass_sweep` and -`test_perf_prefill_store_sweep` are the runs that fill them in; see -[Status](#status). +Nothing in the prefill path is unmeasured any more: the single-pass quantizer +and the 2D block store, which used to be on by reasoning alone, both have their +own tables below. + +**Reading the tables — the noise floor is ~4%.** The unroll sweep times three +shapes whose K puts them on the single-pass quantizer, where `UNROLL` is dead +code, so those rows are three sets of *identical* kernels: they spread 0.4%, +2.1% and 3.9%. Treat anything below ~4% as run-to-run variation. ### Prefill tile -| shape | `128x128` | `256x128` | `128x256` | `256x256` | +| shape | `128x128` | `128x256` | `256x128` | `256x256` | |---|---|---|---|---| -| qwen3 up | 2.774 ms | **2.719 ms** | 2.809 ms | 2.808 ms | -| qwen3 down | 1.904 ms | **1.829 ms** | 2.041 ms | 2.058 ms | -| minimax up | 5.584 ms | 5.595 ms | 5.694 ms | **5.477 ms** | -| minimax down | 5.681 ms | 5.605 ms | **5.541 ms** | 5.662 ms | - -The spread across the whole table is now 0–8%. It used to be 35–50%, and the -cliff was not the tile shape: the mainloop kept a float shadow of the C fragment -live next to the int32 one, so `TileN = 256` asked for 128 + 128 registers per -SIMD16 lane — the entire large-GRF file. With the shadow gone (see -[Prefill: message width and register pressure](#prefill-message-width-and-register-pressure)) -the 256-wide N tiles are merely a few percent behind, which is what a 32×64 C -fragment per sub-group (`SGLayout` is 4 wide in every policy) costs in -occupancy rather than in spills. - -`TileM = 256` is the direction that pays. It keeps the per-lane fragment at -32×32 because `SGLayout` grows to 8×4 instead, while halving how often the -expert's B panel is re-read: per-expert tile traffic is -`M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`. It is now at least as fast as `128x128` on -**all four** shapes — +2.0% (qwen3 up), +3.9% (qwen3 down), +1.3% (minimax -down), −0.2% (minimax up). - -The ladder therefore is: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, -`≥ 256` → **`256x128`**, otherwise `128x128`. All six policies stay compiled and -selectable with `ARK_MOE_W4A8_PREFILL_TILE`. - -The `≥ 256` rung used to carry a second condition, `N ≥ K`, because the first -sweep had the two up-projections at +0.2% / −2.4%. That gate is gone: this run -disagrees with it by more than the noise floor below, so it was fitting a -measurement rather than the hardware. - -Two caveats on reading the table. "tile auto" and the explicit row it resolves -to run the same kernel yet differ by up to 9% (2.007 vs 1.829 ms on qwen3 -down), so anything under ~5–9% here is inside run-to-run noise — including the -one row where `256x256` leads. The rung is therefore taken on the sign of the -effect across shapes, not on any single shape's margin. And the ladder's -rows/expert threshold is compared against `total_tokens / E`, the *average*, so -a skewed routing that averages 256 rows can still leave many experts with ragged -256-row tiles. - -At the chosen tile the swept shapes reach 75.8 / 56.4 / 82.9 / 82.8 TFLOPS -(qwen3 up / down, minimax up / down) — 39% / 29% / 42% / 42% of the B60's -~197 TOPS of int8 peak, up from 61.1 / 39.4 / 68.8 / 65.3 before the C-shadow -removal and the vectorized activation quantizer, but qwen3 down is still short -of the 100 TFLOPS target (51% of peak): at `K = 768` a tile runs only 12 -k-tiles, so the epilogue and the prologue are a large share of it. +| qwen3 up | **3.457 ms** | 3.507 ms | 4.507 ms | 3.952 ms | +| qwen3 down | 2.512 ms | **2.488 ms** | 2.777 ms | 2.696 ms | +| minimax up | **6.940 ms** | 6.978 ms | 8.895 ms | 8.111 ms | +| minimax down | 7.255 ms | **6.710 ms** | 9.166 ms | 7.779 ms | + +**M is about padding, not registers.** An expert launches `ceil(M / TileM)` +*full* tiles, so at 384 rows/expert the 256-row tile schedules 512 rows for 384 +rows of data — a third of the MACs spent on nothing — against exactly three +128-row tiles. That is the whole 1.11–1.30× deficit in the `256x*` columns: on +the two long-K shapes, where the mainloop dominates, the measured ratio (1.30× +qwen3 up, 1.28× minimax up) *is* the padding ratio 512/384 = 1.33 to within +noise. It is not an argument against `TileM = 256` — the earlier run at 256 +rows/expert, where the tile divides the rows exactly, had it 1.3–3.9% ahead on +all four shapes. + +So the rung is gated on padding rather than on a row count: take the 256-row +tile only where `ceil(M/256)·256 == ceil(M/128)·128`, i.e. where it launches no +more rows than the 128-row tile would. True at 256, 400, 512, 2048; false at +384, which is exactly the batch that used to fall off the cliff. + +**N is now free.** The 256-wide tile is ahead or level everywhere the table can +compare it: at `TileM = 128` it takes minimax down by 1.08× and ties the other +three (within 1.4% either way), and at `TileM = 256` it takes all four, by +2.9–15.1%. The 35–50% cliff the first sweep saw on 256-wide N tiles was the +float C shadow (see +[Prefill: message width and register pressure](#prefill-message-width-and-register-pressure)); +what was left of it in the second sweep — 0–8% behind on three shapes — was +measured with the *scalar* epilogue store, and it disappears now that a 32×64 +fragment goes out in a handful of block messages instead of 128 scalar ones. So +the ladder is 256 wide wherever `N % 256 == 0` (every shipped N: 1536 / 2048 / +3072), and 128 wide otherwise, where the wider tile would only pad. + +The ladder therefore is: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, then +`256x256` / `256x128` when the 256-row tile costs no extra padding and +`128x256` / `128x128` when it does — the N choice being `N % 256 == 0` in both +cases. All six policies stay compiled and selectable with +`ARK_MOE_W4A8_PREFILL_TILE`. + +At the ladder's choice the four swept shapes land on 3.507 / 2.488 / 6.978 / +6.710 ms — 88.2 / 62.1 / 99.7 / 103.7 TFLOPS, against 69.9 / 52.7 / 76.9 / 75.5 +for the ladder as it was (1.26× / 1.18× / 1.30× / 1.37×), and within 1.4% of the +fastest tile on every shape. Three of the four now clear the 100 TFLOPS target +or sit within 1%; qwen3 down is still the outlier at 62 TFLOPS, because at +`K = 768` a tile runs only 12 k-tiles and the epilogue and prologue are a large +share of it. + +One caveat that has not changed: the ladder compares `total_tokens / E`, the +*average* rows/expert, so a skewed routing that averages 384 can still leave +individual experts with very different tile counts. ### Prefill activation quantization | shape | scalar | vectorized (default) | speedup | |---|---|---|---| -| qwen3 up | 3.040 ms | **2.699 ms** | 1.13× | -| qwen3 down | 2.088 ms | **1.824 ms** | 1.14× | -| minimax up | 6.269 ms | **5.623 ms** | 1.12× | -| minimax down | 5.965 ms | **5.716 ms** | 1.04× | +| qwen3 up | 4.806 ms | **4.574 ms** | 1.05× | +| qwen3 down | 3.120 ms | **2.769 ms** | 1.13× | +| minimax up | 9.959 ms | **8.981 ms** | 1.11× | +| minimax down | 9.581 ms | **9.221 ms** | 1.04× | Quantizing the routed activations is a streaming pass over `[T, K]` next to a -GEMM that already moves ~400 MB, and it is worth 4–14% of the whole call purely +GEMM that already moves ~400 MB, and it is worth 4–13% of the whole call purely by issuing 256-byte loads and 128-byte stores instead of 32-byte and 16-byte ones. `ARK_MOE_W4A8_ACT_QUANT_VEC=0` restores the scalar mapping. How many of those wide loads a work-item keeps *outstanding* is the separate -knob `ARK_MOE_W4A8_ACT_QUANT_UNROLL` (1 = the mapping as measured above, 2, or -4 = default); `test_perf_prefill_act_quant_unroll_sweep` times it and the row -above is its `unroll 1` baseline. +knob `ARK_MOE_W4A8_ACT_QUANT_UNROLL` (1, 2, or 4 = default). Only minimax up is +a real A/B for it — the other three shapes take the single-pass kernel below, +where `UNROLL` is dead code — and there the default wins: **8.959 ms** at 4, +8.967 ms at 2, 9.139 ms at 1. + +### Prefill single-pass activation quantization + +| shape | K | two-pass | single-pass (default) | speedup | +|---|---|---|---|---| +| qwen3 up | 2048 | 4.455 ms | **4.416 ms** | 1.01× | +| qwen3 down | 768 | 2.836 ms | **2.694 ms** | 1.05× | +| minimax down | 1536 | **9.232 ms** | 9.244 ms | 1.00× | +| minimax up | 3072 | 8.991 ms | 8.962 ms | — (not eligible) | + +This was the one change with real downside risk: the row is held in registers +between the absmax and the quantize pass, and a spill would have made the pass +slower rather than faster. It does not spill. minimax up's `K = 3072` is past +the 16-vector rung, so both of its rows run the same two-pass kernel and their +0.3% gap is noise. + +### Prefill store + +| shape | scalar store | 2D block store (default) | speedup | +|---|---|---|---| +| qwen3 up | 5.111 ms | **4.388 ms** | 1.16× | +| qwen3 down | 3.603 ms | **2.669 ms** | 1.35× | +| minimax up | 9.604 ms | **8.570 ms** | 1.12× | +| minimax down | 10.837 ms | **9.048 ms** | 1.20× | + +The largest single prefill win of the set, and it is the epilogue rather than +the mainloop: a 32×32 sub-group fragment goes out in a handful of block messages +instead of 64 half-cache-line scalar ones. The ordering follows the argument — +qwen3 down, whose 12-k-tile mainloop amortizes the epilogue least and whose D is +as large as its weights, gains the most. ### Prefill epilogue guard | shape | guarded | interior-tile (default) | speedup | |---|---|---|---| -| qwen3 up | 2.774 ms | **2.634 ms** | 1.05× | -| qwen3 down | 2.214 ms | **1.924 ms** | 1.15× | -| minimax up | 5.884 ms | **5.785 ms** | 1.02× | -| minimax down | 5.968 ms | **5.700 ms** | 1.05× | +| qwen3 up | **4.428 ms** | 4.466 ms | 0.99× | +| qwen3 down | 2.840 ms | **2.625 ms** | 1.08× | +| minimax up | 8.971 ms | **8.794 ms** | 1.02× | +| minimax down | **8.990 ms** | 9.020 ms | 1.00× | The mainloop is identical in both columns; only the store differs, so this is the cost of ~4 instructions per output element. It is largest exactly where the mainloop is shortest — qwen3 down runs 12 k-tiles per tile at `K = 768` — which -is the shape the ordering argument predicted. `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` -restores the guarded epilogue; the two are bit-identical. +is the shape the ordering argument predicted, and the two rows that come out +behind do so by 0.9% and 0.3%, inside the noise floor. +`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` restores the guarded epilogue; the two are +bit-identical. ### Decode chunk width and column blocking @@ -705,12 +746,12 @@ nothing else, so the remaining gap is message efficiency, not arithmetic. | `ARK_MOE_W4A8_DECODE_KSPLIT` | Coalesced K-split decode mapping; **on by default**. Set to `0` to fall back to the original one-work-item-per-output GEMV (useful for A/B measurements). Ignored when the shape doesn't qualify. | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. `2` is the measured default, see [Tuned defaults](#tuned-defaults-measured). | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. Measured slower than `16` on every swept shape, so it is a sweep point rather than a recommendation. | -| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder, which picks `256x128` at ≥ 256 rows/expert and `128x128` otherwise (see [Tuned defaults](#tuned-defaults-measured)). The whole table is now within 0–8%, so this is a re-tuning knob rather than a cliff. | -| `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.04–1.14× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | -| `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized mapping. | -| `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets). Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. Not yet measured on hardware — if the register array spills, this is a slowdown, and the sweep is what decides. | -| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth 1.02–1.15× on the swept shapes. The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | -| `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape). Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Not yet measured on hardware. | +| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, then the 256-row tile when it pads no further than the 128-row one would (`⌈M/256⌉·256 == ⌈M/128⌉·128`) and the 128-row tile otherwise, each 256 wide in N when `N % 256 == 0` (see [Tuned defaults](#tuned-defaults-measured)). Forcing a tile the ladder would not pick costs up to 1.30× at 384 rows/expert. | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.04–1.13× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | +| `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default, measured fastest). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized *two-pass* mapping: the single-pass kernel below issues the whole row at once and ignores this. | +| `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | +| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | +| `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. | ## Shape constraints @@ -731,45 +772,45 @@ failing. The W4A8 kernel is a new SYCL/CuTe port, marked `STATUS: PARTIALLY HARDWARE-VALIDATED` in -`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. The perf sweeps have -been run on an Intel Arc Pro B60 — that run is where the tile ladder, the -activation-quantization default, the interior-tile epilogue and the decode -`CH` / `NCOLS` defaults come from (see -[Tuned defaults](#tuned-defaults-measured)) — and every swept -configuration passed the cross-configuration equivalence check. -`test_act_quant_vec_matches_scalar`, `test_full_tile_epilogue_matches_predicated` -and `test_decode_ksplit_matches_legacy` pass on device, so the vectorized -activation quantizer, the interior-tile epilogue and the K-split decode mapping -are each checked against their predecessors as well as timed. +`auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp`. Every perf sweep has +been run on an Intel Arc Pro B60, and **every** dispatch default now comes from +those runs — the tile ladder, the activation quantizer's message width, unroll +depth and single-pass rung, the interior-tile epilogue, the 2D block store, and +the decode `CH` / `NCOLS` (see [Tuned defaults](#tuned-defaults-measured)). +Every swept configuration also passed the cross-configuration equivalence check, +and all six bit-identity tests — `test_act_quant_vec_matches_scalar`, +`test_act_quant_unroll_matches`, `test_act_quant_single_pass_matches`, +`test_full_tile_epilogue_matches_predicated`, +`test_prefill_2d_store_matches_scalar` and `test_decode_ksplit_matches_legacy` — +pass on device, so each optimization is checked against its predecessor as well +as timed. Still to run on device: the accuracy sweep against the fp32 reference, which will catch layout/scale bugs immediately. -Three prefill changes have been reasoned through but neither timed nor run, -because the authoring environment has no XPU and no SYCL compiler. Each keeps -its predecessor one environment variable away, and each has a bit-identity test -that runs on device: +Three prefill changes used to be listed here as reasoned-through but unmeasured, +because the authoring environment has no XPU and no SYCL compiler. All three +have now been timed, and all three kept their default: -| Change | Revert with | Device checks | +| Change | Revert with | Measured | |---|---|---| -| Activation quantizer's batched loads — `UNROLL` vectors in flight instead of one | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | `test_act_quant_unroll_matches`, `test_perf_prefill_act_quant_unroll_sweep` | -| Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | `test_act_quant_single_pass_matches`, `test_perf_prefill_act_quant_single_pass_sweep` | -| 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | `test_prefill_2d_store_matches_scalar`, `test_perf_prefill_store_sweep` | +| Activation quantizer's batched loads — `UNROLL` vectors in flight instead of one | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 1.02× at `UNROLL = 4` on the only shape that exercises it | +| Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.05×; the register-resident row does not spill | +| 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.12–1.35×, the largest single prefill win | -The 2D store was previously listed here as needing a device rather than a flag, -on the grounds that the sibling MoE kernels reach it through +The 2D store was previously listed as needing a device rather than a flag, on +the grounds that the sibling MoE kernels reach it through `partition_sg_fragment_S` + `reorder` and no sibling 2D-stores a *scaled int32* accumulator. That turned out to be the wrong reference: `reorder` moves registers and does not convert, so it could never have carried an int32→fp16 epilogue. `sycl_tla_dense_gemm.hpp` — in the same translation unit — already compiles the sequence that does (`make_block_2d_copy_D(mma, D)` + `make_tensor_like(tCrC)` + `copy(copy_d, tCrD, tCgC)`, with a 32-bit -accumulator and a 16-bit output), so the port is a pure-C++ change after all. +accumulator and a 16-bit output), so the port was a pure-C++ change after all. -The single-pass quantizer is the one with a real downside risk: if the -register-resident row spills, the pass gets slower rather than faster, and only -`test_perf_prefill_act_quant_single_pass_sweep` can say which. Flip the default -in `launch_act_dynamic_quant_vec_unroll` if it does. +The single-pass quantizer was the one with a real downside risk — a spilled +register-resident row makes the pass slower rather than faster — and the sweep +settled it in its favour on every shape that takes it. What the sweeps should be read against has also changed. The prefill roofline in this document used to count weight bytes only, which understated the bandwidth diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index dac4db171f..88d55c73c8 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -265,8 +265,9 @@ K (768)"两种情况下断言二者逐位相同。 在 `grf_size<256>` 下每个 lane 只有 256 个寄存器,因此 `128x128` 时仅 float 影子就 在整个主循环里占掉了四分之一的寄存器堆,而 `128x256` 时两份 fragment 加起来*就是* 整个寄存器堆——留给暂存 A/B tile 的空间为零。这正是 N 方向 256 的 tile 过去在 -[实测得到的默认值](#实测得到的默认值)中要多付 35–50% 的原因;去掉 float 影子之后, -整张 tile 表已经收敛到 0–8% 的区间内。而在默认重缩放 block 下它完全是白付的开销: +[实测得到的默认值](#实测得到的默认值)中要多付 35–50% 的原因;去掉 float 影子、随后又去 +掉 epilogue 的标量 store 之后,256 宽的 tile 现在已经成为阶梯的默认选择。而在默认重缩放 +block 下它完全是白付的开销: `blks == 1` (AUTO_S8 `group=-1` 默认值) 根本没有需要跨 block 携带的东西,scale 完全 可以在写出时再折进去。该路径现在不再分配 float fragment,而是一次遍历就应用 `scale_b[col] × scale_a[row]`——与参考的稠密 int8 GEMM 中 `AccumBlock == false` @@ -523,89 +524,120 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 ## 实测得到的默认值 -下面的默认值来自上文那块 Arc Pro B60 上的一次 `-k sweep` (bf16 激活,decode 为 8 条 -routed 行,prefill 为每专家 256 行)。每种配置在计时之前都会先与第一种配置做数值等价性 -检查。 +下面的每一项默认值都来自上文那块 Arc Pro B60 上的 `-k sweep` 运行 (bf16 激活,decode 为 +8 条 routed 行,prefill 为**每专家 384 行**,即测试套件所用的计算受限 batch)。每种配置在 +计时之前都会先与第一种配置做数值等价性检查。 -有两项默认值**不在**本节中,因为它们尚未被实测: -`ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` 与 `ARK_MOE_W4A8_PREFILL_STORE_2D` 都是仅凭推导 -就默认开启的。`test_perf_prefill_act_quant_single_pass_sweep` 与 -`test_perf_prefill_store_sweep` 正是用来补齐这两项数据的运行;参见[状态](#状态)。 +prefill 路径上已经没有任何未实测的默认值了:单遍激活量化与 2D block store 过去仅凭推导 +就默认开启,现在各自都有下面的实测表格。 + +**读表须知——噪声下限约为 4%。** unroll sweep 中有三个形状的 K 使其走到单遍量化 kernel, +而 `UNROLL` 在那条路径上是死代码,因此那些行是三组**完全相同**的 kernel:它们的离散度分 +别为 0.4%、2.1%、3.9%。低于约 4% 的差异都应视为运行间波动。 ### Prefill tile -| 形状 | `128x128` | `256x128` | `128x256` | `256x256` | +| 形状 | `128x128` | `128x256` | `256x128` | `256x256` | |---|---|---|---|---| -| qwen3 up | 2.774 ms | **2.719 ms** | 2.809 ms | 2.808 ms | -| qwen3 down | 1.904 ms | **1.829 ms** | 2.041 ms | 2.058 ms | -| minimax up | 5.584 ms | 5.595 ms | 5.694 ms | **5.477 ms** | -| minimax down | 5.681 ms | 5.605 ms | **5.541 ms** | 5.662 ms | - -现在整张表的差距只有 0–8%。它曾经是 35–50%,而那道悬崖的成因并不是 tile 形状:当时 -主循环在 int32 累加器之外还全程保持一份 C fragment 的 float 影子,于是 `TileN = 256` -需要每个 SIMD16 lane 128 + 128 个寄存器,即整个 large-GRF 寄存器堆。去掉这份影子之后 -(参见 [Prefill:访存消息宽度与寄存器压力](#prefill访存消息宽度与寄存器压力)),N 方向 -为 256 的 tile 只落后几个百分点——这正是每个 sub-group 32×64 的 C fragment -(所有 policy 的 `SGLayout` 在 N 方向都是 4) 在 occupancy 而非寄存器溢出上付出的代价。 - -真正有收益的方向是 `TileM = 256`:它把 `SGLayout` 变成 8×4,从而让每个 lane 的 -fragment 仍保持 32×32,同时把专家 B 面板的重复读取次数减半——每个专家的 tile 访存量 -为 `M·K·⌈N/TileN⌉ + N·K·⌈M/TileM⌉`。现在它在**全部四个**形状上都不慢于 -`128x128`——+2.0% (qwen3 up)、+3.9% (qwen3 down)、+1.3% (minimax down)、 -−0.2% (minimax up)。 - -因此 tile 阶梯为:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,`≥ 256` → -**`256x128`**,其余 → `128x128`。六个 policy 全部保留编译,并可通过 +| qwen3 up | **3.457 ms** | 3.507 ms | 4.507 ms | 3.952 ms | +| qwen3 down | 2.512 ms | **2.488 ms** | 2.777 ms | 2.696 ms | +| minimax up | **6.940 ms** | 6.978 ms | 8.895 ms | 8.111 ms | +| minimax down | 7.255 ms | **6.710 ms** | 9.166 ms | 7.779 ms | + +**M 方向的关键是 padding,不是寄存器。** 一个专家会启动 `ceil(M / TileM)` 个**完整** +tile,因此在每专家 384 行时,256 行的 tile 要为 384 行数据调度 512 行——三分之一的 MAC +被浪费掉——而 128 行的 tile 恰好是三个。`256x*` 两列 1.11–1.30× 的落后全部由此而来:在 +两个长 K 形状上 (主循环占主导),实测比值 (qwen3 up 1.30×、minimax up 1.28×) 在噪声范围 +内**就等于** padding 比 512/384 = 1.33。这并不是反对 `TileM = 256`——上一次每专家 256 +行 (tile 恰好整除行数) 的运行中,它在四个形状上都领先 1.3–3.9%。 + +所以这一档的判据从"行数阈值"改成了"padding":仅当 +`ceil(M/256)·256 == ceil(M/128)·128`,即 256 行的 tile 启动的行数不比 128 行的 tile 更多 +时,才取 256 行的 tile。M = 256、400、512、2048 时成立;M = 384 时不成立——而那正是过去 +掉下悬崖的那个 batch。 + +**N 方向现在没有代价了。** 只要表格能对比,256 宽的 tile 都不落后:在 `TileM = 128` 时 +它以 1.08× 拿下 minimax down,另外三个持平 (双向差异均在 1.4% 以内);在 `TileM = 256` +时它以 2.9–15.1% 拿下全部四个。第一次 sweep 在 256 宽 N tile 上看到的 35–50% 悬崖来自 +float C 影子 (参见 +[Prefill:访存消息宽度与寄存器压力](#prefill访存消息宽度与寄存器压力));第二次 sweep 中 +残留的部分——三个形状上落后 0–8%——是在 epilogue 仍用**标量** store 时测得的,而当一个 +32×64 的 fragment 改用少数几条 block 消息 (而非 128 条标量消息) 送出之后,它就消失了。 +因此只要 `N % 256 == 0` (所有已发布的 N:1536 / 2048 / 3072) 阶梯就取 256 宽,否则取 128 +宽——在那些形状上更宽的 tile 只会带来 padding。 + +于是完整的阶梯为:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,随后在 256 行 tile 不 +引入额外 padding 时取 `256x256` / `256x128`,否则取 `128x256` / `128x128`;两种情况下 N +的选择都由 `N % 256 == 0` 决定。六个 policy 全部保留编译,并可通过 `ARK_MOE_W4A8_PREFILL_TILE` 手动选择。 -`≥ 256` 这一档过去还附带第二个条件 `N ≥ K`,因为上一次 sweep 中两个 up 投影分别是 -+0.2% / −2.4%。这个条件现在已经去掉:本次测量与它的分歧超出了下面所说的噪声下限,说 -明它拟合的是一次测量结果,而不是硬件本身的行为。 - -读这张表时有两点需要注意。"tile auto" 与它实际解析到的那一列跑的是同一个 kernel,二 -者却相差最多 9% (qwen3 down 上 2.007 vs 1.829 ms),因此这里 ~5–9% 以内的差异都在运行 -间噪声范围内——`256x256` 领先的那一行也在其中。所以这一档是依据各形状上效果的**符 -号**来决定的,而不是依据某一个形状的具体幅度。另外,阶梯里的每专家行数阈值比较的是 -`total_tokens / E` 这个**平均值**,因此在路由不均衡时,即便平均为 256 行,仍可能有很 -多专家只有残缺的 256 行 tile。 +在阶梯选中的 tile 下,被扫描的四个形状达到 3.507 / 2.488 / 6.978 / 6.710 ms,即 +88.2 / 62.1 / 99.7 / 103.7 TFLOPS;而改动之前的阶梯为 69.9 / 52.7 / 76.9 / 75.5 +(1.26× / 1.18× / 1.30× / 1.37×),并且现在每个形状都在最快 tile 的 1.4% 以内。四个形状中 +有三个达到或接近 (1% 以内) 100 TFLOPS 的目标;qwen3 down 仍是例外,只有 62 TFLOPS—— +`K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此占了相当大的比例。 -在选中的 tile 下,被扫描的四个形状达到 75.8 / 56.4 / 82.9 / 82.8 TFLOPS -(qwen3 up / down、minimax up / down)——分别是 B60 那约 197 TOPS int8 峰值的 -39% / 29% / 42% / 42%,相比去掉 float 影子和引入向量化激活量化之前的 -61.1 / 39.4 / 68.8 / 65.3 有明显提升,但 qwen3 down 距离 100 TFLOPS (峰值的 51%) -的目标仍有差距:`K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此 -占了相当大的比例。 +有一点没有变:阶梯比较的是 `total_tokens / E` 这个**平均**每专家行数,因此在路由不均衡 +时,即便平均为 384 行,各个专家的 tile 数仍可能相差很大。 ### Prefill 激活量化 | 形状 | 标量 | 向量化 (默认) | 加速比 | |---|---|---|---| -| qwen3 up | 3.040 ms | **2.699 ms** | 1.13× | -| qwen3 down | 2.088 ms | **1.824 ms** | 1.14× | -| minimax up | 6.269 ms | **5.623 ms** | 1.12× | -| minimax down | 5.965 ms | **5.716 ms** | 1.04× | +| qwen3 up | 4.806 ms | **4.574 ms** | 1.05× | +| qwen3 down | 3.120 ms | **2.769 ms** | 1.13× | +| minimax up | 9.959 ms | **8.981 ms** | 1.11× | +| minimax down | 9.581 ms | **9.221 ms** | 1.04× | 量化 routed 激活只是对 `[T, K]` 的一次流式遍历,而与它并行的 GEMM 本身就要搬运约 -400 MB;仅仅把 32 字节 load / 16 字节 store 换成 256 字节 / 128 字节,就能带来整次调 -用 4–14% 的收益。`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射。 +400 MB;仅仅把 32 字节 load / 16 字节 store 换成 256 字节 / 128 字节,就能带来整次调用 +4–13% 的收益。`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射。 一个 work-item 能让多少条这样的宽 load 同时**在途**,则由另一个开关 -`ARK_MOE_W4A8_ACT_QUANT_UNROLL` 控制 (1 = 上表所测的映射、2、4 = 默认值); -`test_perf_prefill_act_quant_unroll_sweep` 会为它计时,上表就是其 `unroll 1` 基线。 +`ARK_MOE_W4A8_ACT_QUANT_UNROLL` 控制 (1、2、4 = 默认值)。只有 minimax up 是它真正的 +A/B——另外三个形状走的是下面的单遍 kernel,`UNROLL` 在那里是死代码——而在它上面默认值获 +胜:4 时 **8.959 ms**,2 时 8.967 ms,1 时 9.139 ms。 + +### Prefill 单遍激活量化 + +| 形状 | K | 两遍 | 单遍 (默认) | 加速比 | +|---|---|---|---|---| +| qwen3 up | 2048 | 4.455 ms | **4.416 ms** | 1.01× | +| qwen3 down | 768 | 2.836 ms | **2.694 ms** | 1.05× | +| minimax down | 1536 | **9.232 ms** | 9.244 ms | 1.00× | +| minimax up | 3072 | 8.991 ms | 8.962 ms | —(不适用该路径) | + +这是所有改动中唯一存在真实下行风险的一项:激活行在 absmax 与量化两遍之间保存在寄存器里, +一旦溢出,这一遍就会变慢而不是变快。实测没有溢出。minimax up 的 `K = 3072` 超过了 16 个 +向量的门限,因此它那两行跑的是同一个两遍 kernel,0.3% 的差异是噪声。 + +### Prefill store + +| 形状 | 标量 store | 2D block store (默认) | 加速比 | +|---|---|---|---| +| qwen3 up | 5.111 ms | **4.388 ms** | 1.16× | +| qwen3 down | 3.603 ms | **2.669 ms** | 1.35× | +| minimax up | 9.604 ms | **8.570 ms** | 1.12× | +| minimax down | 10.837 ms | **9.048 ms** | 1.20× | + +这是这组改动中 prefill 收益最大的一项,而且它出在 epilogue 而不是主循环:一个 32×32 的 +sub-group fragment 由少数几条 block 消息送出,取代了 64 条只有半条 cache line 的标量消 +息。名次也符合推理——qwen3 down 的主循环只有 12 个 k-tile、对 epilogue 的摊薄最少,且它 +的 D 与权重一样大,因此收益最大。 ### Prefill epilogue 边界保护 | 形状 | 带保护 | 内部 tile 快速路径 (默认) | 加速比 | |---|---|---|---| -| qwen3 up | 2.774 ms | **2.634 ms** | 1.05× | -| qwen3 down | 2.214 ms | **1.924 ms** | 1.15× | -| minimax up | 5.884 ms | **5.785 ms** | 1.02× | -| minimax down | 5.968 ms | **5.700 ms** | 1.05× | +| qwen3 up | **4.428 ms** | 4.466 ms | 0.99× | +| qwen3 down | 2.840 ms | **2.625 ms** | 1.08× | +| minimax up | 8.971 ms | **8.794 ms** | 1.02× | +| minimax down | **8.990 ms** | 9.020 ms | 1.00× | -两列的主循环完全相同,只有 store 不同,因此这就是每个输出元素约 4 条指令的代价。它在 -主循环最短的形状上占比最大——`K = 768` 的 qwen3 down 每个 tile 只跑 12 个 k-tile——正 -是当初按指令数推理所预期的那个形状。`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 可切回带保护 -的 epilogue;两者逐位相同。 +两列的主循环完全相同,只有 store 不同,因此这就是每个输出元素约 4 条指令的代价。它在主 +循环最短的形状上占比最大——`K = 768` 的 qwen3 down 每个 tile 只跑 12 个 k-tile——正是当 +初按指令数推理所预期的那个形状;而落后的那两行只差 0.9% 与 0.3%,在噪声下限之内。 +`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 可切回带保护的 epilogue;两者逐位相同。 ### Decode 的 chunk 宽度与列分块 @@ -634,12 +666,12 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_DECODE_KSPLIT` | 合并访存的 K-split decode 映射,**默认开启**。设为 `0` 可回退到原来每个输出一个 work-item 的 GEMV (便于 A/B 对比)。形状不满足条件时该开关无效。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。默认值 `2` 来自实测,参见[实测得到的默认值](#实测得到的默认值)。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。实测中它在所有形状上都慢于 `16`,因此只作为扫描项而非推荐值。 | -| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 ≥ 256 行时取 `256x128`,否则取 `128x128` (参见[实测得到的默认值](#实测得到的默认值))。现在整张表的差距已收敛到 0–8%,因此这个开关是重新调优用的旋钮,而不再对应一道性能悬崖。 | -| `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.04–1.14× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | -| `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化映射生效。 | -| `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个)。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。尚未在硬件上实测:如果寄存器数组发生溢出,这反而是减速,最终由扫描结果决定。 | -| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上带来 1.02–1.15× 的收益。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | -| `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。尚未在硬件上实测。 | +| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,随后在 256 行 tile 的 padding 不多于 128 行 tile 时 (`⌈M/256⌉·256 == ⌈M/128⌉·128`) 取 256 行 tile、否则取 128 行 tile;两种情况下只要 `N % 256 == 0` 就取 256 宽 (参见[实测得到的默认值](#实测得到的默认值))。在每专家 384 行时,强制选用阶梯不会选的 tile 最多会慢 1.30×。 | +| `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.04–1.13× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | +| `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认,实测最快)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化的**两遍**映射生效:下面的单遍 kernel 一次性发出整行,会忽略这个开关。 | +| `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | +| `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | +| `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。 | ## 形状约束 @@ -660,26 +692,26 @@ decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 `auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp` 中被标记为 `STATUS: PARTIALLY HARDWARE-VALIDATED`。各项性能扫描都已在一块 Intel Arc Pro B60 上 -跑过——tile 阶梯、激活量化的默认值、内部 tile 的 epilogue 以及 decode 的 -`CH` / `NCOLS` 默认值正是来自 -那些运行 (参见[实测得到的默认值](#实测得到的默认值)),并且所有被扫描的配置都通过了配 -置间的数值等价性检查。`test_act_quant_vec_matches_scalar`、 -`test_full_tile_epilogue_matches_predicated` 与 `test_decode_ksplit_matches_legacy` -也已在设备上通过,因此向量化激活量化、内部 tile 的 epilogue 与 K-split decode 映射既 -有计时数据,也都与各自的前身做过比对。 +跑过,并且**每一个** dispatch 默认值现在都来自那些运行——tile 阶梯、激活量化的访存消息 +宽度、unroll 深度与单遍那一档、内部 tile 的 epilogue、2D block store,以及 decode 的 +`CH` / `NCOLS` (参见[实测得到的默认值](#实测得到的默认值))。所有被扫描的配置也都通过了 +配置间的数值等价性检查;六项逐位一致性测试——`test_act_quant_vec_matches_scalar`、 +`test_act_quant_unroll_matches`、`test_act_quant_single_pass_matches`、 +`test_full_tile_epilogue_matches_predicated`、 +`test_prefill_2d_store_matches_scalar` 与 `test_decode_ksplit_matches_legacy`——也都已 +在设备上通过,因此每一项优化既有计时数据,也都与各自的前身做过比对。 仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描,它能立刻暴露 layout / scale 相关的 bug。 -有三项 prefill 改动只经过推导,既未实测计时也尚未在设备上运行,因为编写它们的环境既没 -有 XPU 也没有 SYCL 编译器。每一项都只隔着一个环境变量就能退回其前身,并且都配有可在设 -备上运行的逐位一致性测试: +本节此前列有三项"只经过推导、既未实测计时也尚未在设备上运行"的 prefill 改动,因为编写 +它们的环境既没有 XPU 也没有 SYCL 编译器。这三项现在都已实测,并且都保持了原有默认值: -| 改动 | 回退方式 | 设备上的检查 | +| 改动 | 回退方式 | 实测结果 | |---|---|---| -| 激活量化的批量 load——同时挂起 `UNROLL` 个请求而不是一个 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | `test_act_quant_unroll_matches`、`test_perf_prefill_act_quant_unroll_sweep` | -| 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | `test_act_quant_single_pass_matches`、`test_perf_prefill_act_quant_single_pass_sweep` | -| D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | `test_prefill_2d_store_matches_scalar`、`test_perf_prefill_store_sweep` | +| 激活量化的批量 load——同时挂起 `UNROLL` 个请求而不是一个 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 在唯一真正走这条路径的形状上,`UNROLL = 4` 快 1.02× | +| 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.05×;留在寄存器里的行并未溢出 | +| D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.12–1.35×,prefill 单项收益最大 | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** @@ -689,10 +721,8 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo + `make_tensor_like(tCrC)` + `copy(copy_d, tCrD, tCgC)`,且正是 32 位累加器 配 16 位输出),所以这项移植终究是纯 C++ 的改动。 -单遍量化 kernel 是其中唯一存在真实下行风险的一项:如果留在寄存器里的行数据发生溢出, -这一遍会变慢而不是变快,而只有 -`test_perf_prefill_act_quant_single_pass_sweep` 能给出结论。真是如此的话,把 -`launch_act_dynamic_quant_vec_unroll` 里的默认值翻转即可。 +单遍量化 kernel 是其中唯一存在真实下行风险的一项——留在寄存器里的行数据一旦溢出,这一 +遍就会变慢而不是变快——而扫描结果在所有走这条路径的形状上都支持保留它。 这些扫描该拿什么作为参照,也已经改变了。本文档中 prefill 的 roofline 此前只统计了权重 字节数,把这些形状真正需要的带宽低估了 1.7–2.2×,让一个天花板只有 94 TFLOPS 的形状看 From a1d2ab55898bde8004a9687a388a6cc07ccd1df2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:59:12 +0000 Subject: [PATCH 074/112] test: add 8K-prompt prefill case and tile sweep to W4A8 perf harness Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 91 +++++++++- .../ark/test/README_MOE_W4A8_CN.md | 79 ++++++++- .../ark/test/test_moe_w4a8_perf.py | 159 ++++++++++++++++-- 3 files changed, 306 insertions(+), 23 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 26995e08cf..5db19a9c65 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -147,6 +147,40 @@ it reaches, which is the part a kernel change can move. The verdict is informational by default; pass `--enforce-targets` to turn it into a hard assertion. +### The 8K-prompt point: a prompt is not a rows/expert target + +`_PREFILL_TARGET_ROWS_PER_EXPERT` is *derived* per model, so both shape groups +land on the same 384 rows per expert (6144 model tokens for Qwen3-MoE, 9216 for +MiniMax). A real prefill does the opposite: the prompt length is fixed and the +expert count divides it. `test_perf_prefill_long_seq` therefore runs a single +**8K-token prompt** — 8192 model tokens, 65536 routed rows, the same 8K group +`test_moe_prefill_perf.py` sweeps — and the two models land in *different* +regimes: + +| Shape | rows/expert | Weights | Total traffic | BW for 100 TFLOPS | Ceiling at 400 GB/s | Ceiling at 384 rows/E | +|---|---|---|---|---|---|---| +| qwen3 up (N=1536, K=2048) | 512 | 403 MB | 1141 MB | 277 GB/s | **145 TFLOPS** | 129 | +| qwen3 down (N=2048, K=768) | 512 | 201 MB | 671 MB | 326 GB/s | **123 TFLOPS** | 112 | +| minimax up (N=1536, K=3072) | 341 | 906 MB | 1913 MB | 309 GB/s | **129 TFLOPS** | 137 | +| minimax down (N=3072, K=1536) | 341 | 906 MB | 1711 MB | 277 GB/s | **145 TFLOPS** | 154 | + +For Qwen3-MoE's 128 experts an 8K prompt is 512 rows each, a third more than the +compute-bound batch, so its ceiling rises by 10–12% and the 100 TFLOPS target +gains margin — this is the point where the suite's highest prefill TFLOPS should +be. For MiniMax's 192 experts the same prompt is only 341 rows each, *below* the +compute-bound batch, so its ceiling falls by 6%. The same kernel therefore reads +faster on one model and slower on the other at the same prompt length, which is +the reason to measure both: throughput follows the routing, not the sequence +length. + +512 rows per expert also moves the **tile ladder**. The 256-row tile is gated on +padding no worse than the 128-row one (`⌈M/256⌉·256 == ⌈M/128⌉·128`) — false at +384, true at 512 — so the Qwen3-MoE shapes take the `256x256` rung here and +nowhere else in the suite. `test_perf_prefill_tile_sweep_long_seq` re-runs the +tile sweep at this routing for exactly that reason: the rung the ladder picks at +an 8K prompt has never been measured against its alternatives at a routing where +it does not pad (see [Prefill tile](#prefill-tile)). + ### Why `vs w4a16` is below 1.0 at small batches The same intensity argument explains the `vs w4a16` column. W4A8 streams **2× @@ -432,7 +466,10 @@ widens them to `{128, 512, 2048, 8192}` and `{1, 2, 8, 16}` respectively. gets 384 rows (6144 model tokens for Qwen3-MoE) — the smallest round sweep point where the 100 TFLOPS goal is under the device's bandwidth ceiling on *every* shipped shape, counting [all five streams](#the-weights-are-not-the-only-stream) -and not just the weights. +and not just the weights. `test_perf_prefill_long_seq` adds the other kind of +prefill point: a fixed **8K-token prompt** (8192 model tokens, 65536 routed +rows), which is 512 rows per expert for Qwen3-MoE and 341 for MiniMax — see +[The 8K-prompt point](#the-8k-prompt-point-a-prompt-is-not-a-rowsexpert-target). A second shape group covers MiniMax-M2, matching `test_moe_prefill_perf.py`: @@ -476,6 +513,9 @@ pytest -v -s test_moe_w4a8_perf.py -k decode # The compute-bound prefill case (6144 model tokens), where the TFLOPS goal is reachable pytest -v -s test_moe_w4a8_perf.py -k compute_bound +# The 8K-prompt prefill case (8192 model tokens) and its tile sweep +pytest -v -s test_moe_w4a8_perf.py -k long_seq + # Make the performance goals hard assertions instead of a printed verdict pytest -v -s test_moe_w4a8_perf.py -k perf --enforce-targets @@ -497,6 +537,12 @@ with the first one, and the table is followed by a `best configuration` block naming the winning environment variables per shape, so the tuning knobs can be settled in a single on-hardware run. +`test_perf_prefill_tile_sweep_long_seq` is the same tile sweep at the 8K-prompt +routing, where the ladder's rung differs (512 rows per expert for Qwen3-MoE +instead of 384); a shape swept at more than one batch gets one `best +configuration` line per batch, because the winner is a property of the routing +as much as of the shape. + The `-s` flag is required to see the printed tables. ### As a standalone script (no pytest) @@ -507,11 +553,15 @@ python test_moe_w4a8_perf.py --all-shapes # full sweep python test_moe_w4a8_perf.py --phase decode # decode only python test_moe_w4a8_perf.py --skip-accuracy # perf only python test_moe_w4a8_perf.py --compute-bound # add the 6144-token prefill case +python test_moe_w4a8_perf.py --long-seq # add the 8K-prompt prefill case python test_moe_w4a8_perf.py --dtype fp16 # fp16 activations python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 ``` +`--long-seq` also repeats the prefill tile sweep at the 8K prompt when combined +with `--sweep-configs`. + The script exits non-zero if any accuracy gate fails. ## Python API @@ -650,6 +700,15 @@ One caveat that has not changed: the ladder compares `total_tokens / E`, the *average* rows/expert, so a skewed routing that averages 384 can still leave individual experts with very different tile counts. +The table above is measured at 384 rows/expert, where the padding gate keeps the +256-row tile *out*, so the `256x*` columns there are the cost of padding rather +than a verdict on the tile. The routing that actually selects it is the 8K +prompt (512 rows/expert on Qwen3-MoE, an exact multiple of 256), and +`test_perf_prefill_tile_sweep_long_seq` is the sweep that measures the rung the +ladder picks there against its alternatives — the one open question left in the +tile ladder, since the only prior evidence for `TileM = 256` is the older run at +256 rows/expert (1.3–3.9% ahead on all four shapes). + ### Prefill activation quantization | shape | scalar | vectorized (default) | speedup | @@ -786,7 +845,11 @@ pass on device, so each optimization is checked against its predecessor as well as timed. Still to run on device: the accuracy sweep against the fp32 reference, which -will catch layout/scale bugs immediately. +will catch layout/scale bugs immediately, and the two 8K-prompt prefill cases +(`test_perf_prefill_long_seq`, `test_perf_prefill_tile_sweep_long_seq`) — they +add no new kernel code, only a routing the suite did not measure at, but the +`256x256` rung the ladder takes there has not been timed against its +alternatives at a non-padding routing. Three prefill changes used to be listed here as reasoned-through but unmeasured, because the authoring environment has no XPU and no SYCL compiler. All three @@ -821,6 +884,24 @@ of their true ceilings, and the compute-bound batch moved from 256 to 384 rows per expert so that 100 TFLOPS is reachable on all of them. The remaining gap is traffic, not arithmetic: the largest single win still on the table is fusing the activation quantization into the GEMM's A-tile load, which would delete the int8 -copy's write *and* read — 2 of the 5 streams, ~25% of the traffic on the -small-K shapes — but that is a mainloop change and wants a device to develop -against. +copy's write *and* read — 2 of the 5 streams, 14–22% of the traffic depending on +K — but that is a mainloop change and wants a device to develop against. + +### Where the remaining prefill headroom is + +At the ladder's choice the four compute-bound shapes read 88.2 / 62.1 / 99.7 / +103.7 TFLOPS, i.e. 56–73% of their bandwidth ceilings, so the headroom splits +into traffic the call still moves and ceiling the routing sets: + +| Lead | What it would change | Where it shows | +|---|---|---| +| Fusing the activation quantization into the GEMM's A-tile load | Deletes 2 of the 5 streams (the int8 copy written, then read back) — 14% of the traffic at `K = 768`, 21% at `K = 2048`, 22% at `K = 3072` | Every shape; it is the largest single item left | +| Routing more rows per expert | Nothing in the kernel — it *raises* the ceiling, because the weight stream is the only one that does not grow with the token count | The 8K prompt is exactly this experiment for Qwen3-MoE: 512 rows/expert lifts the ceilings from 129 / 112 to 145 / 123 TFLOPS | +| The `256x256` rung the 8K prompt selects | Halves how often B is re-read per M tile, on top of the N-tile saving already taken | `test_perf_prefill_tile_sweep_long_seq`; unmeasured at a non-padding routing since the 256 rows/expert run | +| A single-pass activation quantizer for `K = 3072` | The second read of `[T, K]`, ~450 MB at the compute-bound batch | minimax up only; its row is 96 dwords per lane, past the 16-vector rung | + +`qwen3 down` (`N = 2048, K = 768`) stays the outlier at ~62–69 TFLOPS: 12 +k-tiles per tile is the shortest mainloop of the four, its output is as large as +its weights, and its ceiling is the lowest of the set at every routing. It is +also the shape the traffic-side leads above would help most. + diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 88d55c73c8..a3ebede814 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -134,6 +134,35 @@ targets [prefill]: prefill compute > 100 TFLOPS 多少 — 这才是 kernel 改动能够撬动的部分。该结论默认只用于提示;加上 `--enforce-targets` 可以把它变成硬断言。 +### 8K 提示词用例:提示词长度不等于每专家行数目标 + +`_PREFILL_TARGET_ROWS_PER_EXPERT` 是**按模型推导**出来的,所以两组形状都落在同样的 +每专家 384 行上 (Qwen3-MoE 是 6144 个模型 token,MiniMax 是 9216)。真实的 prefill +恰好相反:提示词长度是固定的,由专家数去除它。因此 `test_perf_prefill_long_seq` 跑 +的是一条 **8K token 的提示词** — 8192 个模型 token、65536 条路由行,与 +`test_moe_prefill_perf.py` 扫描的 8K 组相同 — 而两个模型会落在**不同**的区间: + +| 形状 | 每专家行数 | 权重 | 总访存量 | 达到 100 TFLOPS 所需带宽 | 400 GB/s 下的上限 | 每专家 384 行时的上限 | +|---|---|---|---|---|---|---| +| qwen3 up (N=1536, K=2048) | 512 | 403 MB | 1141 MB | 277 GB/s | **145 TFLOPS** | 129 | +| qwen3 down (N=2048, K=768) | 512 | 201 MB | 671 MB | 326 GB/s | **123 TFLOPS** | 112 | +| minimax up (N=1536, K=3072) | 341 | 906 MB | 1913 MB | 309 GB/s | **129 TFLOPS** | 137 | +| minimax down (N=3072, K=1536) | 341 | 906 MB | 1711 MB | 277 GB/s | **145 TFLOPS** | 154 | + +对 Qwen3-MoE 的 128 个专家来说,8K 提示词是每专家 512 行,比计算受限 batch 多三分之 +一,所以上限提高 10–12%、100 TFLOPS 目标的余量更大 — 这里应当出现整个套件中最高的 +prefill TFLOPS。而对 MiniMax 的 192 个专家来说,同一条提示词只有每专家 341 行,**低 +于**计算受限 batch,上限反而下降 6%。也就是说同一个 kernel 在同样的提示词长度下, +在一个模型上更快、在另一个模型上更慢 — 这正是两个模型都要测的原因:决定吞吐的是路 +由,而不是序列长度。 + +每专家 512 行同时还会改变 **tile 阶梯**。256 行 tile 的门槛是它的 padding 不比 128 +行 tile 更差 (`⌈M/256⌉·256 == ⌈M/128⌉·128`) — 384 行时不成立、512 行时成立 — 因此 +Qwen3-MoE 的形状只有在这里才会走到 `256x256` 这一档。 +`test_perf_prefill_tile_sweep_long_seq` 正是为此在该路由下重跑 tile 扫描:8K 提示词 +下阶梯选中的这一档,从未在一个不产生 padding 的路由上与其它 tile 对比过 (见 +[Prefill tile](#prefill-tile))。 + ### 为什么小 batch 下 `vs w4a16` 小于 1.0 同样的计算强度分析也解释了 `vs w4a16` 这一列。W4A8 需要传输 int4 路径 **2 倍的权 @@ -383,7 +412,10 @@ qwen3 down (down-proj) : N = 2048, K = 768 `test_perf_prefill_compute_bound` 额外增加一个 batch,其大小保证每个专家拿到 384 行 (Qwen3-MoE 为 6144 个模型 token) — 这是在计入 [全部五条数据流](#权重并不是唯一的数据流)而不只是权重之后,100 TFLOPS 目标在*所有*已 -支持形状上都低于设备带宽天花板的最小整值扫描点。 +支持形状上都低于设备带宽天花板的最小整值扫描点。`test_perf_prefill_long_seq` 则补上 +另一类 prefill 采样点:一条固定长度的 **8K token 提示词** (8192 个模型 token、65536 +条路由行),对 Qwen3-MoE 是每专家 512 行、对 MiniMax 是 341 行 — 详见 +[8K 提示词用例](#8k-提示词用例提示词长度不等于每专家行数目标)。 第二个形状组是 MiniMax-M2,与 `test_moe_prefill_perf.py` 保持一致: @@ -425,6 +457,9 @@ pytest -v -s test_moe_w4a8_perf.py -k decode # 计算受限的 prefill 用例 (6144 个模型 token),TFLOPS 目标在此可达 pytest -v -s test_moe_w4a8_perf.py -k compute_bound +# 8K 提示词的 prefill 用例 (8192 个模型 token) 及其 tile 扫描 +pytest -v -s test_moe_w4a8_perf.py -k long_seq + # 把性能目标从提示信息变成硬断言 pytest -v -s test_moe_w4a8_perf.py -k perf --enforce-targets @@ -445,6 +480,11 @@ tile、激活量化的消息宽度与在途请求深度,以及 epilogue 的边 configuration`,按形状给出获胜配置对应的环境变量,因此在硬件上跑一次就能确定这些 调优开关。 +`test_perf_prefill_tile_sweep_long_seq` 是同一个 tile 扫描在 8K 提示词路由下的版本, +那里阶梯选中的档位不同 (Qwen3-MoE 是每专家 512 行而不是 384 行);同一形状若在多个 +batch 上被扫描,`best configuration` 会为每个 batch 各打印一行 — 因为最优配置既取决 +于形状,也同样取决于路由。 + 需要加 `-s` 才能看到打印出的表格。 ### 作为独立脚本运行 (不依赖 pytest) @@ -455,11 +495,15 @@ python test_moe_w4a8_perf.py --all-shapes # 完整扫描 python test_moe_w4a8_perf.py --phase decode # 仅 decode python test_moe_w4a8_perf.py --skip-accuracy # 仅性能 python test_moe_w4a8_perf.py --compute-bound # 追加 6144 token 的 prefill 用例 +python test_moe_w4a8_perf.py --long-seq # 追加 8K 提示词的 prefill 用例 python test_moe_w4a8_perf.py --dtype fp16 # fp16 激活 python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 ``` +`--long-seq` 与 `--sweep-configs` 一起使用时,还会在 8K 提示词下重跑一遍 prefill 的 +tile 扫描。 + 任何精度门限未通过时,脚本以非 0 状态码退出。 ## Python API @@ -580,6 +624,13 @@ float C 影子 (参见 有一点没有变:阶梯比较的是 `total_tokens / E` 这个**平均**每专家行数,因此在路由不均衡 时,即便平均为 384 行,各个专家的 tile 数仍可能相差很大。 +上表是在每专家 384 行下测得的,而 padding 判据在那里恰好把 256 行的 tile **挡在门外**, +所以那两列 `256x*` 反映的是 padding 的代价,而不是对该 tile 本身的结论。真正会选中它的 +路由是 8K 提示词 (Qwen3-MoE 每专家 512 行,正好是 256 的整数倍), +`test_perf_prefill_tile_sweep_long_seq` 就是在那里把阶梯选中的这一档与其它 tile 对比的 +扫描 — 这也是 tile 阶梯中仅剩的未决问题,因为 `TileM = 256` 目前唯一的证据来自更早那次 +每专家 256 行的运行 (四个形状上领先 1.3–3.9%)。 + ### Prefill 激活量化 | 形状 | 标量 | 向量化 (默认) | 加速比 | @@ -702,7 +753,10 @@ W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 在设备上通过,因此每一项优化既有计时数据,也都与各自的前身做过比对。 仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描,它能立刻暴露 layout / -scale 相关的 bug。 +scale 相关的 bug;以及两个 8K 提示词的 prefill 用例 +(`test_perf_prefill_long_seq`、`test_perf_prefill_tile_sweep_long_seq`) — 它们没有新增 +任何 kernel 代码,只是补上了套件此前没有测过的一种路由,但阶梯在那里选中的 `256x256` +一档,尚未在不产生 padding 的路由上与其它 tile 对比过。 本节此前列有三项"只经过推导、既未实测计时也尚未在设备上运行"的 prefill 改动,因为编写 它们的环境既没有 XPU 也没有 SYCL 编译器。这三项现在都已实测,并且都保持了原有默认值: @@ -730,5 +784,22 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo 之后,四个受算力约束的形状实际上跑在各自真实天花板的 60–74%,而受算力约束的 batch 也 从每专家 256 行提高到 384 行,好让 100 TFLOPS 在所有形状上都是可达的。剩下的差距在访 存而不是算术:目前仍摆在桌面上的最大一项收益,是把激活量化融合进 GEMM 的 A-tile 加载 -中,这将同时消掉 int8 副本的写与读——5 条数据流中的 2 条,在 K 较小的形状上约占 25% -的流量——但那是主循环的改动,需要在有设备的环境里开发。 +中,这将同时消掉 int8 副本的写与读——5 条数据流中的 2 条,视 K 而定约占 14–22% 的流 +量——但那是主循环的改动,需要在有设备的环境里开发。 + +### prefill 还剩下多少空间 + +在阶梯选中的 tile 下,四个受算力约束的形状为 88.2 / 62.1 / 99.7 / 103.7 TFLOPS,即各自 +带宽天花板的 56–73%,因此剩余空间分成两部分:这次调用仍在搬的流量,以及路由所决定的天 +花板。 + +| 方向 | 会改变什么 | 体现在哪里 | +|---|---|---| +| 把激活量化融合进 GEMM 的 A-tile 加载 | 消掉 5 条数据流中的 2 条 (int8 副本的写、以及随后的读回) — `K = 768` 时占 14%、`K = 2048` 时 21%、`K = 3072` 时 22% | 所有形状;这是仍未做的最大一项 | +| 让每个专家分到更多行 | kernel 里什么都不用改 — 它*抬高*的是天花板,因为只有权重这一条流不随 token 数增长 | 8K 提示词对 Qwen3-MoE 正是这个实验:每专家 512 行把天花板从 129 / 112 抬到 145 / 123 TFLOPS | +| 8K 提示词会选中的 `256x256` 一档 | 在已经拿到的 N tile 收益之上,进一步把每个 M tile 重复读 B 的次数减半 | `test_perf_prefill_tile_sweep_long_seq`;自每专家 256 行那次运行以来,没有在不产生 padding 的路由上测过 | +| `K = 3072` 的单遍激活量化 | 省掉对 `[T, K]` 的第二次读,在受算力约束的 batch 下约 450 MB | 仅 minimax up;它的一行是每 lane 96 个 dword,超过了 16 向量那一档 | + +`qwen3 down` (`N = 2048, K = 768`) 仍是那个异常值,只有约 62–69 TFLOPS:每个 tile 只有 +12 个 k-tile,是四者中最短的主循环,其输出与权重一样大,而它在任何路由下的天花板也都是 +四者中最低的。它同时也是上面这些访存侧改动收益最大的形状。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 1a7ba1c401..6c083e63b0 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -52,6 +52,14 @@ python auto_round_extension/ark/test/test_moe_w4a8_perf.py --all-shapes python auto_round_extension/ark/test/test_moe_w4a8_perf.py --phase decode python auto_round_extension/ark/test/test_moe_w4a8_perf.py --rescale-group-size 256 + python auto_round_extension/ark/test/test_moe_w4a8_perf.py --phase prefill --long-seq + +The prefill throughput points are two: ``--compute-bound`` derives, per model, +the batch that puts 384 rows on every expert (the smallest routing where the +100 TFLOPS goal is reachable at all), and ``--long-seq`` runs a fixed 8K-token +prompt, whose 65536 routed rows divide into 512 rows per expert for Qwen3-MoE +and 341 for MiniMax -- a higher intensity than the compute-bound batch for one +model and a lower one for the other. Useful environment variables (read by the kernel itself): @@ -367,6 +375,26 @@ def _models_option(request): # 9216 for MiniMax (192). _PREFILL_TARGET_ROWS_PER_EXPERT = 384 +# A second prefill point, sized like a real prompt instead of by a target +# rows/expert: one 8K-token sequence, i.e. 65536 routed rows -- the same 8K +# group ``test_moe_prefill_perf.py`` sweeps. +# +# It is not a larger copy of the compute-bound batch. That batch is *derived* +# per model so both land on 384 rows/expert; a fixed token count is divided by +# a different expert count in each model, so the same 8K prompt puts 512 rows +# on each of Qwen3-MoE's 128 experts and 341 on each of MiniMax's 192. The +# arithmetic intensity therefore moves in opposite directions, and the roofline +# with it: at a 400 GB/s probe the ceilings go 129 -> 145 and 112 -> 123 TFLOPS +# for qwen3 up/down, and 137 -> 129 and 154 -> 145 for minimax up/down. +# +# 512 rows/expert also moves the *tile ladder*, which is why this point is +# worth a sweep of its own rather than one more perf row: the 256-row tile is +# gated on padding no worse than the 128-row one +# (``ceil(M/256)*256 == ceil(M/128)*128``), false at 384 and true at 512, so +# the Qwen3-MoE shapes take the ``256x256`` rung here and nowhere else in the +# suite. +_PREFILL_LONG_SEQ_LEN = 8192 + # Rows per expert for the epilogue equivalence tests: enough to give every # expert interior *and* ragged tiles against the prefill tile the ladder picks @@ -383,6 +411,16 @@ def _compute_bound_batches(model: dict) -> list: return [_PREFILL_TARGET_ROWS_PER_EXPERT * model["E"] // model["topk"]] +def _long_seq_batches() -> list: + """The long-prompt prefill point: one ``_PREFILL_LONG_SEQ_LEN``-token sequence. + + Unlike ``_compute_bound_batches`` this is *not* derived per model -- the + point of it is that a fixed prompt length routes differently in each model + (see ``_PREFILL_LONG_SEQ_LEN``). + """ + return [_PREFILL_LONG_SEQ_LEN] + + def _spread_tokens(total_tokens: int, num_experts: int) -> list: """Distribute ``total_tokens`` across ``num_experts`` round-robin.""" tpe = [0] * num_experts @@ -1132,16 +1170,21 @@ def _print_sweep_header(title: str, metric: str) -> None: print("-" * _PERF_WIDTH) -def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose=True, compute_bound=None): +def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose=True, compute_bound=None, batches=None): """Time every kernel configuration in ``configs`` on the same workload. ``phase`` picks the sweep's shapes and its metric: ``decode`` reports weight bandwidth (the decode target), ``prefill`` reports TFLOPS (the - prefill target) at the compute-bound batch. Returns one dict per - (shape, configuration). + prefill target) at the compute-bound batch. ``batches`` runs the sweep at + an explicit list of model-token batches instead -- what the long-prompt + sweeps use, since a fixed prompt length is exactly what the compute-bound + derivation replaces. Returns one dict per (shape, batch, configuration). """ is_prefill = phase == "prefill" - compute_bound = is_prefill if compute_bound is None else compute_bound + # An explicit batch list opts out of the compute-bound derivation, which + # ignores ``batches`` and sizes the batch from the target rows/expert. + if compute_bound is None: + compute_bound = is_prefill and batches is None metric_name = "TFLOPS" if is_prefill else "W GB/s" device_bw = _device_bandwidth_gbps() resolved = _models(models) @@ -1151,7 +1194,8 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= f"act={str(dtype).split('.')[-1]}) -- same workload, one row per kernel configuration", metric_name, ) - batches = _DECODE_BATCHES if not is_prefill else None + if batches is None: + batches = _DECODE_BATCHES rows = [] for _, spec in resolved: E, topk, group_size = spec["E"], spec["topk"], spec["group_size"] @@ -1217,7 +1261,12 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= def _print_sweep_best(phase, rows) -> None: - """Report, per shape, the fastest numerically-equivalent configuration.""" + """Report, per shape, the fastest numerically-equivalent configuration. + + A shape swept at more than one batch gets one line per batch: the winner + is a property of the routing as much as of the shape (the tile ladder + changes rung with ``rows/expert``), so the two must not be pooled. + """ if not rows: return is_prefill = phase == "prefill" @@ -1228,14 +1277,22 @@ def _print_sweep_best(phase, rows) -> None: if row["label"] not in shapes: shapes.append(row["label"]) for shape in shapes: - candidates = [r for r in rows if r["label"] == shape and r["snr_db"] >= _SWEEP_MIN_SNR_DB] - if not candidates: - print(f" {shape:<14} no numerically-equivalent configuration") - continue - best = min(candidates, key=lambda r: r["w4a8_ms"]) - metric = f"{best['tflops']:.2f} TFLOPS" if is_prefill else f"{best['gbps']:.1f} GB/s" - env = " ".join(f"{k}={v}" for k, v in sorted(best["overrides"].items()) if v is not None) or "(defaults)" - print(f" {shape:<14} {best['config']:<22} {best['w4a8_ms']:.3f} ms {metric:<16} {env}") + shape_rows = [r for r in rows if r["label"] == shape] + batches = [] + for row in shape_rows: + if row["tokens"] not in batches: + batches.append(row["tokens"]) + for tokens in batches: + group = [r for r in shape_rows if r["tokens"] == tokens] + name = shape if len(batches) == 1 else f"{shape.strip()} T={tokens}" + candidates = [r for r in group if r["snr_db"] >= _SWEEP_MIN_SNR_DB] + if not candidates: + print(f" {name:<14} no numerically-equivalent configuration") + continue + best = min(candidates, key=lambda r: r["w4a8_ms"]) + metric = f"{best['tflops']:.2f} TFLOPS" if is_prefill else f"{best['gbps']:.1f} GB/s" + env = " ".join(f"{k}={v}" for k, v in sorted(best["overrides"].items()) if v is not None) or "(defaults)" + print(f" {name:<14} {best['config']:<22} {best['w4a8_ms']:.3f} ms {metric:<16} {env}") # --------------------------------------------------------------------------- @@ -1321,6 +1378,35 @@ def test_perf_prefill_compute_bound(self, request): assert rows and all(r["w4a8_ms"] > 0 for r in rows) _assert_targets(request, "prefill", rows) + def test_perf_prefill_long_seq(self, request): + """Prefill throughput for one 8K-token prompt. + + ``test_perf_prefill_compute_bound`` derives its batch per model so + that both land on the same 384 rows per expert. A prompt does not + work that way: a fixed 8192 model tokens (65536 routed rows) is + divided by whatever expert count the model has, which puts 512 rows + on each of Qwen3-MoE's 128 experts and 341 on each of MiniMax's + 192. + + Both directions are worth measuring. More rows per expert raise the + arithmetic intensity -- the weights are the only stream that does + not grow with the token count -- so the qwen3 ceilings rise from + 129 / 112 to 145 / 123 TFLOPS at a 400 GB/s probe, which is the + regime where the 100 TFLOPS target has the most margin. Fewer rows + lower it, so minimax comes down from 137 / 154 to 129 / 145 and the + same kernel should read *slower* there: the point of running both + is that the prompt length alone does not decide the throughput, the + routing it produces does. + """ + rows = run_perf( + "prefill", + _long_seq_batches(), + torch_baseline=False, + models=_models_option(request), + ) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + _assert_targets(request, "prefill", rows) + def test_perf_decode_config_sweep(self, request): """Time every decode lane mapping on one workload and name the best. @@ -1354,6 +1440,30 @@ def test_perf_prefill_tile_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"prefill tile {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_tile_sweep_long_seq(self, request): + """Time every prefill work-group tile at the 8K-prompt routing. + + The tile sweep above runs at the derived compute-bound batch, where + every model sits at 384 rows per expert -- a routing the 256-row + tile is *excluded* from, because it would schedule 512 rows for 384 + rows of data. A fixed 8K prompt is the routing that changes that: + Qwen3-MoE's 128 experts get 512 rows each, the padding test + (``ceil(M/256)*256 == ceil(M/128)*128``) turns true, and the ladder + takes its ``256x256`` rung -- the one rung no other case in this + suite reaches. This sweep is what says whether it should. + """ + rows = run_config_sweep( + "prefill", + _PREFILL_TILE_CONFIGS, + models=_models_option(request), + batches=_long_seq_batches(), + ) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"prefill tile {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_act_quant_sweep(self, request): """Time both activation-quantization mappings at the compute-bound batch. @@ -1795,6 +1905,16 @@ def _parse_args(argv): "the smallest sweep point where the 100 TFLOPS goal is not capped by weight bandwidth." ), ) + parser.add_argument( + "--long-seq", + action="store_true", + help=( + f"Also run the long-prompt prefill point ({_PREFILL_LONG_SEQ_LEN} model tokens, one 8K sequence), " + "where Qwen3-MoE routes 512 rows per expert -- a higher intensity than the compute-bound batch, " + "and the only routing at which the tile ladder takes its 256-row rung. With --sweep-configs the " + "prefill tile sweep is repeated there." + ), + ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") return parser.parse_args(argv) @@ -1839,9 +1959,20 @@ def main(argv=None) -> int: compute_bound=True, models=models, ) + if phase == "prefill" and args.long_seq: + run_perf( + phase, + _long_seq_batches(), + dtype=dtype, + rescale_group_size=args.rescale_group_size, + torch_baseline=False, + models=models, + ) if args.sweep_configs: configs = _PREFILL_TILE_CONFIGS if phase == "prefill" else _DECODE_CONFIGS run_config_sweep(phase, configs, dtype=dtype, models=models) + if phase == "prefill" and args.long_seq: + run_config_sweep(phase, configs, dtype=dtype, models=models, batches=_long_seq_batches()) if failures: print() From 4318efaa87090159f5da016c73be85b82f1d92d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:20:07 +0000 Subject: [PATCH 075/112] perf: retire the W4A8 prefill 256-row tile rung from the auto ladder Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 193 +++++++------ .../ark/test/README_MOE_W4A8.md | 262 +++++++++++------- .../ark/test/README_MOE_W4A8_CN.md | 219 +++++++++------ .../ark/test/test_moe_w4a8_perf.py | 28 +- 4 files changed, 410 insertions(+), 292 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index c7c57d9b32..ad6bd34382 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -2,9 +2,10 @@ // // STATUS: PARTIALLY HARDWARE-VALIDATED -- every perf sweep in // `test_moe_w4a8_perf.py` (`test_perf_prefill_tile_sweep`, -// `test_perf_prefill_act_quant_sweep`, `..._unroll_sweep`, -// `..._single_pass_sweep`, `test_perf_prefill_store_sweep`, -// `test_perf_prefill_epilogue_sweep`, `test_perf_decode_config_sweep`) and +// `test_perf_prefill_tile_sweep_long_seq`, `test_perf_prefill_act_quant_- +// sweep`, `..._unroll_sweep`, `..._single_pass_sweep`, +// `test_perf_prefill_store_sweep`, `test_perf_prefill_epilogue_sweep`, +// `test_perf_decode_config_sweep`) and // every cross-configuration equivalence test (`test_act_quant_vec_matches_- // scalar`, `test_act_quant_unroll_matches`, `test_act_quant_single_pass_- // matches`, `test_full_tile_epilogue_matches_predicated`, @@ -15,7 +16,9 @@ // dispatch default -- tile ladder, activation-quant message width / unroll / // single-pass, interior-tile epilogue, 2D block store, decode CH / NCOLS -- // comes from those measurements at the compute-bound batch (384 rows/expert), -// with each configuration checked numerically against the others before it was +// with the tile ladder measured at the 8K-prompt routing (512 / 341 +// rows/expert) as well, and each configuration checked numerically against the +// others before it was // timed. The accuracy gates against the fp32 reference still need a device // run. The authoring environment has no XPU and no SYCL compiler, so anything // added *since* that run follows the porting conventions of its siblings @@ -229,13 +232,13 @@ inline DeviceScratchPool& expert_map_pool() { // `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. // // `test_perf_prefill_act_quant_sweep` on BMG (384 rows/expert, bf16 act) puts -// the widened messages at 1.05x (qwen3 up), 1.13x (qwen3 down), 1.11x (minimax -// up) and 1.04x (minimax down) of the scalar mapping on the *whole* +// the widened messages at 1.12x (qwen3 up), 1.10x (qwen3 down), 1.15x (minimax +// up) and 1.07x (minimax down) of the scalar mapping on the *whole* // `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of // prefill than that, since the GEMM around it is unchanged. (Earlier runs of -// the same sweep read 1.13 / 1.14 / 1.12 / 1.04 and 1.14 / 1.07 / 1.10 / 1.07: -// the ranking is stable, the individual ratios move by a few percent between -// runs.) +// the same sweep read 1.05 / 1.13 / 1.11 / 1.04, 1.13 / 1.14 / 1.12 / 1.04 and +// 1.14 / 1.07 / 1.10 / 1.07: the ranking is stable, the individual ratios move +// by a few percent between runs.) // // `sycl::vec` is used rather than `sycl::vec` // because `sycl::vec` of `bfloat16` is not universally available; the elements @@ -277,11 +280,15 @@ inline DeviceScratchPool& expert_map_pool() { // `test_act_quant_unroll_matches` asserts every depth is bit-identical and // `test_perf_prefill_act_quant_unroll_sweep` times them. // -// That sweep confirms the default: only minimax up is a real A/B in it (the -// other three shapes take the single-pass kernel below, where `UNROLL` is dead -// code, so their rows are three sets of identical kernels -- a useful noise -// probe, spreading 0.4-3.9%), and there `UNROLL = 4` is fastest: 8.959 ms -// against 8.967 ms at 2 and 9.139 ms at 1. +// That sweep keeps the default, though not by much: only minimax up is a real +// A/B in it (the other three shapes take the single-pass kernel below, where +// `UNROLL` is dead code, so their rows are three sets of identical kernels -- +// a useful noise probe, spreading 3.3-7.3% in the latest run and 0.4-3.9% in +// the one before). On that shape the three depths read 6.982 ms at 1, +// 6.795 ms at 2 and 6.837 ms at 4: the batched loads are worth 1.02-1.03x over +// `UNROLL = 1`, and the 0.6% between 2 and 4 is an order of magnitude inside +// the noise the identical-kernel rows show, so the default stays at 4 (the +// earlier run had it 8.959 / 8.967 / 9.139 ms, i.e. 4 fastest). // // Reading the row once (the traffic the two passes duplicate) // ----------------------------------------------------------- @@ -314,10 +321,12 @@ inline DeviceScratchPool& expert_map_pool() { // // This was a register-pressure gamble -- if 64 dwords of row plus addressing // spilled, the pass would get slower, not faster -- and the sweep settled it in -// its favour: at 384 rows/expert the single-pass kernel is 1.05x (qwen3 down, -// K = 768) and 1.01x (qwen3 up, K = 2048, the rung filled exactly) against the -// two-pass one, and level on minimax down (K = 1536, 0.1% apart). Nothing -// spills. `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` still restores the two-pass +// its favour: at 384 rows/expert the single-pass kernel is 1.06x (qwen3 down, +// K = 768), 1.04x (qwen3 up, K = 2048, the rung filled exactly) and 1.02x +// (minimax down, K = 1536) against the two-pass one. Nothing spills. minimax +// up (K = 3072) is past the last rung, so both of its rows run the *same* +// two-pass kernel and their 1.01x is this sweep's noise probe. +// `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` still restores the two-pass // kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times the // pair, and `test_act_quant_single_pass_matches` asserts they agree bit for bit // (same `fmax` set, same `inv`, same `rint`/`clamp`). @@ -740,44 +749,62 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // divide the rows/expert pays for rows that do not exist. // // `test_perf_prefill_tile_sweep` on BMG at the compute-bound batch the suite -// now runs (384 rows/expert, bf16 act), with the 2D block store and the +// runs (384 rows/expert, bf16 act), with the 2D block store and the // single-pass activation quantizer in: // -// shape 128x128 128x256 256x128 256x256 auto (was) -// qwen3 up 3.457 ms 3.507 ms 4.507 ms 3.952 ms 4.423 ms -// qwen3 down 2.512 ms 2.488 ms 2.777 ms 2.696 ms 2.933 ms -// minimax up 6.940 ms 6.978 ms 8.895 ms 8.111 ms 9.043 ms -// minimax down 7.255 ms 6.710 ms 9.166 ms 7.779 ms 9.211 ms -// -// M: the 256-row tile is 1.11-1.30x *behind* the 128-row one here, and that is -// arithmetic, not a register effect. 384 rows/expert take `ceil(384/256) = 2` -// 256-row tiles -- 512 rows scheduled for 384 rows of data, a third of the -// MACs spent on padding -- against exactly 3 full 128-row tiles. The measured -// ratio (1.30x on qwen3 up, 1.28x on minimax up, both long-K shapes where the -// mainloop dominates) is the padding ratio 512/384 = 1.33 to within noise. -// It is *not* an argument against `TileM = 256` as such: the earlier run at -// 256 rows/expert, where the tile divides the rows exactly, had it 1.3-3.9% -// ahead on all four shapes. The ladder therefore gates it on padding rather -// than on a row threshold (see `moe_w4a8_prefill_dispatch`). -// -// N: the 256-wide tile is now ahead or level everywhere the table can compare -// it -- at `TileM = 128` it takes minimax down by 1.08x and ties the other -// three (within 1.4% either way), and at `TileM = 256` it takes all four, by -// 2.9% to 15.1%. -// The 35-50% cliff the first sweep saw on every 256-wide N tile is gone -- it -// was the float C shadow the mainloop used to keep live (see `xe_gemm_w4a8`), -// which doubled the per-lane C footprint and made `TileN = 256` ask for the -// entire 256-register large-GRF file -- and what remained of it in the second -// sweep (0-8% behind on three shapes, measured with the *scalar* epilogue -// store) is gone too, now that a 32x64 fragment goes out in a handful of block -// messages instead of 128 scalar ones. So the ladder is 256 wide in N wherever -// N divides into it. -// -// Noise floor for reading all of this: `test_perf_prefill_act_quant_unroll_- -// sweep` in the same run times three shapes whose K puts them on the -// single-pass quantizer, where `UNROLL` is dead code -- i.e. three sets of -// *identical* kernels -- and they spread 0.4% / 2.1% / 3.9%. Anything under -// ~4% here is run-to-run variation; the padding effect above is 11-30%. +// shape auto 128x128 128x256 256x128 256x256 +// qwen3 up 3.540 ms 3.518 ms 3.585 ms 4.404 ms 3.970 ms +// qwen3 down 2.472 ms 2.473 ms 2.432 ms 2.696 ms 2.547 ms +// minimax up 6.976 ms 6.823 ms 6.878 ms 8.899 ms 8.019 ms +// minimax down 6.749 ms 7.227 ms 6.874 ms 9.096 ms 7.823 ms +// +// and `test_perf_prefill_tile_sweep_long_seq`, the same sweep at one 8K prompt +// -- 512 rows/expert on qwen3 (128 experts), 341 on minimax (192): +// +// shape auto 128x128 128x256 256x128 256x256 +// qwen3 up 4.371 ms 4.382 ms 4.393 ms 4.468 ms 4.394 ms +// qwen3 down 3.075 ms 3.030 ms 2.903 ms 3.025 ms 3.059 ms +// minimax up 6.564 ms 6.673 ms 6.449 ms 9.057 ms 7.744 ms +// minimax down 6.466 ms 6.725 ms 6.450 ms 9.373 ms 7.307 ms +// +// M: `TileM = 256` never pays. At 384 and 341 rows/expert it is 1.05-1.45x +// *behind*, and that part is arithmetic rather than a register effect: 384 +// rows take `ceil(384/256) = 2` 256-row tiles -- 512 rows scheduled for 384 +// rows of data, a third of the MACs spent on padding -- against exactly 3 full +// 128-row tiles, and the like-for-like ratio on the long-K shapes, where the +// mainloop dominates, is that padding ratio (512/384 = 1.33) to within noise +// (1.25x qwen3 up, 1.30x minimax up, both at `TileN = 128`). +// The 8K prompt is the case where that argument does *not* apply: 512 rows per +// expert is an exact multiple of 256, so both tiles schedule the same rows. +// The 256-row tile is still not ahead there. Like for like on `TileN` it reads +// -2.0% / 0.0% (qwen3 up at `TileN` 128 / 256) and +0.2% / -5.4% (qwen3 down), +// i.e. never better than a tie and 5.4% behind on the shape with the shortest +// mainloop -- so halving how often B is pulled per M tile buys nothing that the +// larger work-group (512 threads, one per Xe core) does not give back in +// scheduling granularity. The only reading ever in its favour is an older run +// at 256 rows/expert, 1.3-3.9% ahead, inside the noise floor. The ladder +// therefore stops taking it (see `moe_w4a8_prefill_dispatch`): it has no +// measured upside, and a routing skewed around the average the ladder sees +// puts individual experts back on the padding cliff. +// +// N: the 256-wide tile is ahead or level everywhere the tables can compare it +// -- at 384 rows/expert it takes minimax down by 1.05x and qwen3 down by 1.02x +// and is 0.8-1.9% behind on the other two, and at the 8K prompt it takes three +// of four by 3.5-4.4% and ties qwen3 up (0.3%). The 35-50% cliff the first +// sweep saw on every 256-wide N tile is gone -- it was the float C shadow the +// mainloop used to keep live (see `xe_gemm_w4a8`), which doubled the per-lane +// C footprint and made `TileN = 256` ask for the entire 256-register large-GRF +// file -- and what remained of it in the second sweep (0-8% behind on three +// shapes, measured with the *scalar* epilogue store) is gone too, now that a +// 32x64 fragment goes out in a handful of block messages instead of 128 scalar +// ones. So the ladder is 256 wide in N wherever N divides into it. +// +// Noise floor for reading all of this: the `auto` column is not an independent +// measurement -- it launches whichever explicit tile the ladder picks, so each +// row above contains one *duplicate* pair (`128x256` at 384 rows/expert and at +// the minimax 8K point, `256x256` at the qwen3 8K point). Across the eight +// pairs the two readings of the same kernel differ by 0.2-1.9%, which is the +// run-to-run floor for these tables; the padding effect reaches 45%. // // Every policy stays reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a // re-sweep on a device with a different register budget. @@ -886,15 +913,15 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // these shapes. A 128x128 tile runs `K / 64` k-tiles -- 12 of them for the // qwen3 down-projection (K = 768) -- while it always writes `TileM * TileN` // elements, and qwen3 down is exactly the shape the sweep reports furthest -// from the compute target (62 TFLOPS against 89-104 for the other three). The +// from the compute target (63 TFLOPS against 87-103 for the other three). The // fast path emits the same expression in the same order for every element it // stores, so it is bit-identical to the guarded one // (`test_full_tile_epilogue_matches_predicated`), and // `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` forces the guarded path for A/B // measurement. `test_perf_prefill_epilogue_sweep` at 384 rows/expert has it at -// 1.08x (qwen3 down) and 1.02x (minimax up), level on the other two -- the -// shape ordering the instruction-count argument predicted, with the gain -// concentrated where the mainloop is shortest. +// 1.04x (qwen3 down), 1.03x (qwen3 up) and 1.00-1.01x on the two minimax +// shapes -- the shape ordering the instruction-count argument predicted, with +// the gain concentrated where the mainloop is shortest. // // The store itself: one 2D block message instead of `size(tCrC)` scalar ones // ----------------------------------------------------------------------- @@ -938,8 +965,9 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { // (`test_prefill_2d_store_matches_scalar`); `ARK_MOE_W4A8_PREFILL_STORE_2D=0` // restores the scalar store for A/B measurement. `test_perf_prefill_store_- // sweep` at 384 rows/expert makes it the largest single prefill win of the -// set: 1.16x (qwen3 up), 1.35x (qwen3 down -- the shape that pays the epilogue -// twice), 1.12x (minimax up) and 1.20x (minimax down). +// set: 1.14x (qwen3 up), 1.21x (qwen3 down -- the shape that pays the epilogue +// twice), 1.09x (minimax up) and 1.16x (minimax down); the run before read +// 1.16 / 1.35 / 1.12 / 1.20, same ordering. // // The block 2D descriptor wants a 64-byte aligned base and a row pitch that is // a multiple of 16 bytes. The base here is the expert's slice @@ -1326,21 +1354,26 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // `sycl_tla_s8_gemm.hpp`: a grouped GEMM's M is *per expert*, so the ladder // walks the average rows/expert rather than the total token count. // -// Both the M and the N rung are gated on the same thing -- that the wider tile -// does not schedule work the shape does not have: -// -// * `TileM = 256` halves how often each expert's B panel is pulled through -// L2/DRAM (B is read once per M tile), but an expert launches -// `ceil(M / TileM)` *full* tiles. It is taken only where that rounds to the -// same row count the 128-row tile would launch -- -// `ceil(M/256)*256 == ceil(M/128)*128`, true at M = 256, 512, 400, ... and -// false at M = 384, where the 256-row tile computes 512 rows for 384 rows -// of data. The sweep in the tile-policy comment measures exactly that -// third of wasted MACs (1.26-1.37x in favour of `TileM = 128` at M = 384). -// `A_avg_M >= 256` is implied by the equality but kept for readability. +// The M rung is a row threshold and the N rung a divisibility test, and both +// are about not scheduling work the shape does not have: +// +// * `TileM` stops at 128. The 256-row tile halves how often each expert's B +// panel is pulled through L2/DRAM (B is read once per M tile), but an +// expert launches `ceil(M / TileM)` *full* tiles, so it only breaks even +// where `ceil(M/256)*256 == ceil(M/128)*128` -- false at the 384 and 341 +// rows/expert the perf suite measures, where it computes 512 rows for 384 +// rows of data and reads 1.05-1.45x slower. The 8K prompt puts Qwen3-MoE +// at exactly 512 rows/expert, where the padding argument does not apply, +// and `test_perf_prefill_tile_sweep_long_seq` measures it there: still not +// ahead (a tie on qwen3 up, 5.4% behind on qwen3 down). So the rung is +// gone rather than gated -- there is no routing at which it has been +// measured to win, and the ladder only sees the *average* rows/expert, so +// a skewed routing would put individual experts back on the padding cliff +// even when the average divides. Both 256-row policies stay compiled and +// reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a re-sweep. // // * `TileN = 256` halves how often A is re-read (once per N tile) and is -// ahead or level everywhere the sweep can compare it, so it is taken +// ahead or level everywhere the sweeps can compare it, so it is taken // whenever N divides into it exactly. `N % 256 != 0` would pad the last // tile the same way a ragged M does, and no shipped shape needs it: every // N here (1536 / 2048 / 3072) is a multiple of 256. @@ -1348,7 +1381,8 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // The rung used to be `A_avg_M >= 256 -> 256x128` with no padding test and a // 128-wide N at every rung, which is what made the 384 rows/expert batch -- // the compute-bound batch the perf suite now runs -- land on the slowest -// column of its own sweep. +// column of its own sweep; it then became a padding-gated 256-row tile, which +// the 8K-prompt sweep has now retired. // // `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile // (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything @@ -1378,11 +1412,6 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* compat::set_default_queue(*q); const int A_avg_M = total_tokens / E; - // Rows a whole expert actually schedules at each candidate `TileM`. The - // 256-row tile only pays where it does not launch more rows than the 128-row - // one would: see the tile-policy comment for the measurement. - const auto padded_rows = [](int m, int tile) { return (m + tile - 1) / tile * tile; }; - const bool tile_m_256 = A_avg_M >= 256 && padded_rows(A_avg_M, 256) == padded_rows(A_avg_M, 128); const bool tile_n_256 = (N % 256) == 0; const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); const bool store_2d_aligned = (static_cast(N) * sizeof(ElementD)) % 64 == 0 && @@ -1423,12 +1452,6 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) } else if (A_avg_M < 128) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - } else if (tile_m_256) { - if (tile_n_256) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) - } else { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) - } } else if (tile_n_256) { ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) } else { diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 5db19a9c65..8894ad4023 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -135,9 +135,10 @@ block: ``` targets [prefill]: prefill compute > 100 TFLOPS - device copy bandwidth probe: 400 GB/s + device copy bandwidth probe: 390 GB/s qwen3 up tokens=1024 rows/E=8.0 4.56 TFLOPS vs 100 -> N/A (bandwidth bound: ...) - qwen3 down tokens=49152 rows/E=384.0 102.40 TFLOPS vs 100 -> PASS (92% of the 112 TFLOPS bandwidth ceiling) + qwen3 down tokens=49152 rows/E=384.0 66.77 TFLOPS vs 100 -> FAIL (61% of the 109 TFLOPS bandwidth ceiling) + minimax down tokens=73728 rows/E=384.0 104.77 TFLOPS vs 100 -> PASS (70% of the 150 TFLOPS bandwidth ceiling) ``` A row is reported `N/A` rather than `FAIL` when the device bandwidth probe (one @@ -173,13 +174,21 @@ faster on one model and slower on the other at the same prompt length, which is the reason to measure both: throughput follows the routing, not the sequence length. -512 rows per expert also moves the **tile ladder**. The 256-row tile is gated on -padding no worse than the 128-row one (`⌈M/256⌉·256 == ⌈M/128⌉·128`) — false at -384, true at 512 — so the Qwen3-MoE shapes take the `256x256` rung here and -nowhere else in the suite. `test_perf_prefill_tile_sweep_long_seq` re-runs the -tile sweep at this routing for exactly that reason: the rung the ladder picks at -an 8K prompt has never been measured against its alternatives at a routing where -it does not pad (see [Prefill tile](#prefill-tile)). +The run bears that out. Against the compute-bound batch's 93.8 / 66.8 / 101.0 / +104.8 TFLOPS, the 8K prompt reads 98.2 / 70.5 / 93.5 / 95.4 — both qwen3 shapes +up by ~5%, both minimax shapes down by 7–9%, in the direction each model's +routing moved. As a *fraction* of the ceiling nothing moved much (70 / 59 / 74 / +68% against 74 / 61 / 76 / 70%), which is the useful reading: the prompt changed +what was reachable, not how close the kernel gets. + +512 rows per expert also moves the **tile ladder**, and that is the reason this +point gets a tile sweep of its own. The 256-row tile schedules `⌈M/256⌉·256` +rows, so at 384 rows/expert it can only be measured *with* a third of its MACs +spent on padding; 512 is an exact multiple of 256, the only routing in the suite +where `⌈M/256⌉·256 == ⌈M/128⌉·128`, so it is the only place `TileM = 256` can be +judged on its merits. `test_perf_prefill_tile_sweep_long_seq` judged it — level +on qwen3 up, 5.4% behind on qwen3 down — and the ladder's 256-row rung was +removed as a result (see [Prefill tile](#prefill-tile)). ### Why `vs w4a16` is below 1.0 at small batches @@ -634,47 +643,83 @@ B60, cache the layers that are prefill-bound and leave the rest on Every default below comes from `-k sweep` runs on the Arc Pro B60 above (bf16 activations, 8 routed rows for decode, **384 rows/expert** for prefill — the -compute-bound batch the suite uses). Each configuration is checked for numerical -equivalence with the first one before it is timed. +compute-bound batch the suite uses); the tile ladder additionally has a sweep at +the 8K-prompt routing (512 / 341 rows/expert). Each configuration is checked for +numerical equivalence with the first one before it is timed. Nothing in the prefill path is unmeasured any more: the single-pass quantizer and the 2D block store, which used to be on by reasoning alone, both have their own tables below. -**Reading the tables — the noise floor is ~4%.** The unroll sweep times three -shapes whose K puts them on the single-pass quantizer, where `UNROLL` is dead -code, so those rows are three sets of *identical* kernels: they spread 0.4%, -2.1% and 3.9%. Treat anything below ~4% as run-to-run variation. +**Reading the tables — the noise floor is 2–7%, and it is measured.** Two sweeps +carry their own control. The unroll sweep times three shapes whose K puts them on +the single-pass quantizer, where `UNROLL` is dead code, so those rows are three +sets of *identical* kernels: they spread 3.3%, 4.5% and 7.3% in this run (0.4%, +2.1% and 3.9% in the one before). The tile sweeps contain a duplicate pair per +row, because `auto` launches one of the explicit tiles, and those spread +0.2–1.9%. Treat differences below the control in the same table as run-to-run +variation, and do not compare a number in one table with a number in another. ### Prefill tile -| shape | `128x128` | `128x256` | `256x128` | `256x256` | -|---|---|---|---|---| -| qwen3 up | **3.457 ms** | 3.507 ms | 4.507 ms | 3.952 ms | -| qwen3 down | 2.512 ms | **2.488 ms** | 2.777 ms | 2.696 ms | -| minimax up | **6.940 ms** | 6.978 ms | 8.895 ms | 8.111 ms | -| minimax down | 7.255 ms | **6.710 ms** | 9.166 ms | 7.779 ms | - -**M is about padding, not registers.** An expert launches `ceil(M / TileM)` -*full* tiles, so at 384 rows/expert the 256-row tile schedules 512 rows for 384 -rows of data — a third of the MACs spent on nothing — against exactly three -128-row tiles. That is the whole 1.11–1.30× deficit in the `256x*` columns: on -the two long-K shapes, where the mainloop dominates, the measured ratio (1.30× -qwen3 up, 1.28× minimax up) *is* the padding ratio 512/384 = 1.33 to within -noise. It is not an argument against `TileM = 256` — the earlier run at 256 -rows/expert, where the tile divides the rows exactly, had it 1.3–3.9% ahead on -all four shapes. - -So the rung is gated on padding rather than on a row count: take the 256-row -tile only where `ceil(M/256)·256 == ceil(M/128)·128`, i.e. where it launches no -more rows than the 128-row tile would. True at 256, 400, 512, 2048; false at -384, which is exactly the batch that used to fall off the cliff. - -**N is now free.** The 256-wide tile is ahead or level everywhere the table can -compare it: at `TileM = 128` it takes minimax down by 1.08× and ties the other -three (within 1.4% either way), and at `TileM = 256` it takes all four, by -2.9–15.1%. The 35–50% cliff the first sweep saw on 256-wide N tiles was the -float C shadow (see +At the compute-bound batch (384 rows/expert on both models): + +| shape | `auto` | `128x128` | `128x256` | `256x128` | `256x256` | +|---|---|---|---|---|---| +| qwen3 up | 3.540 ms | **3.518 ms** | 3.585 ms | 4.404 ms | 3.970 ms | +| qwen3 down | 2.472 ms | 2.473 ms | **2.432 ms** | 2.696 ms | 2.547 ms | +| minimax up | 6.976 ms | **6.823 ms** | 6.878 ms | 8.899 ms | 8.019 ms | +| minimax down | **6.749 ms** | 7.227 ms | 6.874 ms | 9.096 ms | 7.823 ms | + +and at the 8K prompt (512 rows/expert on Qwen3-MoE, 341 on MiniMax): + +| shape | rows/E | `auto` | `128x128` | `128x256` | `256x128` | `256x256` | +|---|---|---|---|---|---|---| +| qwen3 up | 512 | **4.371 ms** | 4.382 ms | 4.393 ms | 4.468 ms | 4.394 ms | +| qwen3 down | 512 | 3.075 ms | 3.030 ms | **2.903 ms** | 3.025 ms | 3.059 ms | +| minimax up | 341 | 6.564 ms | 6.673 ms | **6.449 ms** | 9.057 ms | 7.744 ms | +| minimax down | 341 | 6.466 ms | 6.725 ms | **6.450 ms** | 9.373 ms | 7.307 ms | + +**Read `auto` as a control, not a candidate.** It launches whichever explicit +tile the ladder picks — `128x256` in every row above except the two qwen3 rows +of the second table, where the ladder still had a 256-row rung and picked +`256x256` — so each row contains one duplicate pair, the same kernel measured +twice. Across the eight pairs the two readings differ by 0.2–1.9%. That is the +run-to-run floor these tables should be read against, and it is a tighter probe +than the unroll sweep's identical-kernel rows (3.3–7.3% in this run), because it +is measured inside the sweep whose numbers it qualifies. + +**M is about padding, not registers — and once padding is removed there is +nothing left.** An expert launches `ceil(M / TileM)` *full* tiles, so at 384 +rows/expert the 256-row tile schedules 512 rows for 384 rows of data (a third of +the MACs spent on nothing) against exactly three 128-row tiles, and at 341 +rows/expert it is worse (512/341 = 1.5). That is the whole 1.05–1.45× deficit of +the `256x*` columns in every row where padding applies: on the long-K shapes, +where the mainloop dominates, the like-for-like ratio (1.25× qwen3 up, 1.30× +minimax up, both at `TileN = 128`) *is* the padding ratio 512/384 = 1.33 to +within noise. + +The 8K prompt removes that term for Qwen3-MoE — 512 rows per expert is an exact +multiple of 256, so both tiles schedule the same rows — and it is the only +routing in the suite that does. Like for like on `TileN`, the 256-row tile then +reads −2.0% / 0.0% on qwen3 up (`TileN` 128 / 256) and +0.2% / −5.4% on qwen3 +down: never better than a tie, and 5.4% behind on the shape with the shortest +mainloop. Halving how often B is pulled per M tile is real traffic, but the +512-thread work-group it takes gives it back in scheduling granularity. + +So the rung is **gone** rather than gated. The only reading ever in its favour is +an older run at 256 rows/expert (1.3–3.9% ahead, inside the noise floor); it has +never been measured to win; and the ladder only sees `total_tokens / E`, the +*average* rows/expert, so a skewed routing puts individual experts back on the +padding cliff even when the average divides. Both 256-row policies stay compiled +and selectable with `ARK_MOE_W4A8_PREFILL_TILE` for a re-sweep on a device with a +different register budget. + +**N is free.** The 256-wide tile is ahead or level everywhere the tables can +compare it: at 384 rows/expert it takes minimax down by 1.05× and qwen3 down by +1.02× and is 0.8–1.9% behind on the other two (inside the floor), and at the 8K +prompt it takes three of four by 3.5–4.4% and ties qwen3 up. The 35–50% cliff the +first sweep saw on 256-wide N tiles was the float C shadow (see [Prefill: message width and register pressure](#prefill-message-width-and-register-pressure)); what was left of it in the second sweep — 0–8% behind on three shapes — was measured with the *scalar* epilogue store, and it disappears now that a 32×64 @@ -682,99 +727,102 @@ fragment goes out in a handful of block messages instead of 128 scalar ones. So the ladder is 256 wide wherever `N % 256 == 0` (every shipped N: 1536 / 2048 / 3072), and 128 wide otherwise, where the wider tile would only pad. -The ladder therefore is: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, then -`256x256` / `256x128` when the 256-row tile costs no extra padding and -`128x256` / `128x128` when it does — the N choice being `N % 256 == 0` in both -cases. All six policies stay compiled and selectable with -`ARK_MOE_W4A8_PREFILL_TILE`. - -At the ladder's choice the four swept shapes land on 3.507 / 2.488 / 6.978 / -6.710 ms — 88.2 / 62.1 / 99.7 / 103.7 TFLOPS, against 69.9 / 52.7 / 76.9 / 75.5 -for the ladder as it was (1.26× / 1.18× / 1.30× / 1.37×), and within 1.4% of the -fastest tile on every shape. Three of the four now clear the 100 TFLOPS target -or sit within 1%; qwen3 down is still the outlier at 62 TFLOPS, because at -`K = 768` a tile runs only 12 k-tiles and the epilogue and prologue are a large -share of it. +The ladder therefore is: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, +otherwise `128x256` when `N % 256 == 0` and `128x128` when it does not. All six +policies stay compiled and selectable with `ARK_MOE_W4A8_PREFILL_TILE`. + +At the ladder's choice the four swept shapes land on 3.585 / 2.432 / 6.878 / +6.874 ms at the compute-bound batch — 86.3 / 63.6 / 101.2 / 101.2 TFLOPS, +against 69.9 / 52.7 / 76.9 / 75.5 for the ladder as it was two revisions ago +(1.23× / 1.21× / 1.31× / 1.34×), and within 1.9% of the fastest tile on every +shape. Two of the four clear the 100 TFLOPS target here; qwen3 down is still the +outlier at 64 TFLOPS, because at `K = 768` a tile runs only 12 k-tiles and the +epilogue and prologue are a large share of it. At the 8K prompt the ladder lands +on 4.393 / 2.903 / 6.449 / 6.450 ms, which on the two qwen3 shapes is exactly +what removing the 256-row rung buys: level on the up-projection and 1.06× on the +down-projection. + +Sweep rows are comparable *with each other*, not with the perf tables above: the +same shape, batch and configuration reads 3.296 ms in +`test_perf_prefill_compute_bound` and 3.518–3.585 ms in the tile sweep, because +the perf test times W4A16 immediately before W4A8 on the same workload while a +sweep starts each shape from a freshly built case. Every conclusion in this +section is drawn from differences inside one sweep. One caveat that has not changed: the ladder compares `total_tokens / E`, the *average* rows/expert, so a skewed routing that averages 384 can still leave individual experts with very different tile counts. -The table above is measured at 384 rows/expert, where the padding gate keeps the -256-row tile *out*, so the `256x*` columns there are the cost of padding rather -than a verdict on the tile. The routing that actually selects it is the 8K -prompt (512 rows/expert on Qwen3-MoE, an exact multiple of 256), and -`test_perf_prefill_tile_sweep_long_seq` is the sweep that measures the rung the -ladder picks there against its alternatives — the one open question left in the -tile ladder, since the only prior evidence for `TileM = 256` is the older run at -256 rows/expert (1.3–3.9% ahead on all four shapes). - ### Prefill activation quantization | shape | scalar | vectorized (default) | speedup | |---|---|---|---| -| qwen3 up | 4.806 ms | **4.574 ms** | 1.05× | -| qwen3 down | 3.120 ms | **2.769 ms** | 1.13× | -| minimax up | 9.959 ms | **8.981 ms** | 1.11× | -| minimax down | 9.581 ms | **9.221 ms** | 1.04× | +| qwen3 up | 3.633 ms | **3.255 ms** | 1.12× | +| qwen3 down | 2.437 ms | **2.221 ms** | 1.10× | +| minimax up | 7.704 ms | **6.702 ms** | 1.15× | +| minimax down | 7.111 ms | **6.637 ms** | 1.07× | Quantizing the routed activations is a streaming pass over `[T, K]` next to a -GEMM that already moves ~400 MB, and it is worth 4–13% of the whole call purely +GEMM that already moves ~400 MB, and it is worth 7–15% of the whole call purely by issuing 256-byte loads and 128-byte stores instead of 32-byte and 16-byte ones. `ARK_MOE_W4A8_ACT_QUANT_VEC=0` restores the scalar mapping. How many of those wide loads a work-item keeps *outstanding* is the separate knob `ARK_MOE_W4A8_ACT_QUANT_UNROLL` (1, 2, or 4 = default). Only minimax up is a real A/B for it — the other three shapes take the single-pass kernel below, -where `UNROLL` is dead code — and there the default wins: **8.959 ms** at 4, -8.967 ms at 2, 9.139 ms at 1. +where `UNROLL` is dead code — and there the three depths read 6.982 ms at 1, +6.795 ms at 2 and 6.837 ms at 4. Batching the loads is worth 1.02–1.03× over +`UNROLL = 1`; the 0.6% between 2 and 4 is far inside the 3.3–7.3% those dead-code +rows spread in the same sweep, so the default stays at 4 (the previous run had it +8.959 ms at 4, 8.967 at 2, 9.139 at 1). ### Prefill single-pass activation quantization | shape | K | two-pass | single-pass (default) | speedup | |---|---|---|---|---| -| qwen3 up | 2048 | 4.455 ms | **4.416 ms** | 1.01× | -| qwen3 down | 768 | 2.836 ms | **2.694 ms** | 1.05× | -| minimax down | 1536 | **9.232 ms** | 9.244 ms | 1.00× | -| minimax up | 3072 | 8.991 ms | 8.962 ms | — (not eligible) | +| qwen3 up | 2048 | 3.401 ms | **3.269 ms** | 1.04× | +| qwen3 down | 768 | 2.399 ms | **2.262 ms** | 1.06× | +| minimax down | 1536 | 6.752 ms | **6.645 ms** | 1.02× | +| minimax up | 3072 | 6.989 ms | 6.948 ms | — (not eligible) | This was the one change with real downside risk: the row is held in registers between the absmax and the quantize pass, and a spill would have made the pass slower rather than faster. It does not spill. minimax up's `K = 3072` is past the 16-vector rung, so both of its rows run the same two-pass kernel and their -0.3% gap is noise. +0.6% gap is this sweep's own noise control. ### Prefill store | shape | scalar store | 2D block store (default) | speedup | |---|---|---|---| -| qwen3 up | 5.111 ms | **4.388 ms** | 1.16× | -| qwen3 down | 3.603 ms | **2.669 ms** | 1.35× | -| minimax up | 9.604 ms | **8.570 ms** | 1.12× | -| minimax down | 10.837 ms | **9.048 ms** | 1.20× | +| qwen3 up | 3.884 ms | **3.395 ms** | 1.14× | +| qwen3 down | 2.850 ms | **2.349 ms** | 1.21× | +| minimax up | 7.808 ms | **7.133 ms** | 1.09× | +| minimax down | 7.814 ms | **6.721 ms** | 1.16× | The largest single prefill win of the set, and it is the epilogue rather than the mainloop: a 32×32 sub-group fragment goes out in a handful of block messages instead of 64 half-cache-line scalar ones. The ordering follows the argument — qwen3 down, whose 12-k-tile mainloop amortizes the epilogue least and whose D is -as large as its weights, gains the most. +as large as its weights, gains the most. (The previous run read 1.16 / 1.35 / +1.12 / 1.20×, same ordering.) ### Prefill epilogue guard | shape | guarded | interior-tile (default) | speedup | |---|---|---|---| -| qwen3 up | **4.428 ms** | 4.466 ms | 0.99× | -| qwen3 down | 2.840 ms | **2.625 ms** | 1.08× | -| minimax up | 8.971 ms | **8.794 ms** | 1.02× | -| minimax down | **8.990 ms** | 9.020 ms | 1.00× | +| qwen3 up | 3.573 ms | **3.473 ms** | 1.03× | +| qwen3 down | 2.434 ms | **2.341 ms** | 1.04× | +| minimax up | 6.729 ms | **6.703 ms** | 1.00× | +| minimax down | 6.635 ms | **6.569 ms** | 1.01× | The mainloop is identical in both columns; only the store differs, so this is the cost of ~4 instructions per output element. It is largest exactly where the mainloop is shortest — qwen3 down runs 12 k-tiles per tile at `K = 768` — which -is the shape the ordering argument predicted, and the two rows that come out -behind do so by 0.9% and 0.3%, inside the noise floor. -`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` restores the guarded epilogue; the two are -bit-identical. +is the shape the ordering argument predicted, and the two minimax rows come out +level, as a change confined to the epilogue should on the shapes that amortize +it best. `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` restores the guarded epilogue; the +two are bit-identical. ### Decode chunk width and column blocking @@ -805,7 +853,7 @@ nothing else, so the remaining gap is message efficiency, not arithmetic. | `ARK_MOE_W4A8_DECODE_KSPLIT` | Coalesced K-split decode mapping; **on by default**. Set to `0` to fall back to the original one-work-item-per-output GEMV (useful for A/B measurements). Ignored when the shape doesn't qualify. | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | Output columns per sub-group in the K-split mapping: `1`, `2` (default) or `4`. Higher values amortize the activation loads over more columns but need `N % (16 × NCOLS) == 0`. `2` is the measured default, see [Tuned defaults](#tuned-defaults-measured). | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | K elements (= bytes) a lane loads per chunk: `16` (default) or `32`. `32` halves the number of memory messages and doubles the bytes a thread keeps in flight, at the cost of GRF; it needs a re-scale block of at least 512 and silently falls back to `16` otherwise. Measured slower than `16` on every swept shape, so it is a sweep point rather than a recommendation. | -| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, then the 256-row tile when it pads no further than the 128-row one would (`⌈M/256⌉·256 == ⌈M/128⌉·128`) and the 128-row tile otherwise, each 256 wide in N when `N % 256 == 0` (see [Tuned defaults](#tuned-defaults-measured)). Forcing a tile the ladder would not pick costs up to 1.30× at 384 rows/expert. | +| `ARK_MOE_W4A8_PREFILL_TILE` | Force a prefill work-group tile: `8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`. Unset (default) uses the ladder: `< 16` rows/expert → `8x128`, `< 128` → `64x128`, otherwise the 128-row tile, 256 wide in N when `N % 256 == 0` (see [Tuned defaults](#tuned-defaults-measured)). The 256-row tiles stay compiled but are no longer reachable from the ladder; forcing one costs up to 1.45×. | | `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.04–1.13× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default, measured fastest). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized *two-pass* mapping: the single-pass kernel below issues the whole row at once and ignores this. | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | @@ -845,21 +893,21 @@ pass on device, so each optimization is checked against its predecessor as well as timed. Still to run on device: the accuracy sweep against the fp32 reference, which -will catch layout/scale bugs immediately, and the two 8K-prompt prefill cases -(`test_perf_prefill_long_seq`, `test_perf_prefill_tile_sweep_long_seq`) — they -add no new kernel code, only a routing the suite did not measure at, but the -`256x256` rung the ladder takes there has not been timed against its -alternatives at a non-padding routing. +will catch layout/scale bugs immediately. The two 8K-prompt prefill cases +(`test_perf_prefill_long_seq`, `test_perf_prefill_tile_sweep_long_seq`) have now +run, and they settled the one open question in the ladder: at 512 rows/expert — +the only routing where a 256-row tile pads no more than a 128-row one — the +256-row tile is not ahead, so the rung is gone rather than gated. Three prefill changes used to be listed here as reasoned-through but unmeasured, because the authoring environment has no XPU and no SYCL compiler. All three -have now been timed, and all three kept their default: +have now been timed twice, and all three kept their default: | Change | Revert with | Measured | |---|---|---| -| Activation quantizer's batched loads — `UNROLL` vectors in flight instead of one | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 1.02× at `UNROLL = 4` on the only shape that exercises it | -| Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.05×; the register-resident row does not spill | -| 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.12–1.35×, the largest single prefill win | +| Activation quantizer's batched loads — `UNROLL` vectors in flight instead of one | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 1.02–1.03× at `UNROLL = 2` or `4` on the only shape that exercises it; 2 vs 4 is inside the noise | +| Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×; the register-resident row does not spill | +| 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×, the largest single prefill win | The 2D store was previously listed as needing a device rather than a flag, on the grounds that the sibling MoE kernels reach it through @@ -889,18 +937,22 @@ K — but that is a mainloop change and wants a device to develop against. ### Where the remaining prefill headroom is -At the ladder's choice the four compute-bound shapes read 88.2 / 62.1 / 99.7 / -103.7 TFLOPS, i.e. 56–73% of their bandwidth ceilings, so the headroom splits -into traffic the call still moves and ceiling the routing sets: +At the compute-bound batch the four shapes read 93.8 / 66.8 / 101.0 / 104.8 +TFLOPS, i.e. 61–76% of their bandwidth ceilings, so the headroom splits into +traffic the call still moves and ceiling the routing sets: | Lead | What it would change | Where it shows | |---|---|---| | Fusing the activation quantization into the GEMM's A-tile load | Deletes 2 of the 5 streams (the int8 copy written, then read back) — 14% of the traffic at `K = 768`, 21% at `K = 2048`, 22% at `K = 3072` | Every shape; it is the largest single item left | -| Routing more rows per expert | Nothing in the kernel — it *raises* the ceiling, because the weight stream is the only one that does not grow with the token count | The 8K prompt is exactly this experiment for Qwen3-MoE: 512 rows/expert lifts the ceilings from 129 / 112 to 145 / 123 TFLOPS | -| The `256x256` rung the 8K prompt selects | Halves how often B is re-read per M tile, on top of the N-tile saving already taken | `test_perf_prefill_tile_sweep_long_seq`; unmeasured at a non-padding routing since the 256 rows/expert run | +| Routing more rows per expert | Nothing in the kernel — it *raises* the ceiling, because the weight stream is the only one that does not grow with the token count | The 8K prompt is exactly this experiment for Qwen3-MoE: 512 rows/expert lifts the ceilings from 129 / 112 to 145 / 123 TFLOPS, and the measured 98.2 / 70.5 TFLOPS follow | | A single-pass activation quantizer for `K = 3072` | The second read of `[T, K]`, ~450 MB at the compute-bound batch | minimax up only; its row is 96 dwords per lane, past the 16-vector rung | -`qwen3 down` (`N = 2048, K = 768`) stays the outlier at ~62–69 TFLOPS: 12 +A fourth lead has been closed: a 256-row tile halves how often B is re-read per +M tile, but the 8K-prompt sweep timed it at the only routing where it pads no +more than a 128-row tile and it came out level at best (see +[Prefill tile](#prefill-tile)), so the ladder no longer reaches for it. + +`qwen3 down` (`N = 2048, K = 768`) stays the outlier at ~64–70 TFLOPS: 12 k-tiles per tile is the shortest mainloop of the four, its output is as large as its weights, and its ceiling is the lowest of the set at every routing. It is also the shape the traffic-side leads above would help most. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index a3ebede814..e61eb7138e 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -124,9 +124,10 @@ kernel 已经跑在 DRAM roofline 上。要在该形状上达到 100 TFLOPS 需 ``` targets [prefill]: prefill compute > 100 TFLOPS - device copy bandwidth probe: 400 GB/s + device copy bandwidth probe: 390 GB/s qwen3 up tokens=1024 rows/E=8.0 4.56 TFLOPS vs 100 -> N/A (bandwidth bound: ...) - qwen3 down tokens=49152 rows/E=384.0 102.40 TFLOPS vs 100 -> PASS (92% of the 112 TFLOPS bandwidth ceiling) + qwen3 down tokens=49152 rows/E=384.0 66.77 TFLOPS vs 100 -> FAIL (61% of the 109 TFLOPS bandwidth ceiling) + minimax down tokens=73728 rows/E=384.0 104.77 TFLOPS vs 100 -> PASS (70% of the 150 TFLOPS bandwidth ceiling) ``` 当设备带宽探测 (每次运行执行一次的大块 device-to-device 拷贝) 表明该路由下目标不 @@ -156,12 +157,18 @@ prefill TFLOPS。而对 MiniMax 的 192 个专家来说,同一条提示词只 在一个模型上更快、在另一个模型上更慢 — 这正是两个模型都要测的原因:决定吞吐的是路 由,而不是序列长度。 -每专家 512 行同时还会改变 **tile 阶梯**。256 行 tile 的门槛是它的 padding 不比 128 -行 tile 更差 (`⌈M/256⌉·256 == ⌈M/128⌉·128`) — 384 行时不成立、512 行时成立 — 因此 -Qwen3-MoE 的形状只有在这里才会走到 `256x256` 这一档。 -`test_perf_prefill_tile_sweep_long_seq` 正是为此在该路由下重跑 tile 扫描:8K 提示词 -下阶梯选中的这一档,从未在一个不产生 padding 的路由上与其它 tile 对比过 (见 -[Prefill tile](#prefill-tile))。 +实测结果正是如此。相对计算受限 batch 的 93.8 / 66.8 / 101.0 / 104.8 TFLOPS,8K 提示 +词读到 98.2 / 70.5 / 93.5 / 95.4 — 两个 qwen3 形状提高约 5%,两个 minimax 形状下降 +7–9%,方向与各自路由的变化一致。而以**占上限的比例**看几乎没有变化 (70 / 59 / 74 / +68% 对 74 / 61 / 76 / 70%),这才是有用的读法:变的是可达的上限,而不是 kernel 与上限 +的距离。 + +每专家 512 行同时还会改变 **tile 阶梯**,这也是这个点要单独跑一遍 tile 扫描的原因。 +256 行 tile 实际调度 `⌈M/256⌉·256` 行,所以在每专家 384 行时只能带着三分之一算力花在 +padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 +`⌈M/256⌉·256 == ⌈M/128⌉·128` 的路由,也就是唯一能公平评判 `TileM = 256` 的地方。 +`test_perf_prefill_tile_sweep_long_seq` 给出了结论 — qwen3 up 上持平、qwen3 down 上落 +后 5.4% — 阶梯中的 256 行档因此被移除 (见 [Prefill tile](#prefill-tile))。 ### 为什么小 batch 下 `vs w4a16` 小于 1.0 @@ -569,126 +576,157 @@ int4 `weights` / `scales` 张量的引用 (缓存 key 基于指针标识,否 ## 实测得到的默认值 下面的每一项默认值都来自上文那块 Arc Pro B60 上的 `-k sweep` 运行 (bf16 激活,decode 为 -8 条 routed 行,prefill 为**每专家 384 行**,即测试套件所用的计算受限 batch)。每种配置在 -计时之前都会先与第一种配置做数值等价性检查。 +8 条 routed 行,prefill 为**每专家 384 行**,即测试套件所用的计算受限 batch);tile 阶梯另 +外还有一次在 8K 提示词路由 (每专家 512 / 341 行) 下的扫描。每种配置在计时之前都会先与第 +一种配置做数值等价性检查。 prefill 路径上已经没有任何未实测的默认值了:单遍激活量化与 2D block store 过去仅凭推导 就默认开启,现在各自都有下面的实测表格。 -**读表须知——噪声下限约为 4%。** unroll sweep 中有三个形状的 K 使其走到单遍量化 kernel, -而 `UNROLL` 在那条路径上是死代码,因此那些行是三组**完全相同**的 kernel:它们的离散度分 -别为 0.4%、2.1%、3.9%。低于约 4% 的差异都应视为运行间波动。 +**读表须知——噪声下限为 2–7%,而且是实测出来的。** 有两个 sweep 自带对照组。unroll +sweep 中有三个形状的 K 使其走到单遍量化 kernel,而 `UNROLL` 在那条路径上是死代码,因此 +那些行是三组**完全相同**的 kernel:本次运行中它们的离散度分别为 3.3%、4.5%、7.3% (上一 +次为 0.4%、2.1%、3.9%)。tile sweep 则每行都含一对重复测量,因为 `auto` 实际启动的就是其 +中某一个显式 tile,这些重复对的离散度为 0.2–1.9%。凡是小于同一张表内对照组离散度的差 +异,都应视为运行间波动;也不要把一张表里的数字与另一张表里的数字直接比较。 ### Prefill tile -| 形状 | `128x128` | `128x256` | `256x128` | `256x256` | -|---|---|---|---|---| -| qwen3 up | **3.457 ms** | 3.507 ms | 4.507 ms | 3.952 ms | -| qwen3 down | 2.512 ms | **2.488 ms** | 2.777 ms | 2.696 ms | -| minimax up | **6.940 ms** | 6.978 ms | 8.895 ms | 8.111 ms | -| minimax down | 7.255 ms | **6.710 ms** | 9.166 ms | 7.779 ms | - -**M 方向的关键是 padding,不是寄存器。** 一个专家会启动 `ceil(M / TileM)` 个**完整** -tile,因此在每专家 384 行时,256 行的 tile 要为 384 行数据调度 512 行——三分之一的 MAC -被浪费掉——而 128 行的 tile 恰好是三个。`256x*` 两列 1.11–1.30× 的落后全部由此而来:在 -两个长 K 形状上 (主循环占主导),实测比值 (qwen3 up 1.30×、minimax up 1.28×) 在噪声范围 -内**就等于** padding 比 512/384 = 1.33。这并不是反对 `TileM = 256`——上一次每专家 256 -行 (tile 恰好整除行数) 的运行中,它在四个形状上都领先 1.3–3.9%。 - -所以这一档的判据从"行数阈值"改成了"padding":仅当 -`ceil(M/256)·256 == ceil(M/128)·128`,即 256 行的 tile 启动的行数不比 128 行的 tile 更多 -时,才取 256 行的 tile。M = 256、400、512、2048 时成立;M = 384 时不成立——而那正是过去 -掉下悬崖的那个 batch。 - -**N 方向现在没有代价了。** 只要表格能对比,256 宽的 tile 都不落后:在 `TileM = 128` 时 -它以 1.08× 拿下 minimax down,另外三个持平 (双向差异均在 1.4% 以内);在 `TileM = 256` -时它以 2.9–15.1% 拿下全部四个。第一次 sweep 在 256 宽 N tile 上看到的 35–50% 悬崖来自 -float C 影子 (参见 +计算受限 batch 下 (两个模型都是每专家 384 行): + +| 形状 | `auto` | `128x128` | `128x256` | `256x128` | `256x256` | +|---|---|---|---|---|---| +| qwen3 up | 3.540 ms | **3.518 ms** | 3.585 ms | 4.404 ms | 3.970 ms | +| qwen3 down | 2.472 ms | 2.473 ms | **2.432 ms** | 2.696 ms | 2.547 ms | +| minimax up | 6.976 ms | **6.823 ms** | 6.878 ms | 8.899 ms | 8.019 ms | +| minimax down | **6.749 ms** | 7.227 ms | 6.874 ms | 9.096 ms | 7.823 ms | + +8K 提示词下 (Qwen3-MoE 每专家 512 行,MiniMax 341 行): + +| 形状 | 每专家行数 | `auto` | `128x128` | `128x256` | `256x128` | `256x256` | +|---|---|---|---|---|---|---| +| qwen3 up | 512 | **4.371 ms** | 4.382 ms | 4.393 ms | 4.468 ms | 4.394 ms | +| qwen3 down | 512 | 3.075 ms | 3.030 ms | **2.903 ms** | 3.025 ms | 3.059 ms | +| minimax up | 341 | 6.564 ms | 6.673 ms | **6.449 ms** | 9.057 ms | 7.744 ms | +| minimax down | 341 | 6.466 ms | 6.725 ms | **6.450 ms** | 9.373 ms | 7.307 ms | + +**`auto` 是对照组,不是候选项。** 它启动的就是阶梯选中的那个显式 tile — 上面除第二张表 +中两个 qwen3 行 (当时阶梯还有 256 行档、选中 `256x256`) 之外都是 `128x256` — 因此每一行 +里都含一对重复测量,即同一个 kernel 被测了两次。八对重复的两次读数相差 0.2–1.9%,这就是 +读这两张表时应参照的运行间波动下限;它比 unroll sweep 中相同 kernel 的行 (本次 3.3–7.3%) +更严格,因为它就测在它所限定的那次扫描内部。 + +**M 方向的关键是 padding、不是寄存器——而一旦去掉 padding,它也就没有优势了。** 一个专 +家会启动 `ceil(M / TileM)` 个**完整** tile,因此在每专家 384 行时,256 行的 tile 要为 +384 行数据调度 512 行 (三分之一的 MAC 被浪费掉),而 128 行的 tile 恰好是三个;在每专家 +341 行时更差 (512/341 = 1.5)。所有存在 padding 的行中 `256x*` 那 1.05–1.45× 的落后全部 +由此而来:在两个长 K 形状上 (主循环占主导),同口径的比值 (qwen3 up 1.25×、minimax up +1.30×,均取 `TileN = 128`) 在噪声范围内**就等于** padding 比 512/384 = 1.33。 + +8K 提示词为 Qwen3-MoE 消去了这一项 — 每专家 512 行正好是 256 的整数倍,两种 tile 调度的 +行数相同 — 而且这是整个套件中唯一这样的路由。在 `TileN` 相同的口径下,256 行 tile 在 +qwen3 up 上读到 −2.0% / 0.0% (`TileN` 为 128 / 256)、在 qwen3 down 上读到 +0.2% / +−5.4%:最好也只是持平,而在主循环最短的那个形状上落后 5.4%。把每个 M tile 重复读取 B 的 +次数减半确实省了访存,但它所需的 512 线程 work-group 又在调度粒度上把这点收益还了回去。 + +所以这一档是被**移除**,而不是加条件保留。唯一曾对它有利的读数来自更早那次每专家 256 行 +的运行 (领先 1.3–3.9%,本身就在噪声下限之内);它从未被实测赢过;而且阶梯只能看到 +`total_tokens / E` 这个**平均**每专家行数,因此即便平均值能整除,路由不均衡时各个专家仍 +会掉回 padding 悬崖。两个 256 行的 policy 仍然保留编译,可用 +`ARK_MOE_W4A8_PREFILL_TILE` 选择,以便在寄存器预算不同的设备上重新扫描。 + +**N 方向没有代价。** 只要表格能对比,256 宽的 tile 都不落后:在每专家 384 行时它以 +1.05× 拿下 minimax down、以 1.02× 拿下 qwen3 down,另外两个落后 0.8–1.9% (在下限之 +内);在 8K 提示词下它以 3.5–4.4% 拿下四个中的三个,并与 qwen3 up 持平。第一次 sweep 在 +256 宽 N tile 上看到的 35–50% 悬崖来自 float C 影子 (参见 [Prefill:访存消息宽度与寄存器压力](#prefill访存消息宽度与寄存器压力));第二次 sweep 中 残留的部分——三个形状上落后 0–8%——是在 epilogue 仍用**标量** store 时测得的,而当一个 32×64 的 fragment 改用少数几条 block 消息 (而非 128 条标量消息) 送出之后,它就消失了。 因此只要 `N % 256 == 0` (所有已发布的 N:1536 / 2048 / 3072) 阶梯就取 256 宽,否则取 128 宽——在那些形状上更宽的 tile 只会带来 padding。 -于是完整的阶梯为:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,随后在 256 行 tile 不 -引入额外 padding 时取 `256x256` / `256x128`,否则取 `128x256` / `128x128`;两种情况下 N -的选择都由 `N % 256 == 0` 决定。六个 policy 全部保留编译,并可通过 +于是完整的阶梯为:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,其余情况下 +`N % 256 == 0` 时取 `128x256`、否则取 `128x128`。六个 policy 全部保留编译,并可通过 `ARK_MOE_W4A8_PREFILL_TILE` 手动选择。 -在阶梯选中的 tile 下,被扫描的四个形状达到 3.507 / 2.488 / 6.978 / 6.710 ms,即 -88.2 / 62.1 / 99.7 / 103.7 TFLOPS;而改动之前的阶梯为 69.9 / 52.7 / 76.9 / 75.5 -(1.26× / 1.18× / 1.30× / 1.37×),并且现在每个形状都在最快 tile 的 1.4% 以内。四个形状中 -有三个达到或接近 (1% 以内) 100 TFLOPS 的目标;qwen3 down 仍是例外,只有 62 TFLOPS—— -`K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此占了相当大的比例。 +在阶梯选中的 tile 下,被扫描的四个形状在计算受限 batch 上达到 3.585 / 2.432 / 6.878 / +6.874 ms,即 86.3 / 63.6 / 101.2 / 101.2 TFLOPS;而两个版本之前的阶梯为 +69.9 / 52.7 / 76.9 / 75.5 (1.23× / 1.21× / 1.31× / 1.34×),并且现在每个形状都在最快 +tile 的 1.9% 以内。四个形状中有两个达到 100 TFLOPS 的目标;qwen3 down 仍是例外,只有 +64 TFLOPS——`K = 768` 时一个 tile 只有 12 个 k-tile,epilogue 与 prologue 因此占了相当大 +的比例。在 8K 提示词下阶梯落在 4.393 / 2.903 / 6.449 / 6.450 ms,两个 qwen3 形状上的变 +化正是移除 256 行档所带来的:up 投影持平,down 投影快 1.06×。 + +sweep 的各行只能**互相**比较,不能与上文的性能表比较:同样的形状、batch 与配置,在 +`test_perf_prefill_compute_bound` 中读到 3.296 ms,而在 tile sweep 中读到 +3.518–3.585 ms——因为性能测试会在同一负载上紧接着 W4A16 之后计时 W4A8,而 sweep 每个形状 +都是从新建的用例开始。本节的所有结论都取自同一次扫描内部的差异。 有一点没有变:阶梯比较的是 `total_tokens / E` 这个**平均**每专家行数,因此在路由不均衡 时,即便平均为 384 行,各个专家的 tile 数仍可能相差很大。 -上表是在每专家 384 行下测得的,而 padding 判据在那里恰好把 256 行的 tile **挡在门外**, -所以那两列 `256x*` 反映的是 padding 的代价,而不是对该 tile 本身的结论。真正会选中它的 -路由是 8K 提示词 (Qwen3-MoE 每专家 512 行,正好是 256 的整数倍), -`test_perf_prefill_tile_sweep_long_seq` 就是在那里把阶梯选中的这一档与其它 tile 对比的 -扫描 — 这也是 tile 阶梯中仅剩的未决问题,因为 `TileM = 256` 目前唯一的证据来自更早那次 -每专家 256 行的运行 (四个形状上领先 1.3–3.9%)。 - ### Prefill 激活量化 | 形状 | 标量 | 向量化 (默认) | 加速比 | |---|---|---|---| -| qwen3 up | 4.806 ms | **4.574 ms** | 1.05× | -| qwen3 down | 3.120 ms | **2.769 ms** | 1.13× | -| minimax up | 9.959 ms | **8.981 ms** | 1.11× | -| minimax down | 9.581 ms | **9.221 ms** | 1.04× | +| qwen3 up | 3.633 ms | **3.255 ms** | 1.12× | +| qwen3 down | 2.437 ms | **2.221 ms** | 1.10× | +| minimax up | 7.704 ms | **6.702 ms** | 1.15× | +| minimax down | 7.111 ms | **6.637 ms** | 1.07× | 量化 routed 激活只是对 `[T, K]` 的一次流式遍历,而与它并行的 GEMM 本身就要搬运约 400 MB;仅仅把 32 字节 load / 16 字节 store 换成 256 字节 / 128 字节,就能带来整次调用 -4–13% 的收益。`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射。 +7–15% 的收益。`ARK_MOE_W4A8_ACT_QUANT_VEC=0` 可恢复标量映射。 一个 work-item 能让多少条这样的宽 load 同时**在途**,则由另一个开关 `ARK_MOE_W4A8_ACT_QUANT_UNROLL` 控制 (1、2、4 = 默认值)。只有 minimax up 是它真正的 -A/B——另外三个形状走的是下面的单遍 kernel,`UNROLL` 在那里是死代码——而在它上面默认值获 -胜:4 时 **8.959 ms**,2 时 8.967 ms,1 时 9.139 ms。 +A/B——另外三个形状走的是下面的单遍 kernel,`UNROLL` 在那里是死代码——在它上面三档分别读 +到 1 时 6.982 ms、2 时 6.795 ms、4 时 6.837 ms。把 load 批量发出相对 `UNROLL = 1` 值 +1.02–1.03×;而 2 与 4 之间 0.6% 的差距,远小于同一次扫描中那些死代码行 3.3–7.3% 的离散 +度,因此默认值仍保持为 4 (上一次运行中它是 4 时 8.959 ms、2 时 8.967 ms、1 时 +9.139 ms)。 ### Prefill 单遍激活量化 | 形状 | K | 两遍 | 单遍 (默认) | 加速比 | |---|---|---|---|---| -| qwen3 up | 2048 | 4.455 ms | **4.416 ms** | 1.01× | -| qwen3 down | 768 | 2.836 ms | **2.694 ms** | 1.05× | -| minimax down | 1536 | **9.232 ms** | 9.244 ms | 1.00× | -| minimax up | 3072 | 8.991 ms | 8.962 ms | —(不适用该路径) | +| qwen3 up | 2048 | 3.401 ms | **3.269 ms** | 1.04× | +| qwen3 down | 768 | 2.399 ms | **2.262 ms** | 1.06× | +| minimax down | 1536 | 6.752 ms | **6.645 ms** | 1.02× | +| minimax up | 3072 | 6.989 ms | 6.948 ms | —(不适用该路径) | 这是所有改动中唯一存在真实下行风险的一项:激活行在 absmax 与量化两遍之间保存在寄存器里, 一旦溢出,这一遍就会变慢而不是变快。实测没有溢出。minimax up 的 `K = 3072` 超过了 16 个 -向量的门限,因此它那两行跑的是同一个两遍 kernel,0.3% 的差异是噪声。 +向量的门限,因此它那两行跑的是同一个两遍 kernel,0.6% 的差异正是这次扫描自带的噪声对照。 ### Prefill store | 形状 | 标量 store | 2D block store (默认) | 加速比 | |---|---|---|---| -| qwen3 up | 5.111 ms | **4.388 ms** | 1.16× | -| qwen3 down | 3.603 ms | **2.669 ms** | 1.35× | -| minimax up | 9.604 ms | **8.570 ms** | 1.12× | -| minimax down | 10.837 ms | **9.048 ms** | 1.20× | +| qwen3 up | 3.884 ms | **3.395 ms** | 1.14× | +| qwen3 down | 2.850 ms | **2.349 ms** | 1.21× | +| minimax up | 7.808 ms | **7.133 ms** | 1.09× | +| minimax down | 7.814 ms | **6.721 ms** | 1.16× | 这是这组改动中 prefill 收益最大的一项,而且它出在 epilogue 而不是主循环:一个 32×32 的 sub-group fragment 由少数几条 block 消息送出,取代了 64 条只有半条 cache line 的标量消 息。名次也符合推理——qwen3 down 的主循环只有 12 个 k-tile、对 epilogue 的摊薄最少,且它 -的 D 与权重一样大,因此收益最大。 +的 D 与权重一样大,因此收益最大。(上一次运行读到 1.16 / 1.35 / 1.12 / 1.20×,名次相同。) ### Prefill epilogue 边界保护 | 形状 | 带保护 | 内部 tile 快速路径 (默认) | 加速比 | |---|---|---|---| -| qwen3 up | **4.428 ms** | 4.466 ms | 0.99× | -| qwen3 down | 2.840 ms | **2.625 ms** | 1.08× | -| minimax up | 8.971 ms | **8.794 ms** | 1.02× | -| minimax down | **8.990 ms** | 9.020 ms | 1.00× | +| qwen3 up | 3.573 ms | **3.473 ms** | 1.03× | +| qwen3 down | 2.434 ms | **2.341 ms** | 1.04× | +| minimax up | 6.729 ms | **6.703 ms** | 1.00× | +| minimax down | 6.635 ms | **6.569 ms** | 1.01× | 两列的主循环完全相同,只有 store 不同,因此这就是每个输出元素约 4 条指令的代价。它在主 循环最短的形状上占比最大——`K = 768` 的 qwen3 down 每个 tile 只跑 12 个 k-tile——正是当 -初按指令数推理所预期的那个形状;而落后的那两行只差 0.9% 与 0.3%,在噪声下限之内。 -`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 可切回带保护的 epilogue;两者逐位相同。 +初按指令数推理所预期的那个形状;而两个 minimax 形状读到持平,这也正是一项只改动 epilogue +的优化在最能摊薄它的形状上应有的表现。`ARK_MOE_W4A8_PREFILL_FULL_TILE=0` 可切回带保护的 +epilogue;两者逐位相同。 ### Decode 的 chunk 宽度与列分块 @@ -717,7 +755,7 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_DECODE_KSPLIT` | 合并访存的 K-split decode 映射,**默认开启**。设为 `0` 可回退到原来每个输出一个 work-item 的 GEMV (便于 A/B 对比)。形状不满足条件时该开关无效。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` | K-split 映射中每个 sub-group 处理的输出列数:`1`、`2` (默认) 或 `4`。取值越大,激活数据的加载可以摊到更多列上,但要求 `N % (16 × NCOLS) == 0`。默认值 `2` 来自实测,参见[实测得到的默认值](#实测得到的默认值)。 | | `ARK_MOE_W4A8_DECODE_KSPLIT_CH` | 每个 lane 每次加载的 K 元素数 (即字节数):`16` (默认) 或 `32`。`32` 可以把访存指令数减半、并让每个线程同时在途的字节数翻倍,代价是更多 GRF;它要求 re-scale block 至少为 512,否则会自动回退到 `16`。实测中它在所有形状上都慢于 `16`,因此只作为扫描项而非推荐值。 | -| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,随后在 256 行 tile 的 padding 不多于 128 行 tile 时 (`⌈M/256⌉·256 == ⌈M/128⌉·128`) 取 256 行 tile、否则取 128 行 tile;两种情况下只要 `N % 256 == 0` 就取 256 宽 (参见[实测得到的默认值](#实测得到的默认值))。在每专家 384 行时,强制选用阶梯不会选的 tile 最多会慢 1.30×。 | +| `ARK_MOE_W4A8_PREFILL_TILE` | 强制指定 prefill 的 work-group tile:`8x128`、`64x128`、`128x128`、`128x256`、`256x128`、`256x256`。不设置 (默认) 时按 tile 阶梯自动选择:每专家 `< 16` 行 → `8x128`,`< 128` → `64x128`,其余情况取 128 行 tile,且只要 `N % 256 == 0` 就取 256 宽 (参见[实测得到的默认值](#实测得到的默认值))。两个 256 行的 tile 仍保留编译,但阶梯已不会选中它们;强制指定最多会慢 1.45×。 | | `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.04–1.13× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认,实测最快)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化的**两遍**映射生效:下面的单遍 kernel 一次性发出整行,会忽略这个开关。 | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | @@ -753,19 +791,20 @@ W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 在设备上通过,因此每一项优化既有计时数据,也都与各自的前身做过比对。 仍需在设备上运行的部分:与 fp32 参考实现对比的精度扫描,它能立刻暴露 layout / -scale 相关的 bug;以及两个 8K 提示词的 prefill 用例 -(`test_perf_prefill_long_seq`、`test_perf_prefill_tile_sweep_long_seq`) — 它们没有新增 -任何 kernel 代码,只是补上了套件此前没有测过的一种路由,但阶梯在那里选中的 `256x256` -一档,尚未在不产生 padding 的路由上与其它 tile 对比过。 +scale 相关的 bug。两个 8K 提示词的 prefill 用例 +(`test_perf_prefill_long_seq`、`test_perf_prefill_tile_sweep_long_seq`) 现已跑过,并且 +解决了阶梯中仅剩的那个未决问题:在每专家 512 行 — 唯一一种 256 行 tile 的 padding 不多 +于 128 行 tile 的路由 — 上,256 行 tile 并不占优,因此这一档是被移除、而不是加条件保留。 本节此前列有三项"只经过推导、既未实测计时也尚未在设备上运行"的 prefill 改动,因为编写 -它们的环境既没有 XPU 也没有 SYCL 编译器。这三项现在都已实测,并且都保持了原有默认值: +它们的环境既没有 XPU 也没有 SYCL 编译器。这三项现在都已实测两次,并且都保持了原有默认 +值: | 改动 | 回退方式 | 实测结果 | |---|---|---| -| 激活量化的批量 load——同时挂起 `UNROLL` 个请求而不是一个 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 在唯一真正走这条路径的形状上,`UNROLL = 4` 快 1.02× | -| 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.05×;留在寄存器里的行并未溢出 | -| D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.12–1.35×,prefill 单项收益最大 | +| 激活量化的批量 load——同时挂起 `UNROLL` 个请求而不是一个 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 在唯一真正走这条路径的形状上,`UNROLL = 2` 或 `4` 快 1.02–1.03×;2 与 4 之间的差异在噪声内 | +| 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×;留在寄存器里的行并未溢出 | +| D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×,prefill 单项收益最大 | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** @@ -789,17 +828,19 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo ### prefill 还剩下多少空间 -在阶梯选中的 tile 下,四个受算力约束的形状为 88.2 / 62.1 / 99.7 / 103.7 TFLOPS,即各自 -带宽天花板的 56–73%,因此剩余空间分成两部分:这次调用仍在搬的流量,以及路由所决定的天 -花板。 +在受算力约束的 batch 上,四个形状为 93.8 / 66.8 / 101.0 / 104.8 TFLOPS,即各自带宽天花 +板的 61–76%,因此剩余空间分成两部分:这次调用仍在搬的流量,以及路由所决定的天花板。 | 方向 | 会改变什么 | 体现在哪里 | |---|---|---| | 把激活量化融合进 GEMM 的 A-tile 加载 | 消掉 5 条数据流中的 2 条 (int8 副本的写、以及随后的读回) — `K = 768` 时占 14%、`K = 2048` 时 21%、`K = 3072` 时 22% | 所有形状;这是仍未做的最大一项 | -| 让每个专家分到更多行 | kernel 里什么都不用改 — 它*抬高*的是天花板,因为只有权重这一条流不随 token 数增长 | 8K 提示词对 Qwen3-MoE 正是这个实验:每专家 512 行把天花板从 129 / 112 抬到 145 / 123 TFLOPS | -| 8K 提示词会选中的 `256x256` 一档 | 在已经拿到的 N tile 收益之上,进一步把每个 M tile 重复读 B 的次数减半 | `test_perf_prefill_tile_sweep_long_seq`;自每专家 256 行那次运行以来,没有在不产生 padding 的路由上测过 | +| 让每个专家分到更多行 | kernel 里什么都不用改 — 它*抬高*的是天花板,因为只有权重这一条流不随 token 数增长 | 8K 提示词对 Qwen3-MoE 正是这个实验:每专家 512 行把天花板从 129 / 112 抬到 145 / 123 TFLOPS,实测的 98.2 / 70.5 TFLOPS 也随之上移 | | `K = 3072` 的单遍激活量化 | 省掉对 `[T, K]` 的第二次读,在受算力约束的 batch 下约 450 MB | 仅 minimax up;它的一行是每 lane 96 个 dword,超过了 16 向量那一档 | -`qwen3 down` (`N = 2048, K = 768`) 仍是那个异常值,只有约 62–69 TFLOPS:每个 tile 只有 +还有第四个方向已经收敛:256 行的 tile 能把每个 M tile 重复读 B 的次数减半,但 8K 提示词 +的扫描已经在唯一一种它不比 128 行 tile 多 padding 的路由上测过它,结果最好也只是持平 +(参见 [Prefill tile](#prefill-tile)),因此阶梯不再选用它。 + +`qwen3 down` (`N = 2048, K = 768`) 仍是那个异常值,只有约 64–70 TFLOPS:每个 tile 只有 12 个 k-tile,是四者中最短的主循环,其输出与权重一样大,而它在任何路由下的天花板也都是 四者中最低的。它同时也是上面这些访存侧改动收益最大的形状。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 6c083e63b0..1cd0f94d45 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -388,11 +388,12 @@ def _models_option(request): # for qwen3 up/down, and 137 -> 129 and 154 -> 145 for minimax up/down. # # 512 rows/expert also moves the *tile ladder*, which is why this point is -# worth a sweep of its own rather than one more perf row: the 256-row tile is -# gated on padding no worse than the 128-row one -# (``ceil(M/256)*256 == ceil(M/128)*128``), false at 384 and true at 512, so -# the Qwen3-MoE shapes take the ``256x256`` rung here and nowhere else in the -# suite. +# worth a sweep of its own rather than one more perf row: it is the only +# routing in the suite at which the 256-row tile does not pad +# (``ceil(M/256)*256 == ceil(M/128)*128``, false at 384 and true at 512), i.e. +# the only one that can measure `TileM = 256` on its merits rather than on its +# padding. ``test_perf_prefill_tile_sweep_long_seq`` did, and it came out level +# or behind, which is why the ladder no longer has a 256-row rung. _PREFILL_LONG_SEQ_LEN = 8192 @@ -1444,13 +1445,14 @@ def test_perf_prefill_tile_sweep_long_seq(self, request): """Time every prefill work-group tile at the 8K-prompt routing. The tile sweep above runs at the derived compute-bound batch, where - every model sits at 384 rows per expert -- a routing the 256-row - tile is *excluded* from, because it would schedule 512 rows for 384 - rows of data. A fixed 8K prompt is the routing that changes that: - Qwen3-MoE's 128 experts get 512 rows each, the padding test - (``ceil(M/256)*256 == ceil(M/128)*128``) turns true, and the ladder - takes its ``256x256`` rung -- the one rung no other case in this - suite reaches. This sweep is what says whether it should. + every model sits at 384 rows per expert -- a routing at which the + 256-row tile can only lose, because it would schedule 512 rows for + 384 rows of data. A fixed 8K prompt is the routing that changes + that: Qwen3-MoE's 128 experts get 512 rows each, an exact multiple + of 256, so both candidate ``TileM`` values schedule the same rows + and the sweep measures the tile rather than its padding. It is the + only case in the suite that can, which is what makes it the + evidence for (or, as it turned out, against) a 256-row rung. """ rows = run_config_sweep( "prefill", @@ -1911,7 +1913,7 @@ def _parse_args(argv): help=( f"Also run the long-prompt prefill point ({_PREFILL_LONG_SEQ_LEN} model tokens, one 8K sequence), " "where Qwen3-MoE routes 512 rows per expert -- a higher intensity than the compute-bound batch, " - "and the only routing at which the tile ladder takes its 256-row rung. With --sweep-configs the " + "and the only routing at which a 256-row tile does not pad. With --sweep-configs the " "prefill tile sweep is repeated there." ), ) From fb2fca6aefd08c8ef59d4820d82c2ba746632127 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:48:31 +0000 Subject: [PATCH 076/112] perf: cut W4A8 prefill traffic with int8-in and fused top-k reduce Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 99 +++- .../ark/auto_round_kernel/ark.cpp | 10 +- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 269 ++++++++-- .../ark/test/test_moe_w4a8_perf.py | 501 +++++++++++++++++- 4 files changed, 817 insertions(+), 62 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index 87528b46bc..9827e69917 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -2532,6 +2532,11 @@ def moe_gemm_w4a8( *, rescale_block_size: Optional[int] = None, phase: str = "auto", + activation_scale: Optional[torch.Tensor] = None, + out_dtype: Optional[torch.dtype] = None, + row_to_token: Optional[torch.Tensor] = None, + routing_weights: Optional[torch.Tensor] = None, + output_rows: Optional[int] = None, ) -> torch.Tensor: """W4A8 MoE GEMM: int4 weights (pre-converted to int8), int8 compute. @@ -2540,8 +2545,31 @@ def moe_gemm_w4a8( the ``s8 x s8 -> s32`` DPAS atom. The output is ``acc * act_scale[token] * weight_scale[n, block]``. + Two optional contracts cut DRAM traffic by moving work across the call + boundary; both default off and change nothing when unused. + + *Pre-quantized activations.* Pass an ``int8`` ``activations`` together with + ``activation_scale`` and the in-call quantization pass is skipped: the + kernel then never reads the 16-bit activations, never writes the int8 copy + and never reads it back (``4 * T * K`` bytes, 27% of the qwen3 + down-projection's traffic). The producer of the activations -- typically + the SiLU/gate elementwise kernel, which already writes ``[T, K]`` once -- + can emit that int8 and its per-row scale directly. + + *Fused top-k reduction.* Pass ``row_to_token``, ``routing_weights`` and + ``output_rows`` and the epilogue scales each routed row by its routing + weight and accumulates it into ``out[row_to_token[row]]`` of a + ``[output_rows, N]`` fp32 tensor, instead of writing the ``[T, N]`` + unreduced result for the caller to reduce afterwards. That replaces + ``T * N`` written + ``T * N`` read + ``batch * N`` written with a + ``batch * N`` read-modify-write, and removes the caller's reduction kernel. + The combiner is a device-scope fp32 atomic add, so the summation order is + not deterministic and the result is *not* bit-identical to reducing the + unfused output. Prefill only. + Args: - activations: ``[total_tokens, K]`` fp16/bf16, rows sorted by expert. + activations: ``[total_tokens, K]`` fp16/bf16, rows sorted by expert, or + ``[total_tokens, K]`` int8 when ``activation_scale`` is given. weights_s8: ``[E, N, K]`` ``torch.int8`` from :func:`moe_w4a8_prepack`. wscales: ``[E, N, K // rescale_block_size]`` fp32 from :func:`moe_w4a8_prepack`. @@ -2550,16 +2578,42 @@ def moe_gemm_w4a8( ``K // wscales.shape[2]``. phase: ``"auto"`` (GEMV for small batches, grouped GEMM otherwise), ``"decode"`` (force GEMV) or ``"prefill"`` (force grouped GEMM). + activation_scale: ``[total_tokens]`` fp32 per-row scales + (``absmax / 127``) for pre-quantized int8 activations. + out_dtype: output dtype for pre-quantized activations, which carry no + floating dtype of their own. Defaults to ``torch.bfloat16``. + row_to_token: ``[total_tokens]`` int32, routed row -> model token. + routing_weights: ``[total_tokens]`` fp32, the top-k weight of each row. + output_rows: number of model tokens, i.e. rows of the fused output. Returns: - ``[total_tokens, N]`` in the activations dtype. + ``[total_tokens, N]`` in the activations dtype, or ``[output_rows, N]`` + fp32 when the fused reduction is used. """ if phase not in _MOE_VALID_PHASES: raise ValueError(f"phase must be one of {_MOE_VALID_PHASES}, got {phase!r}") if activations.device.type != "xpu": raise NotImplementedError("moe_gemm_w4a8 is only supported on XPU") - if activations.dtype not in (torch.float16, torch.bfloat16): + + prequantized = activation_scale is not None or activations.dtype == torch.int8 + if prequantized: + if activations.dtype != torch.int8: + raise ValueError(f"pre-quantized activations must be int8, got {activations.dtype}") + if activation_scale is None: + raise ValueError("int8 activations require activation_scale") + if activation_scale.dtype != torch.float32: + raise ValueError(f"activation_scale must be fp32, got {activation_scale.dtype}") + if activation_scale.numel() != activations.shape[0]: + raise ValueError( + f"activation_scale has {activation_scale.numel()} entries, expected {activations.shape[0]}" + ) + act_dtype = torch.bfloat16 if out_dtype is None else out_dtype + if act_dtype not in (torch.float16, torch.bfloat16): + raise ValueError(f"out_dtype must be fp16/bf16, got {act_dtype}") + elif activations.dtype not in (torch.float16, torch.bfloat16): raise ValueError(f"activations must be fp16/bf16, got {activations.dtype}") + else: + act_dtype = activations.dtype if activations.ndim != 2: raise ValueError("activations must be 2D [total_tokens, K]") if weights_s8.ndim != 3 or weights_s8.dtype != torch.int8: @@ -2570,6 +2624,8 @@ def moe_gemm_w4a8( activations = activations.contiguous() weights_s8 = weights_s8.contiguous() wscales = wscales.contiguous() + if prequantized: + activation_scale = activation_scale.contiguous() total_tokens, K = activations.shape num_experts, N, weight_K = weights_s8.shape @@ -2597,7 +2653,30 @@ def moe_gemm_w4a8( raise ValueError(f"num_tokens_per_expert length {num_tokens_per_expert.shape[0]} != num_experts {num_experts}") _check_routing_total(num_tokens_per_expert, total_tokens) - outputs = torch.empty((total_tokens, N), device=activations.device, dtype=activations.dtype) + fused = row_to_token is not None or routing_weights is not None or output_rows is not None + if fused: + if row_to_token is None or routing_weights is None or output_rows is None: + raise ValueError("the fused top-k reduction needs row_to_token, routing_weights and output_rows") + if phase != "prefill": + raise ValueError("the fused top-k reduction is prefill-only; pass phase='prefill'") + if int(output_rows) <= 0: + raise ValueError(f"output_rows must be positive (got {output_rows})") + if row_to_token.dtype != torch.int32: + row_to_token = row_to_token.to(torch.int32) + if routing_weights.dtype != torch.float32: + routing_weights = routing_weights.to(torch.float32) + row_to_token = row_to_token.contiguous() + routing_weights = routing_weights.contiguous() + if row_to_token.numel() != total_tokens or routing_weights.numel() != total_tokens: + raise ValueError( + f"row_to_token / routing_weights must have {total_tokens} entries, got " + f"{row_to_token.numel()} / {routing_weights.numel()}" + ) + # The epilogue accumulates into this buffer with atomics, so it starts + # at zero and is fp32 regardless of the activation dtype. + outputs = torch.zeros((int(output_rows), N), device=activations.device, dtype=torch.float32) + else: + outputs = torch.empty((total_tokens, N), device=activations.device, dtype=act_dtype) if total_tokens == 0: return outputs @@ -2605,11 +2684,11 @@ def moe_gemm_w4a8( stream = get_stream(activations) lib.moe_gemm_w4a8( stream, - activations.data_ptr(), + 0 if prequantized else activations.data_ptr(), weights_s8.data_ptr(), wscales.data_ptr(), - outputs.data_ptr(), - cvt_dtype(activations.dtype), + 0 if fused else outputs.data_ptr(), + cvt_dtype(act_dtype), N, K, block, @@ -2617,6 +2696,12 @@ def moe_gemm_w4a8( num_experts, total_tokens, _MOE_VALID_PHASES.index(phase), + activations.data_ptr() if prequantized else 0, + activation_scale.data_ptr() if prequantized else 0, + row_to_token.data_ptr() if fused else 0, + routing_weights.data_ptr() if fused else 0, + outputs.data_ptr() if fused else 0, + int(output_rows) if fused else 0, ) return outputs diff --git a/auto_round_extension/ark/auto_round_kernel/ark.cpp b/auto_round_extension/ark/auto_round_kernel/ark.cpp index 2860f9d14f..b46a5dc6f3 100755 --- a/auto_round_extension/ark/auto_round_kernel/ark.cpp +++ b/auto_round_extension/ark/auto_round_kernel/ark.cpp @@ -544,13 +544,19 @@ static void moe_w4a8_prepack_wrapper(torch_ptr stream, torch_ptr weights_s4, tor rescale_group_size); } +// `qact`/`ascale` are optional pre-quantized activations (0 = quantize in the +// call); `row_to_token`/`routing_weights`/`fused_out` are the optional fused +// top-k reduction (0 = write the unreduced `[T, N]` output). static void moe_gemm_w4a8_wrapper(torch_ptr stream, torch_ptr activations, torch_ptr weights_s8, torch_ptr wscales, torch_ptr outputs, int act_dtype, int N, int K, int rescale_block_size, torch_ptr num_tokens_per_expert, int num_experts, - int total_tokens, int phase) { + int total_tokens, int phase, torch_ptr qact, torch_ptr ascale, + torch_ptr row_to_token, torch_ptr routing_weights, torch_ptr fused_out, + int fused_batch) { ark::moe_gemm_w4a8((sycl::queue*)stream, (void*)activations, (void*)weights_s8, (void*)wscales, (void*)outputs, (BTLA_DTYPE)(act_dtype), N, K, rescale_block_size, (int*)num_tokens_per_expert, num_experts, - total_tokens, phase); + total_tokens, phase, (const void*)qact, (const float*)ascale, (const int*)row_to_token, + (const float*)routing_weights, (float*)fused_out, fused_batch); } static void sage_dynamic_quant(torch_ptr stream, torch_ptr input, torch_ptr bias, torch_ptr output, diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index ad6bd34382..356bfa6b85 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -562,6 +562,29 @@ inline int moe_w4a8_act_quant_unroll() { return kActQuantUnrollDefault; } +// How many k-tiles of A and B the prefill mainloop keeps prefetched ahead of +// the tile it is computing. The prologue issues `prefetch_dist` pairs before +// the first DPAS and the loop then issues one pair per tile, so this is the +// depth of the memory pipeline the mainloop runs against -- too shallow and +// the DPAS waits on L2, too deep and the prefetched lines are evicted before +// use (and the prologue itself becomes a serial stall on short K). +// +// 3 is the value the mainloop was written with and the sibling prefill kernels +// use. The shapes here are short in K (12 k-tiles at K = 768), which is exactly +// where the depth is worth re-measuring, so it is a runtime knob rather than a +// constant; `test_perf_prefill_prefetch_sweep` walks it. +inline constexpr int kPrefillPrefetchDefault = 3; + +inline int moe_w4a8_prefill_prefetch_dist() { + const char* env = std::getenv("ARK_MOE_W4A8_PREFILL_PREFETCH"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 1 && v <= 8) return static_cast(v); + } + return kPrefillPrefetchDefault; +} + // Runtime unroll depth -> compile-time bridge. template void launch_act_dynamic_quant_vec_unroll(int unroll, sycl::queue* q, const ScalarT* activations, int8_t* qact, @@ -845,6 +868,49 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { using SGLayout = Layout, Stride<_4, _1, _0>>; }; +// --------------------------------------------------------------------------- +// Optional fused top-k reduction (prefill only). +// +// The grouped GEMM's natural output is `[T, N]`, one row per *routed* row, and +// every caller immediately reduces it: a token's `top_k` rows are scaled by +// their routing weights and summed into one `[batch, N]` row. That reduction +// reads `T*N` and writes `batch*N`, and the GEMM wrote `T*N` for it to read -- +// so the unfused contract moves `2*T*N + batch*N` elements where the fused one +// moves `2*batch*N` (a read-modify-write of the accumulator). +// +// It is the largest lever on the down-projection shapes, where D is a third of +// the call's traffic: at qwen3's routing (`top_k = 8`) it takes D from +// `T*N*sizeof(ElementD)` to `batch*N*4*2`, i.e. 192 MB -> 48 MB at 384 +// rows/expert, and deletes the caller's reduction kernel outright. +// +// The accumulator is fp32 and the caller must zero it: rows of the same token +// land on different experts, hence on different work-groups, so the only +// portable combiner is a device-scope atomic add. That makes the result +// **order-dependent** and therefore not bit-identical to the unfused path -- +// the equivalence test for this contract is an SNR/cosine gate, not +// `torch.equal`. Scaling is applied before the atomic (one multiply per +// element), so the atomic itself stays a plain `fetch_add`. +// +// `out == nullptr` selects the unfused path and compiles to the same code as +// before; the branch is uniform across the work-group (it is a kernel +// argument). +// --------------------------------------------------------------------------- +struct MoEFusedReduce { + const int* row_to_token = nullptr; // routed row -> model token (expert-local base) + const float* row_weight = nullptr; // routed row -> routing weight (expert-local base) + float* out = nullptr; // [batch, N] fp32 accumulator, zeroed by the caller + int batch = 0; // rows of `out`; bounds the scatter + + CUTE_HOST_DEVICE bool enabled() const { return out != nullptr; } +}; + +CUTE_DEVICE inline void atomic_add_f32(float* addr, float value) { + sycl::atomic_ref + ref(*addr); + ref.fetch_add(value); +} + // --------------------------------------------------------------------------- // Single-tile int8 x int8 -> int32 mainloop with a per-block weight scale and // a per-row activation scale. @@ -979,16 +1045,23 @@ class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { template CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, const float* scale_a, const float* scale_b, int m, int n, int k, int blocksize, int blks, int m_coord, - int n_coord, bool allow_full_tile, bool allow_block_2d_store, TiledMMA const& mma) { + int n_coord, bool allow_full_tile, bool allow_block_2d_store, int prefetch_dist, + MoEFusedReduce const& reduce, TiledMMA const& mma) { auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); const int local_id = static_cast(item.get_local_linear_id()); auto wg_tile = mma.tile_mnk(); auto wg_coord = make_coord(m_coord, n_coord, 0); + // The fused path never writes through `c` (it scatters into `reduce.out` + // instead) and its caller has no `[T, N]` buffer to hand over, so `c` is + // null there. D and its 2D copy atom are still built -- they are ordinary + // objects, not lazily constructed -- so give them a valid base to describe. + ElementD* d_base = c != nullptr ? c : reinterpret_cast(reduce.out); + auto A = make_tensor(make_gmem_ptr(const_cast(a)), make_shape(m, k), make_stride(k, _1{})); auto B = make_tensor(make_gmem_ptr(const_cast(b)), make_shape(n, k), make_stride(k, _1{})); - auto D = make_tensor(make_gmem_ptr(c), make_shape(m, n), make_stride(n, _1{})); + auto D = make_tensor(make_gmem_ptr(d_base), make_shape(m, n), make_stride(n, _1{})); Tensor cA = make_identity_tensor(A.shape()); Tensor cB = make_identity_tensor(B.shape()); @@ -1025,7 +1098,6 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con auto pBgB = prefetch_b.get_slice(local_id).partition_S(gB); constexpr auto barrier_scope = ScopeWorkgroup; - constexpr int prefetch_dist = 3; const int k_tile_size = static_cast(get<2>(wg_tile)); const int k_tiles_per_block = blocksize / k_tile_size; @@ -1053,7 +1125,9 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con barrier_wait(barrier_scope); }; - CUTE_UNROLL + // Runtime bound (`ARK_MOE_W4A8_PREFILL_PREFETCH`), so no unroll pragma: the + // prologue runs once per tile, ahead of a mainloop of `k_tile_count` + // iterations, and its trip count is uniform across the work-group. for (; k_tile_prefetch < prefetch_dist && k_tile_prefetch < k_tile_count; ++k_tile_prefetch) { prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); @@ -1124,7 +1198,39 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con copy(copy_d, tCrD, tCgC); }; - if (allow_block_2d_store) { + // Fused top-k reduction: scale the row by its routing weight and + // accumulate it into the token's row of the `[batch, n]` fp32 output. + // Out-of-range rows are dropped rather than clamped -- a clamped scatter + // would corrupt a *valid* token's accumulator, which the guarded store + // above cannot do -- but the loads stay unconditional so they still + // collapse across the fragment. `row_to_token` is caller data, so its + // value is range-checked as well: a bad index drops the contribution + // instead of writing outside the accumulator. + auto store_fused = [&](auto full) { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + const int row_in = decltype(full)::value ? row : (row < m ? row : m - 1); + const int col_in = decltype(full)::value ? col : (col < n ? col : n - 1); + const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * + scale_a[row_in] * reduce.row_weight[row_in]; + const int token = reduce.row_to_token[row_in]; + const bool in_tile = decltype(full)::value || (row < m && col < n); + if (in_tile && token >= 0 && token < reduce.batch) { + atomic_add_f32(&reduce.out[static_cast(token) * n + col], value); + } + } + }; + + if (reduce.enabled()) { + if (full_tile) { + store_fused(std::true_type{}); + } else { + store_fused(std::false_type{}); + } + } else if (allow_block_2d_store) { if (full_tile) { store_scaled_2d(std::true_type{}); } else { @@ -1168,6 +1274,23 @@ CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, con } } + if (reduce.enabled()) { + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + const int row_in = full_tile ? row : (row < m ? row : m - 1); + const float value = tFrC(i) * scale_a[row_in] * reduce.row_weight[row_in]; + const int token = reduce.row_to_token[row_in]; + const bool in_tile = full_tile || (row < m && col < n); + if (in_tile && token >= 0 && token < reduce.batch) { + atomic_add_f32(&reduce.out[static_cast(token) * n + col], value); + } + } + return; + } + if (allow_block_2d_store) { Tensor tCrD = make_tensor_like(tFrC); if (full_tile) { @@ -1228,7 +1351,8 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const float* ScaleB, ElementD* Outputs, TiledMMA const& mma, const int* rows_per_expert, const int32_t num_experts, const int32_t gemm_n, const int32_t gemm_k, const int32_t blocksize, const int32_t blks, - const bool allow_full_tile, const bool allow_block_2d_store, int32_t* atomic_buffer, + const bool allow_full_tile, const bool allow_block_2d_store, + const int32_t prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer, const sycl::local_accessor& slm_mem_const) { auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); auto wg_tile = mma.tile_mnk(); @@ -1274,7 +1398,16 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const int8_t* ptr_B_curr_batch = Weights + B_offset; const float* ptr_SA_curr_batch = ScaleA + pre_rows; const float* ptr_SB_curr_batch = ScaleB + SB_offset; - ElementD* ptr_D_curr_batch = Outputs + static_cast(pre_rows) * gemm_n; + ElementD* ptr_D_curr_batch = Outputs == nullptr ? nullptr : Outputs + static_cast(pre_rows) * gemm_n; + + // The scatter targets a `[batch, N]` accumulator shared by every expert, + // so only the per-row side tables advance with the expert; `reduce.out` + // stays put. + MoEFusedReduce expert_reduce = reduce; + if (reduce.enabled()) { + expert_reduce.row_to_token = reduce.row_to_token + pre_rows; + expert_reduce.row_weight = reduce.row_weight + pre_rows; + } while (group_m_id < cumsum_tiles_for_experts) { const int n_coord = (group_id * wg_tile_n) % gemm_n_pad / wg_tile_n; @@ -1283,7 +1416,7 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, xe_gemm_w4a8(ptr_A_curr_batch, ptr_B_curr_batch, ptr_D_curr_batch, ptr_SA_curr_batch, ptr_SB_curr_batch, gemm_m, gemm_n, gemm_k, blocksize, blks, m_coord, n_coord, allow_full_tile, - allow_block_2d_store, mma); + allow_block_2d_store, prefetch_dist, expert_reduce, mma); if (local_id == 0) { slm_mem[0] = cutlass::atomicAdd(atomic_buffer, 1); @@ -1306,7 +1439,7 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, const int blks, const bool allow_full_tile, const bool allow_block_2d_store, - int32_t* atomic_buffer) { + const int prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer) { using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; using WGTile = typename Policy::WGTile; using SGLayout = typename Policy::SGLayout; @@ -1338,8 +1471,8 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, - blks, allow_full_tile, allow_block_2d_store, atomic_buffer, - local_mem); + blks, allow_full_tile, allow_block_2d_store, prefetch_dist, + reduce, atomic_buffer, local_mem); }); }); @@ -1406,7 +1539,8 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const template void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, const float* wscale, ElementD* outputs, const int* num_tokens_per_expert, int E, - int N, int K, int blocksize, int blks, int total_tokens) { + int N, int K, int blocksize, int blks, int total_tokens, + MoEFusedReduce reduce = MoEFusedReduce{}) { if (E == 0 || N == 0 || K == 0 || total_tokens == 0) return; compat::set_default_queue(*q); @@ -1414,10 +1548,11 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* const int A_avg_M = total_tokens / E; const bool tile_n_256 = (N % 256) == 0; const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); - const bool store_2d_aligned = (static_cast(N) * sizeof(ElementD)) % 64 == 0 && + const bool store_2d_aligned = !reduce.enabled() && (static_cast(N) * sizeof(ElementD)) % 64 == 0 && reinterpret_cast(outputs) % 64 == 0; const bool allow_block_2d_store = store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); + const int prefetch_dist = moe_w4a8_prefill_prefetch_dist(); int32_t* atomic_buffer = moe_dpas_fp8::get_persistent_atomic_buffer(q); #define ARK_MOE_W4A8_LAUNCH(policy) \ @@ -1940,10 +2075,34 @@ inline void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, voi // `phase`: 0 = auto (decode when `total_tokens <= // ARK_MOE_W4A8_DECODE_MAX_TOKENS`), 1 = force decode GEMV, 2 = force prefill // grouped GEMM. +// +// Two optional call contracts trade interface work for DRAM traffic. Both are +// opt-in and the defaults are unchanged. +// +// Pre-quantized activations (`qact_in` + `ascale_in`) +// --------------------------------------------------- +// By default the call quantizes `[T, K]` itself: it reads the 16-bit +// activations, writes an int8 copy and the GEMM reads that copy back, i.e. +// `4 * T * K` bytes on top of the GEMM's own operands. On the down-projection +// that is 27% of everything the call moves -- and it is redundant, because the +// producer of those activations (the SiLU/gate elementwise kernel) already +// writes `[T, K]` once and could write int8 plus a per-row scale instead: the +// absmax it needs is a reduction over the row it is already holding. When both +// pointers are supplied all three streams disappear, along with a kernel +// launch. `ascale_in` is `[T]` fp32, `scale = absmax / 127`, matching what +// `launch_act_dynamic_quant` writes. +// +// Fused top-k reduction (`row_to_token` + `routing_weights` + `fused_out`) +// ----------------------------------------------------------------------- +// See `MoEFusedReduce`. Prefill only, and the accumulator must be zeroed by +// the caller; `outputs` is then unused and may be null. // --------------------------------------------------------------------------- inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, - int* num_tokens_per_expert, int num_experts, int total_tokens, int phase) { + int* num_tokens_per_expert, int num_experts, int total_tokens, int phase, + const void* qact_in = nullptr, const float* ascale_in = nullptr, + const int* row_to_token = nullptr, const float* routing_weights = nullptr, + float* fused_out = nullptr, int fused_batch = 0) { if (total_tokens == 0 || num_experts <= 0) return; if (N % moe_w4a8::N_TILE != 0) { throw std::invalid_argument("moe_gemm_w4a8: N must be a multiple of 16"); @@ -1961,41 +2120,79 @@ inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, v throw std::invalid_argument("moe_gemm_w4a8: act_dtype must be F16 or BF16"); } + const bool prequantized = qact_in != nullptr || ascale_in != nullptr; + if (prequantized && (qact_in == nullptr || ascale_in == nullptr)) { + throw std::invalid_argument("moe_gemm_w4a8: pre-quantized activations need both qact and ascale"); + } + if (!prequantized && activations == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: null activations"); + } + + const bool fused_reduce = row_to_token != nullptr || routing_weights != nullptr || fused_out != nullptr; + if (fused_reduce && (row_to_token == nullptr || routing_weights == nullptr || fused_out == nullptr || + fused_batch <= 0)) { + throw std::invalid_argument( + "moe_gemm_w4a8: the fused top-k reduction needs row_to_token, routing_weights, a zeroed [batch, N] " + "fp32 output and batch > 0"); + } + if (!fused_reduce && outputs == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: null outputs"); + } + const int blocksize = rescale_block_size; const int blks = K / blocksize; const bool use_decode = phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); - // Quantized activations + per-token scales share one slab: `[T, K]` int8 - // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because - // K is a multiple of 64). - const size_t qact_bytes = static_cast(total_tokens) * static_cast(K); - const size_t scale_offset = (qact_bytes + sizeof(float) - 1) / sizeof(float) * sizeof(float); - const size_t slab_bytes = scale_offset + static_cast(total_tokens) * sizeof(float); - uint8_t* slab = moe_w4a8::qact_pool().acquire(q, slab_bytes); - int8_t* qact = reinterpret_cast(slab); - float* ascale = reinterpret_cast(slab + scale_offset); + if (fused_reduce && use_decode) { + throw std::invalid_argument("moe_gemm_w4a8: the fused top-k reduction is prefill-only"); + } + + const int8_t* qact = static_cast(qact_in); + const float* ascale = ascale_in; + int8_t* qact_scratch = nullptr; + float* ascale_scratch = nullptr; + + if (!prequantized) { + // Quantized activations + per-token scales share one slab: `[T, K]` int8 + // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because + // K is a multiple of 64). + const size_t qact_bytes = static_cast(total_tokens) * static_cast(K); + const size_t scale_offset = (qact_bytes + sizeof(float) - 1) / sizeof(float) * sizeof(float); + const size_t slab_bytes = scale_offset + static_cast(total_tokens) * sizeof(float); + uint8_t* slab = moe_w4a8::qact_pool().acquire(q, slab_bytes); + qact_scratch = reinterpret_cast(slab); + ascale_scratch = reinterpret_cast(slab + scale_offset); + qact = qact_scratch; + ascale = ascale_scratch; + } // Decode consumes `expert_id_per_token`; the activation-quant kernel already // runs one sub-group per token, so it derives the map as well instead of // paying for a second launch (`fill_expert_id_per_token`) on a timeline where // one call is issued per generated token. Prefill passes nullptr and the scan - // is not compiled into the work. + // is not compiled into the work. With pre-quantized activations that kernel + // does not run at all, so decode falls back to the standalone scan. int* expert_map = nullptr; if (use_decode) { expert_map = reinterpret_cast( moe_w4a8::expert_map_pool().acquire(q, static_cast(total_tokens) * sizeof(int))); } - if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact, ascale, - total_tokens, K, expert_map, num_tokens_per_expert, - num_experts); + if (prequantized) { + if (use_decode) { + moe_decode_detail::fill_expert_id_per_token(q, expert_map, num_tokens_per_expert, num_experts, + total_tokens); + } + } else if (act_dtype == BTLA_DTYPE::F16) { + moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, + ascale_scratch, total_tokens, K, expert_map, + num_tokens_per_expert, num_experts); } else { using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact, ascale, total_tokens, K, - expert_map, num_tokens_per_expert, num_experts); + moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, ascale_scratch, + total_tokens, K, expert_map, num_tokens_per_expert, num_experts); } const auto* weights = static_cast(weights_s8); @@ -2014,15 +2211,23 @@ inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, v return; } + moe_w4a8::MoEFusedReduce reduce{}; + if (fused_reduce) { + reduce.row_to_token = row_to_token; + reduce.row_weight = routing_weights; + reduce.out = fused_out; + reduce.batch = fused_batch; + } + if (act_dtype == BTLA_DTYPE::F16) { moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), num_tokens_per_expert, - num_experts, N, K, blocksize, blks, total_tokens); + num_experts, N, K, blocksize, blks, total_tokens, reduce); } else { using BF = sycl::ext::oneapi::bfloat16; moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), num_tokens_per_expert, num_experts, N, K, blocksize, blks, - total_tokens); + total_tokens, reduce); } } diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 1cd0f94d45..376f5bd829 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -443,7 +443,19 @@ def _prefill_batches(all_shapes: bool) -> list: # --------------------------------------------------------------------------- -def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0, need_reference=True, need_dequant=True): +def _build_case( + N, + K, + E, + total_tokens, + group_size, + dtype, + device="xpu", + seed=0, + need_reference=True, + need_dequant=True, + topk=None, +): """Build one W4A8 MoE test case. Returns a dict with the packed int4 weights + scales, the activations, @@ -459,6 +471,13 @@ def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0, the accuracy sweep. Skipping them is what keeps the compute-bound batches -- the only ones where a prefill TOPS target is physically reachable -- inside a sane memory and time budget. + + ``topk`` adds the side tables the fused top-k reduction needs: a + ``row -> model token`` map and a per-row routing weight. Row ``r`` is given + to token ``r % batch``, which puts each token on exactly ``top_k`` rows and + -- because an expert's block of rows is shorter than ``batch`` at every + shipped ``E`` -- never twice on the same expert, i.e. the same structure a + real router produces after the rows are sorted by expert. """ generator = torch.Generator(device="cpu").manual_seed(seed) w_float = torch.randn(E, N, K, generator=generator, dtype=torch.float32) * 0.05 @@ -469,10 +488,21 @@ def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0, tpe = _spread_tokens(total_tokens, E) ntpe = torch.tensor(tpe, dtype=torch.int32) + row_to_token = None + routing_weights = None + batch = None + if topk: + batch = total_tokens // topk + row_to_token = (torch.arange(total_tokens, dtype=torch.int32) % max(batch, 1)).contiguous() + routing_weights = (torch.rand(total_tokens, generator=generator, dtype=torch.float32) + 0.5) / topk + packed = packed.to(device) scales = scales.to(device) activations = activations.to(device) ntpe = ntpe.to(device) + if topk: + row_to_token = row_to_token.to(device) + routing_weights = routing_weights.to(device) dequant = _dequant_int4_sym(packed, scales, group_size) if (need_dequant or need_reference) else None @@ -504,6 +534,10 @@ def _build_case(N, K, E, total_tokens, group_size, dtype, device="xpu", seed=0, "total_tokens": total_tokens, "group_size": group_size, "dtype": dtype, + "topk": topk, + "batch": batch, + "row_to_token": row_to_token, + "routing_weights": routing_weights, } @@ -544,14 +578,69 @@ def _w4a16(case, phase): ) -def _w4a8(case, weights_s8, wscales, block, phase): +def _quantize_rows(activations): + """Per-row absmax int8 quantization -- the reference for what the kernel does. + + Reproduces ``launch_act_dynamic_quant`` element for element: the absmax is + an exact reduction, ``inv = 127 / absmax`` and ``scale = absmax / 127`` are + the same two fp32 operations, and ``rint`` is round-half-to-even, which is + what ``torch.round`` does as well. So handing the result back through the + pre-quantized entry point must reproduce the internally-quantized call + bit-for-bit, not merely closely. + """ + a = activations.to(torch.float32) + absmax = a.abs().amax(dim=1) + scale = absmax / 127.0 + inv = torch.where(absmax > 0, 127.0 / absmax, torch.zeros_like(absmax)) + q = torch.clamp(torch.round(a * inv.unsqueeze(1)), -127.0, 127.0).to(torch.int8) + return q.contiguous(), scale.contiguous() + + +def _prequantized(case): + """Cached ``(int8 activations, fp32 row scales)`` for ``case``.""" + if case.get("qact") is None: + qact, ascale = _quantize_rows(case["activations"]) + case["qact"], case["ascale"] = qact, ascale + return case["qact"], case["ascale"] + + +def _reduce_topk(case, out): + """Reduce an unfused ``[T, N]`` output the way a caller would. + + ``index_add_`` on an fp32 accumulator, i.e. exactly the reduction the fused + epilogue replaces -- so this is what a fused result is compared against. + """ + reduced = torch.zeros((case["batch"], case["N"]), device=out.device, dtype=torch.float32) + reduced.index_add_(0, case["row_to_token"].to(torch.long), out.to(torch.float32) * case["routing_weights"][:, None]) + return reduced + + +def _w4a8(case, weights_s8, wscales, block, phase, prequant=False, fused=False): + """One ``moe_gemm_w4a8`` call under the requested call contract. + + ``prequant`` hands the kernel int8 activations and their per-row scales so + it skips its own quantization pass; ``fused`` asks the epilogue to apply + the routing weights and scatter-add into a ``[batch, N]`` fp32 accumulator + instead of writing the unreduced ``[T, N]``. + """ + kwargs = {} + activations = case["activations"] + if prequant: + activations, ascale = _prequantized(case) + kwargs["activation_scale"] = ascale + kwargs["out_dtype"] = case["dtype"] + if fused: + kwargs["row_to_token"] = case["row_to_token"] + kwargs["routing_weights"] = case["routing_weights"] + kwargs["output_rows"] = case["batch"] return ark.moe_gemm_w4a8( - case["activations"], + activations, weights_s8, wscales, case["ntpe"], rescale_block_size=block, phase=phase, + **kwargs, ) @@ -612,35 +701,64 @@ def _rows_per_expert(total_tokens, active_experts) -> float: return float(total_tokens) / float(active_experts) if active_experts else 0.0 -def _traffic_bytes(total_tokens, active_experts, N, K, act_bytes=2, out_bytes=2) -> float: +def _traffic_bytes(total_tokens, active_experts, N, K, act_bytes=2, out_bytes=2, prequantized=False, fused_rows=None): """Compulsory DRAM traffic of one ``moe_gemm_w4a8`` call, in bytes. Counts each byte once: re-reads of A across the N tiles are L2 hits at any launch this kernel produces (~20 concurrent work-groups against 8 MB of L2), so they are not DRAM traffic. This is a lower bound, which keeps the derived ceiling optimistic and therefore never excuses a slow kernel. + + The two optional call contracts remove whole streams, so the model has to + know which one is in force or every derived number (``BW@100T``, the + ceiling, the PASS/FAIL verdict) is computed against traffic the call no + longer moves: + + * ``prequantized``: the caller hands over int8 activations, so the 16-bit + read and the int8 write are gone and only the GEMM's read-back remains. + * ``fused_rows``: the epilogue reduces into a ``[batch, N]`` fp32 + accumulator, so instead of writing ``T * N`` elements it reads *and* + writes ``batch * N`` fp32 ones. """ - act_read = float(total_tokens) * K * act_bytes - qact_write_read = 2.0 * float(total_tokens) * K + act_read = 0.0 if prequantized else float(total_tokens) * K * act_bytes + qact_write_read = (1.0 if prequantized else 2.0) * float(total_tokens) * K weights = float(active_experts) * N * K - out_write = float(total_tokens) * N * out_bytes + if fused_rows: + out_write = 2.0 * float(fused_rows) * N * 4 + else: + out_write = float(total_tokens) * N * out_bytes return act_read + qact_write_read + weights + out_write def _bw_needed_for_tflops( - total_tokens, active_experts, N, K, act_bytes=2, out_bytes=2, tflops_target=_TARGET_PREFILL_TFLOPS + total_tokens, + active_experts, + N, + K, + act_bytes=2, + out_bytes=2, + tflops_target=_TARGET_PREFILL_TFLOPS, + prequantized=False, + fused_rows=None, ) -> float: """GB/s of DRAM traffic a shape needs to hit ``tflops_target``.""" flops = _flops(total_tokens, N, K) if flops <= 0.0: return float("inf") seconds_at_target = flops / (tflops_target * 1e12) - return _traffic_bytes(total_tokens, active_experts, N, K, act_bytes, out_bytes) / seconds_at_target / 1e9 + traffic = _traffic_bytes( + total_tokens, active_experts, N, K, act_bytes, out_bytes, prequantized=prequantized, fused_rows=fused_rows + ) + return traffic / seconds_at_target / 1e9 -def _tflops_ceiling(total_tokens, active_experts, N, K, gbps, act_bytes=2, out_bytes=2) -> float: +def _tflops_ceiling( + total_tokens, active_experts, N, K, gbps, act_bytes=2, out_bytes=2, prequantized=False, fused_rows=None +) -> float: """Best TFLOPS this shape can reach at ``gbps`` of DRAM bandwidth.""" - traffic = _traffic_bytes(total_tokens, active_experts, N, K, act_bytes, out_bytes) + traffic = _traffic_bytes( + total_tokens, active_experts, N, K, act_bytes, out_bytes, prequantized=prequantized, fused_rows=fused_rows + ) if traffic <= 0.0: return float("inf") return _flops(total_tokens, N, K) / (traffic / (gbps * 1e9)) / 1e12 @@ -920,6 +1038,8 @@ def run_perf( torch_baseline=True, models=None, compute_bound=False, + prequantized=False, + fused_reduce=False, ): """Run the W4A8 perf sweep. Returns a list of per-row metric dicts. @@ -930,16 +1050,27 @@ def run_perf( or a list); ``compute_bound=True`` ignores ``batches`` and derives, per model, the batch that puts ``_PREFILL_TARGET_ROWS_PER_EXPERT`` rows on every expert -- the only regime where the prefill TOPS target is reachable. + + ``prequantized`` / ``fused_reduce`` select the traffic-cutting call + contracts (see :func:`_w4a8`). They change what the call moves, so they are + also fed to the traffic model: the printed ``DRAM GB/s``, ``BW@100T`` and + the ceiling that decides the verdict all follow the contract in force. """ rows = [] # Probed before anything large is allocated (and cached across sweeps). device_bw = _device_bandwidth_gbps() resolved = _models(models) + contract = "".join( + [ + ", A=int8-in" if prequantized else "", + ", fused top-k reduce" if fused_reduce else "", + ] + ) if verbose: _print_perf_header( f"W4A8 perf [{phase}] (models={'+'.join(n for n, _ in resolved)}, " f"group_size={_QWEN3_GROUP_SIZE}, " - f"act={str(dtype).split('.')[-1]}, rescale_group_size={rescale_group_size}) " + f"act={str(dtype).split('.')[-1]}, rescale_group_size={rescale_group_size}{contract}) " f"-- ark.moe_gemm_w4a8 vs W4A16 vs torch" ) shapes = [ @@ -960,6 +1091,7 @@ def run_perf( dtype, need_reference=False, need_dequant=torch_baseline, + topk=topk if fused_reduce else None, ) # One-shot int4 -> int8 AUTO_S8 conversion. Timed separately: it @@ -981,7 +1113,9 @@ def run_perf( rescale_group_size=rescale_group_size, ) - w4a8_ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase)) + w4a8_ms = _xpu_time_ms( + lambda: _w4a8(case, weights_s8, wscales, block, phase, prequant=prequantized, fused=fused_reduce) + ) torch_ms = _xpu_time_ms(lambda: _torch_baseline(case)) if torch_baseline else None try: w4a16_ms = _xpu_time_ms(lambda: _w4a16(case, phase)) @@ -998,10 +1132,29 @@ def run_perf( # of A, the int8 copy it writes, the GEMM's read of that copy and the # output. On the small-K shapes the weights are under half of it. act_bytes = _dtype_bytes(dtype) - traffic = _traffic_bytes(total_tokens, active_experts, N, K, act_bytes, act_bytes) + fused_rows = case["batch"] if fused_reduce else None + traffic = _traffic_bytes( + total_tokens, + active_experts, + N, + K, + act_bytes, + act_bytes, + prequantized=prequantized, + fused_rows=fused_rows, + ) dram_gbps = traffic / (w4a8_ms * 1e-3) / 1e9 rows_per_expert = _rows_per_expert(total_tokens, active_experts) - bw_at_100t = _bw_needed_for_tflops(total_tokens, active_experts, N, K, act_bytes, act_bytes) + bw_at_100t = _bw_needed_for_tflops( + total_tokens, + active_experts, + N, + K, + act_bytes, + act_bytes, + prequantized=prequantized, + fused_rows=fused_rows, + ) row = { "label": nk_label, @@ -1025,9 +1178,21 @@ def run_perf( "tflops_ceiling": ( None if device_bw is None - else _tflops_ceiling(total_tokens, active_experts, N, K, device_bw, act_bytes, act_bytes) + else _tflops_ceiling( + total_tokens, + active_experts, + N, + K, + device_bw, + act_bytes, + act_bytes, + prequantized=prequantized, + fused_rows=fused_rows, + ) ), "device_bw_gbps": device_bw, + "prequantized": prequantized, + "fused_reduce": fused_reduce, } rows.append(row) if verbose: @@ -1157,6 +1322,41 @@ def run_perf( ("store block2d", {"ARK_MOE_W4A8_PREFILL_STORE_2D": "1"}), ] +# Prefill: how many k-tiles the mainloop keeps prefetched ahead of the tile it +# is computing. The prologue issues this many A/B prefetch pairs before the +# first DPAS and the loop then issues one pair per tile, so it is the depth of +# the memory pipeline the DPAS chain runs against. 3 is what the kernel was +# written with; the shipped shapes are short in K (12 k-tiles at K = 768, where +# a prologue of 3 is a quarter of the whole mainloop), which is exactly the +# regime where the depth is worth re-measuring in both directions. +_PREFILL_PREFETCH_CONFIGS = [ + (f"prefetch {dist}", {"ARK_MOE_W4A8_PREFILL_PREFETCH": str(dist)}) for dist in (2, 3, 4, 6) +] + +# Prefill: the two call contracts that cut traffic instead of cycles. +# +# Neither changes the GEMM. They change what crosses the call boundary, which +# is where the remaining traffic is: on the qwen3 down-projection at 384 +# rows/expert the call moves 528 MB, of which only 36% is weights -- 27% is the +# activation quantization round-trip (read fp16, write int8, read the int8 +# back) and 36% is a `[T, N]` output that the caller immediately reduces to +# `[batch, N]`. Both are redundancies of the *interface*: the producer of the +# activations already writes `[T, K]` once and could write int8, and the +# reduction the caller performs can be done in the epilogue while the values +# are still in registers. +# +# The fused row is not bit-identical to the others -- fp32 atomics do not +# commit in a fixed order, and the unfused baseline additionally rounds each +# row to the activation dtype before the caller's reduction sees it -- so this +# is the one sweep whose SNR column is a quality gate rather than an identity +# check. +_PREFILL_CONTRACT_CONFIGS = [ + ("A quant in-call", {}, {}), + ("A int8 in", {}, {"prequant": True}), + ("fused reduce", {}, {"fused": True}), + ("A int8 + fused", {}, {"prequant": True, "fused": True}), +] + _SWEEP_MIN_SNR_DB = 40.0 @@ -1180,6 +1380,13 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= an explicit list of model-token batches instead -- what the long-prompt sweeps use, since a fixed prompt length is exactly what the compute-bound derivation replaces. Returns one dict per (shape, batch, configuration). + + A configuration is ``(label, env_overrides)`` or, for the sweeps that + compare *call contracts* rather than dispatch knobs, + ``(label, env_overrides, call_kwargs)`` -- the extra dict is forwarded to + :func:`_w4a8`. A contract that reduces inside the kernel returns a + ``[batch, N]`` tensor where the others return ``[T, N]``, so outputs are + put in the same frame (:func:`_reduce_topk`) before they are compared. """ is_prefill = phase == "prefill" # An explicit batch list opts out of the compute-bound derivation, which @@ -1189,6 +1396,9 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= metric_name = "TFLOPS" if is_prefill else "W GB/s" device_bw = _device_bandwidth_gbps() resolved = _models(models) + configs = [(cfg[0], cfg[1], cfg[2] if len(cfg) > 2 else {}) for cfg in configs] + # Only build the routing side tables when some configuration asks for them. + need_routing = any(kwargs.get("fused") for _, _, kwargs in configs) if verbose: _print_sweep_header( f"W4A8 config sweep [{phase}] (models={'+'.join(n for n, _ in resolved)}, " @@ -1203,7 +1413,17 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= for nk_label, N, K in spec["nk"]: for batch in _compute_bound_batches(spec) if compute_bound else batches: total_tokens = batch * topk - case = _build_case(N, K, E, total_tokens, group_size, dtype, need_reference=False, need_dequant=False) + case = _build_case( + N, + K, + E, + total_tokens, + group_size, + dtype, + need_reference=False, + need_dequant=False, + topk=topk if need_routing else None, + ) weights_s8, wscales, block = ark.moe_w4a8_prepack( case["packed"], case["scales"], group_size=group_size, rescale_group_size=-1 ) @@ -1212,10 +1432,12 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= baseline_out = None baseline_ms = None - for label, overrides in configs: + for label, overrides, call_kwargs in configs: with _env_override(**overrides): - out = _w4a8(case, weights_s8, wscales, block, phase) - ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase)) + out = _w4a8(case, weights_s8, wscales, block, phase, **call_kwargs) + ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase, **call_kwargs)) + if need_routing and not call_kwargs.get("fused"): + out = _reduce_topk(case, out) if baseline_out is None: # Cloned: the kernel may hand back a reused scratch # buffer, which would make every later comparison @@ -1230,6 +1452,7 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= "phase": phase, "config": label, "overrides": overrides, + "call_kwargs": call_kwargs, "E": E, "N": N, "K": K, @@ -1292,7 +1515,9 @@ def _print_sweep_best(phase, rows) -> None: continue best = min(candidates, key=lambda r: r["w4a8_ms"]) metric = f"{best['tflops']:.2f} TFLOPS" if is_prefill else f"{best['gbps']:.1f} GB/s" - env = " ".join(f"{k}={v}" for k, v in sorted(best["overrides"].items()) if v is not None) or "(defaults)" + parts = [f"{k}={v}" for k, v in sorted(best["overrides"].items()) if v is not None] + parts += [f"{k}={v}" for k, v in sorted(best.get("call_kwargs", {}).items()) if v] + env = " ".join(parts) or "(defaults)" print(f" {name:<14} {best['config']:<22} {best['w4a8_ms']:.3f} ms {metric:<16} {env}") @@ -1573,6 +1798,217 @@ def test_perf_prefill_epilogue_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"epilogue config {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_prefetch_sweep(self, request): + """Time the mainloop's prefetch depth at the compute-bound batch. + + The mainloop is otherwise identical in every row: only how far + ahead of the computing tile the A/B block prefetches run changes. + The shipped shapes are short in K -- the qwen3 down-projection has + 12 k-tiles at a 64-element k-tile -- so the default depth of 3 is a + quarter of the whole mainloop, which is the regime where both + directions are plausible: deeper hides more latency but spends more + of the tile in a prologue that computes nothing, and the prefetched + lines have to survive in L2 until the tile that wants them runs. + + Nothing about the arithmetic changes, so every row must be + bit-identical to the first; only the timing is a measurement. + """ + rows = run_config_sweep("prefill", _PREFILL_PREFETCH_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"prefetch depth {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + + def test_perf_prefill_contract_sweep(self, request): + """Time the two traffic-cutting call contracts at the compute-bound batch. + + This is the sweep that decides whether the qwen3 shapes can reach + the 100 TFLOPS target at all. Every kernel configuration above + moves the same bytes and competes for the same ~60-75% of the + device's bandwidth; the target on the down-projection needs 358 + GB/s of a ~390 GB/s part, which no scheduling change reaches. The + contracts are the only levers that change the numerator: handing + the kernel int8 activations removes 27% of the call's traffic and + reducing in the epilogue removes another 27%. + + Both rows are also a correctness check on the harness's own model: + the ``TFLOPS`` column here is directly comparable to + ``test_perf_prefill_compute_bound`` because the workload, the + weights and the routing are the same objects. + """ + rows = run_config_sweep("prefill", _PREFILL_CONTRACT_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"call contract {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + + def test_perf_prefill_contracts_long_seq(self, request): + """Prefill throughput for one 8K prompt with both contracts enabled. + + ``test_perf_prefill_long_seq`` measures the shipped contract, where + the qwen3 shapes are bandwidth-bound below the target. This runs + the same prompt with the activation round-trip and the unreduced + output removed, which is the configuration the target is reachable + in; the ceiling printed next to it is computed from the same + reduced traffic model, so the verdict is against the right roof. + """ + rows = run_perf( + "prefill", + _long_seq_batches(), + torch_baseline=False, + models=_models_option(request), + prequantized=True, + fused_reduce=True, + ) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + _assert_targets(request, "prefill", rows) + + def test_prequantized_activations_match_internal(self): + """Handing the kernel int8 activations must reproduce the in-call quantization. + + The pre-quantized entry point does not change any arithmetic: it + removes the pass that computes the int8 copy and takes the caller's + instead. :func:`_quantize_rows` is that pass, expression for + expression -- an exact absmax, the same ``127 / absmax`` + reciprocal, the same round-half-to-even and the same clamp -- so + the GEMM sees the same bytes and the results must be bit-identical. + Any difference is a contract bug: a transposed scale, an off-by-one + row, or the scale being interpreted as its reciprocal. + + Both K are covered because the internal quantizer picks its lane + mapping from K, and only one of the two rungs would be exercised by + a single shape. + """ + for nk_label, N, K in _QWEN3_NK: + case = _build_case( + N, + K, + _QWEN3_E, + _PREFILL_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + internal = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + external = _w4a8(case, weights_s8, wscales, block, "prefill", prequant=True).clone() + assert torch.equal(internal, external), ( + f"{nk_label.strip()} (K={K}): pre-quantized activations disagree with the in-call " + f"quantizer: max |diff| {(internal.float() - external.float()).abs().max().item():.6g}" + ) + case = weights_s8 = wscales = internal = external = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + + def test_prequantized_activations_match_internal_decode(self): + """The same, on the decode GEMV, where the expert map comes from elsewhere. + + Decode needs a ``token -> expert`` map, and the shipped path gets + it for free: the activation-quant kernel already runs one sub-group + per token, so it fills the map on the way past. Pre-quantized + activations delete that kernel, so the map has to come from the + standalone scan instead -- a different code path producing a value + the GEMV indexes its weights with. If it were wrong every token + would read another expert's weights, which this test sees as a + gross mismatch rather than a rounding difference. + """ + case = _build_case( + _QWEN3_NK[0][1], + _QWEN3_NK[0][2], + _QWEN3_E, + _DECODE_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + internal = _w4a8(case, weights_s8, wscales, block, "decode").clone() + external = _w4a8(case, weights_s8, wscales, block, "decode", prequant=True).clone() + assert torch.equal(internal, external), ( + "pre-quantized activations disagree with the in-call quantizer on decode: " + f"max |diff| {(internal.float() - external.float()).abs().max().item():.6g}" + ) + + def test_fused_reduce_matches_unfused(self): + """The fused epilogue must agree with reducing the unfused output. + + Unlike every other prefill A/B in this file, this one is *not* a + bit-identity check and cannot be. The fused epilogue combines a + token's ``top_k`` contributions with device-scope fp32 atomics, + which commit in whatever order the work-groups finish, and fp32 + addition is not associative; the unfused path additionally rounds + each row to the activation dtype before the caller's reduction ever + sees it. So the two differ by rounding on both sides, and the gate + is the accuracy gate the rest of the suite uses against the fp32 + reference (20 dB / 0.99 cosine) -- generous enough to survive + reassociation, far too tight to survive a wrong token index, a + missing routing weight or an expert-offset slip, all of which + misplace whole rows. + + The batch is the ragged one: 300 rows per expert against the + ladder's 128-row tile gives every expert two interior tiles and one + partial tile, so both the fast and the guarded scatter run, and the + partial tile is where a scatter can do damage a predicated store + cannot -- an out-of-range row would land on a *valid* token. + """ + rows_per_expert = _RAGGED_TILE_ROWS_PER_EXPERT + case = _build_case( + _QWEN3_NK[1][1], + _QWEN3_NK[1][2], + _QWEN3_E, + rows_per_expert * _QWEN3_E, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + topk=_QWEN3_TOPK, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + unfused = _reduce_topk(case, _w4a8(case, weights_s8, wscales, block, "prefill")) + fused = _w4a8(case, weights_s8, wscales, block, "prefill", fused=True) + assert fused.shape == unfused.shape, f"fused output shape {tuple(fused.shape)} != {tuple(unfused.shape)}" + snr = _snr_db(unfused, fused) + cos = _cosine(unfused, fused) + assert snr >= _MIN_SNR_DB, f"the fused top-k reduction disagrees with the unfused one: SNR {snr:.2f} dB" + assert cos >= _MIN_COSINE, f"the fused top-k reduction disagrees with the unfused one: cosine {cos:.6f}" + + def test_fused_reduce_rejects_decode(self): + """The fused reduction must refuse the decode phase rather than mis-reduce. + + The scatter lives in the grouped GEMM's epilogue; the decode GEMV + has no such epilogue, so a decode call with routing tables would + silently return the unreduced output under a shape that claims to + be reduced. Both the Python guard and the kernel's own check exist + for this; the Python one is what a caller hits. + """ + case = _build_case( + _QWEN3_NK[1][1], + _QWEN3_NK[1][2], + _QWEN3_E, + _DECODE_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + topk=_QWEN3_TOPK, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + with pytest.raises(ValueError): + _w4a8(case, weights_s8, wscales, block, "decode", fused=True) + def test_act_quant_vec_matches_scalar(self): """The vectorized activation quantizer must be bit-identical to the scalar one. @@ -1917,6 +2353,15 @@ def _parse_args(argv): "prefill tile sweep is repeated there." ), ) + parser.add_argument( + "--contracts", + action="store_true", + help=( + "Also run the prefill points with the traffic-cutting call contracts (caller-supplied int8 " + "activations + the fused top-k reduction), and with --sweep-configs the contract A/B sweep. " + "These change what the call moves, so the printed ceiling and BW@100T follow the contract." + ), + ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") return parser.parse_args(argv) @@ -1970,11 +2415,25 @@ def main(argv=None) -> int: torch_baseline=False, models=models, ) + if phase == "prefill" and args.contracts: + run_perf( + phase, + _long_seq_batches() if args.long_seq else None, + dtype=dtype, + rescale_group_size=args.rescale_group_size, + torch_baseline=False, + compute_bound=not args.long_seq, + models=models, + prequantized=True, + fused_reduce=True, + ) if args.sweep_configs: configs = _PREFILL_TILE_CONFIGS if phase == "prefill" else _DECODE_CONFIGS run_config_sweep(phase, configs, dtype=dtype, models=models) if phase == "prefill" and args.long_seq: run_config_sweep(phase, configs, dtype=dtype, models=models, batches=_long_seq_batches()) + if phase == "prefill" and args.contracts: + run_config_sweep(phase, _PREFILL_CONTRACT_CONFIGS, dtype=dtype, models=models) if failures: print() From dce6713d05d664b049836aaec06bf4ae5e8d4af5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:54:23 +0000 Subject: [PATCH 077/112] docs: document the W4A8 prefill call contracts and correct the A-tile fusion claim Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 245 ++++++++++++++++-- .../ark/test/README_MOE_W4A8_CN.md | 204 ++++++++++++++- 2 files changed, 421 insertions(+), 28 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 8894ad4023..6d6f9ec711 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -208,6 +208,117 @@ prefill are far below it, so readings of 0.55–0.71× are expected there: W4A8 a large-batch prefill optimization, and at decode it can only help by improving the *memory* path. +## Cutting the prefill traffic: two optional call contracts + +Read the roofline the other way round. At the compute-bound batch the four +shapes run at 61–76% of their ceilings, so perfect kernel work is worth at most +1.3–1.6× — but `qwen3 down`'s ceiling is itself only 109 TFLOPS at 384 rows per +expert and **105 TFLOPS as the rows per expert go to infinity**, because at that +point the weight stream has amortized away and what is left (`2 × K` bytes of +activation traffic and `N × 2` bytes of output per row) does not amortize at +all. No batch size and no amount of mainloop tuning reaches 100 TFLOPS on that +shape. Its traffic has to go down. Where the 554 MB it moves at 384 rows per +expert actually goes: + +| Stream | Bytes | Share | +|---|---|---| +| weights (the only one that does not grow with the token count) | 201 MB | 36% | +| the output `[T, N]` fp16 | 201 MB | 36% | +| the activation quantization round trip (read fp16, write int8, read int8 back) | 151 MB | 27% | + +The weight third is irreducible. The other two thirds are not kernel +inefficiency — they are **interface** redundancy, and both are visible only from +outside the call: + +* the fp16 activations were written by the *previous* kernel (the SiLU/gate + elementwise pass for the down projection), which could have written int8 + directly; +* every row of `D` is consumed immediately afterwards by the top-k weighted + reduction, which collapses `top_k` rows into one. + +So `moe_gemm_w4a8` takes two optional contracts that let a caller who owns both +sides delete those streams. Both are opt-in, both default off, and the existing +call is unchanged. + +### Contract 1 — caller-supplied int8 activations + +```python +out = ark.moe_gemm_w4a8( + qact, # [T, K] int8, rows sorted by expert + weights_s8, wscales, num_tokens_per_expert, + activation_scale=ascale, # [T] fp32, dequant scale per row + out_dtype=torch.bfloat16, # what the fp16/bf16 output should be +) +``` + +`qact[r, k] × ascale[r]` must reproduce the fp16 row, which is exactly what the +kernel's own quantizer produces (`round(x × 127 / absmax)`, `absmax / 127`). The +producer computes that absmax in the same registers it already holds the row in, +so upstream this is free; here it deletes three of the five streams — the fp16 +read, the int8 write and the int8 read-back — plus one kernel launch. The GEMM +itself is untouched, so results are **bit-identical** to passing fp16 and letting +the kernel quantize (`test_prequantized_activations_match_internal`, and the +decode equivalent, assert exactly that). + +### Contract 2 — the top-k reduction fused into the epilogue + +```python +out = ark.moe_gemm_w4a8( + activations, weights_s8, wscales, num_tokens_per_expert, + row_to_token=row_to_token, # [T] int32, routed row -> model token + routing_weights=routing_weights, # [T] fp32, that row's gate weight + output_rows=batch, # -> [batch, N] fp32, pre-zeroed +) +``` + +Instead of writing `[T, N]` and having the caller reduce it, the epilogue scales +each element by its row's routing weight and `atomic_add`s it into +`out[row_to_token[r]]`. The `T × N × 2` write becomes a read-modify-write of +`batch × N × 4` — a quarter of the bytes at `top_k = 8`, counted here as both +directions — and the separate reduction kernel (another `T × N` read and +`batch × N` write, which this document's model never counted because it is a +different call) disappears entirely. + +This applies to the **second** projection only: an up/gate projection's output +feeds SiLU per routed row and must stay unreduced. Two consequences worth +knowing before enabling it: + +* fp32 atomics accumulate in a nondeterministic order, so the result is **not + bit-identical** — run to run either. It is validated against the unfused path + with the harness's SNR/cosine gates rather than an equality assertion + (`test_fused_reduce_matches_unfused`). +* the output buffer must be zeroed by the caller; the Python wrapper allocates a + zeroed one when it owns the allocation. + +### What the two are worth + +Holding each shape's *measured* effective bandwidth fixed — i.e. assuming the +kernel gets no better, only that it moves less — and using the harness's own +traffic model: + +| Shape | Routing | Measured | + int8 in | + fused reduce | Both | +|---|---|---|---|---|---| +| qwen3 up (N=1536, K=2048) | 384 rows/E | 93.8 | **137** | n/a | n/a | +| qwen3 down (N=2048, K=768) | 384 rows/E | 66.8 | 84.0 | 81.6 | **109** | +| minimax up (N=1536, K=3072) | 384 rows/E | 101.0 | **152** | n/a | n/a | +| minimax down (N=3072, K=1536) | 384 rows/E | 104.8 | 129 | 120 | **152** | +| qwen3 up | 8K prompt | 98.2 | **152** | n/a | n/a | +| qwen3 down | 8K prompt | 70.5 | 91.0 | 88.1 | **123** | +| minimax up | 8K prompt | 93.5 | **137** | n/a | n/a | +| minimax down | 8K prompt | 95.4 | 116 | 108 | **135** | + +(`n/a` = the fused reduction does not apply to an up/gate projection, so the +reachable number for those shapes is the `+ int8 in` column.) + +Both qwen3 shapes clear 100 TFLOPS: the up projection on contract 1 alone, the +down projection on **both together** — neither is sufficient by itself, at either +routing. `qwen3 down`'s asymptotic ceiling moves from 105 TFLOPS to 155 (int8 +in), 147 (fused) or 267 (both), which is the real statement: with the contracts +the shape stops being routing-limited. + +These are projections from a traffic model, not measurements — see +[Status](#status). + ## Decode: coalesced K-split mapping The decode GEMV originally assigned **one work-item per output element**: lane @@ -533,8 +644,21 @@ pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax # Sweep the kernel dispatch configurations and print the fastest equivalent one pytest -v -s test_moe_w4a8_perf.py -k sweep + +# The traffic-cutting call contracts: A/B sweep, 8K-prompt run, equivalence tests +pytest -v -s test_moe_w4a8_perf.py -k contract +pytest -v -s test_moe_w4a8_perf.py -k "prequantized or fused_reduce" ``` +`test_perf_prefill_contract_sweep` and `test_perf_prefill_contracts_long_seq` +time the four combinations of the two [call +contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) at the two +prefill routings. Because each contract changes what the call moves, every row's +`DRAM GB/s`, `BW@100T` and ceiling are computed from *that row's* traffic model, +so the columns stay comparable across contracts. The fused rows are compared +against a canonicalized baseline (the unfused output reduced by the harness) +under an SNR gate instead of the bit-identity the other sweeps use. + `test_perf_decode_config_sweep`, `test_perf_prefill_tile_sweep`, `test_perf_prefill_act_quant_sweep`, `test_perf_prefill_act_quant_unroll_sweep` and `test_perf_prefill_epilogue_sweep` build one workload, prepack it once, then @@ -563,13 +687,14 @@ python test_moe_w4a8_perf.py --phase decode # decode only python test_moe_w4a8_perf.py --skip-accuracy # perf only python test_moe_w4a8_perf.py --compute-bound # add the 6144-token prefill case python test_moe_w4a8_perf.py --long-seq # add the 8K-prompt prefill case +python test_moe_w4a8_perf.py --contracts # add the int8-in + fused-reduce prefill run python test_moe_w4a8_perf.py --dtype fp16 # fp16 activations python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 ``` `--long-seq` also repeats the prefill tile sweep at the 8K prompt when combined -with `--sweep-configs`. +with `--sweep-configs`; `--contracts` adds the contract A/B sweep the same way. The script exits non-zero if any accuracy gate fails. @@ -594,6 +719,36 @@ out = ark.moe_gemm_w4a8( ) ``` +Two optional prefill contracts cut the traffic the call moves (see [Cutting the +prefill traffic](#cutting-the-prefill-traffic-two-optional-call-contracts)); +both default off: + +```python +# Caller already has int8 activations + one dequant scale per row. +out = ark.moe_gemm_w4a8( + qact, # [total_tokens, K] int8 + weights_s8, + wscales, + num_tokens_per_expert, + activation_scale=ascale, # [total_tokens] fp32 + out_dtype=torch.bfloat16, # dtype of the returned fp16/bf16 output + rescale_block_size=block, +) + +# Second projection only: fold the top-k weighted reduction into the epilogue. +out = ark.moe_gemm_w4a8( # -> [batch, N] fp32 + activations, + weights_s8, + wscales, + num_tokens_per_expert, + row_to_token=row_to_token, # [total_tokens] int32 + routing_weights=routing_weights, # [total_tokens] fp32 + output_rows=batch, + rescale_block_size=block, + phase="prefill", +) +``` + A convenience wrapper does both, caching the conversion on the weight/scale tensor identity: @@ -858,7 +1013,8 @@ nothing else, so the remaining gap is message efficiency, not arithmetic. | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default, measured fastest). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized *two-pass* mapping: the single-pass kernel below issues the whole row at once and ignores this. | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | -| `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. | +| `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` times `2 / 3 / 4 / 6`. Values outside `1`–`8` fall back to the default. | ## Shape constraints @@ -930,10 +1086,32 @@ kernel deficiency (see [the roofline](#the-weights-are-not-the-only-stream)). With every stream counted, the four compute-bound shapes were running at 60–74% of their true ceilings, and the compute-bound batch moved from 256 to 384 rows per expert so that 100 TFLOPS is reachable on all of them. The remaining gap is -traffic, not arithmetic: the largest single win still on the table is fusing the -activation quantization into the GEMM's A-tile load, which would delete the int8 -copy's write *and* read — 2 of the 5 streams, 14–22% of the traffic depending on -K — but that is a mainloop change and wants a device to develop against. +traffic, not arithmetic — but the traffic worth deleting turned out to be at the +call boundary rather than in the mainloop, which is the subject of the two +[call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts). + +Those contracts, and the `ARK_MOE_W4A8_PREFILL_PREFETCH` sweep point, are the +current `NEEDS-HARDWARE-VALIDATION` items: they are reasoned through and +lint-clean but have not been compiled or timed, because the authoring +environment has no XPU and no SYCL compiler. What needs to be checked on device, +in order: + +1. `test_prequantized_activations_match_internal` and its decode counterpart — + bit-identity of the int8-in path against the kernel's own quantizer. A + mismatch means the harness's reference quantizer and + `launch_act_dynamic_quant` disagree on rounding, not that the contract is + wrong. +2. `test_fused_reduce_matches_unfused` — SNR/cosine against the unfused path. + Expected around 54 dB (bf16 rounding of the unfused rows dominates the fp32 + atomic's reassociation), against a 20 dB / 0.99 gate. +3. `test_perf_prefill_contract_sweep` and `test_perf_prefill_contracts_long_seq` + — whether the measured speedups follow the traffic model. If the int8-in path + gains materially *more* than the model predicts, the fp16 A reads were missing + L2 and there is mainloop blocking still to do; materially less, and the + quantization pass was overlapping with the GEMM better than a byte count + suggests. +4. `test_perf_prefill_prefetch_sweep` — a pure-kernel A/B, worth 3–8% on the + short-mainloop shapes if anything. ### Where the remaining prefill headroom is @@ -943,17 +1121,54 @@ traffic the call still moves and ceiling the routing sets: | Lead | What it would change | Where it shows | |---|---|---| -| Fusing the activation quantization into the GEMM's A-tile load | Deletes 2 of the 5 streams (the int8 copy written, then read back) — 14% of the traffic at `K = 768`, 21% at `K = 2048`, 22% at `K = 3072` | Every shape; it is the largest single item left | -| Routing more rows per expert | Nothing in the kernel — it *raises* the ceiling, because the weight stream is the only one that does not grow with the token count | The 8K prompt is exactly this experiment for Qwen3-MoE: 512 rows/expert lifts the ceilings from 129 / 112 to 145 / 123 TFLOPS, and the measured 98.2 / 70.5 TFLOPS follow | -| A single-pass activation quantizer for `K = 3072` | The second read of `[T, K]`, ~450 MB at the compute-bound batch | minimax up only; its row is 96 dwords per lane, past the 16-vector rung | - -A fourth lead has been closed: a 256-row tile halves how often B is re-read per -M tile, but the 8K-prompt sweep timed it at the only routing where it pads no -more than a 128-row tile and it came out level at best (see -[Prefill tile](#prefill-tile)), so the ladder no longer reaches for it. +| Caller-supplied int8 activations ([contract 1](#contract-1--caller-supplied-int8-activations)) | Deletes 3 of the 5 streams — 27% of the traffic at `K = 768`, 37% at `K = 2048`, 44% at `K = 3072` | Every shape; it is the largest single item, and the only one that gets `qwen3 up` past 100 on its own | +| The fused top-k reduction ([contract 2](#contract-2--the-top-k-reduction-fused-into-the-epilogue)) | Turns a `T × N` fp16 write into a `batch × N` fp32 read-modify-write, and deletes the separate reduction kernel | Second projections only; combined with contract 1 it is what gets `qwen3 down` past 100 | +| Routing more rows per expert | Nothing in the kernel — it *raises* the ceiling, because the weight stream is the only one that does not grow with the token count | Real but bounded: `qwen3 down`'s ceiling converges to 105 TFLOPS as rows/expert → ∞, so this lead alone can never reach the target on that shape | +| Prefetch depth, scheduler tile order, non-temporal D stores | Pure mainloop/epilogue work against the 239–296 GB/s the four shapes currently stream | `qwen3 down` is the lowest of the four (239 GB/s): its D is a *write*, and 12 k-tiles is the shortest mainloop, so its prologue/epilogue amortize worst | +| A single-pass activation quantizer for `K = 3072` | The second read of `[T, K]`, ~450 MB at the compute-bound batch | minimax up only; its row is 96 dwords per lane, past the 16-vector rung — and moot under contract 1, which deletes the pass entirely | + +Two leads have been closed by analysis rather than measurement: + +**A 256-row tile** halves how often B is re-read per M tile, but the 8K-prompt +sweep timed it at the only routing where it pads no more than a 128-row tile and +it came out level at best (see [Prefill tile](#prefill-tile)), so the ladder no +longer reaches for it. + +**De-duplicating A for the up/gate projection.** Its `[T, K]` input repeats each +token `top_k = 8` times, so taking `[batch, K]` plus `sorted_token_ids` and +gathering rows would cut the activation streams 8× — this is what vLLM's +`fused_moe` does. It does not port here: the A tile is loaded through the Xe **2D +block descriptor**, which describes a rectangle of a regular surface (base, +pitch, height) and cannot gather, so a gathered A degenerates into one one-row +block load per row of the tile. Gathering only inside the *quantizer* saves +nothing either, because a token's 8 copies are spread across the whole +sorted-by-expert range and miss L2. Contract 1 removes the same bytes for the +same shape without touching the load path, which is why it was implemented +instead. + +**Fusing the activation quantization into the GEMM's A-tile load** — listed in +earlier revisions of this document as the single largest remaining win, at +14–22% of the traffic — does not survive arithmetic either, and the claim was +wrong. It only pays if the quantized A panel stays resident in SLM *and* the +per-row absmax is still available: + +* a per-row absmax needs the whole row before any element can be quantized, so a + mainloop that quantizes during the A-tile load must read A as **fp16** (2 + bytes/element) — which costs exactly what the deleted int8 write + read-back + saved (1 + 1 bytes/element). Net zero. +* keeping the int8 panel resident instead would need `128 × 768` int8 = 96 KB in + one work-group's SLM budget; even `TileM = 64` is 48 KB, over what the tile + configuration allows. +* switching to a per-k-block activation scale sidesteps the absmax, but requires + the fp32 shadow accumulator back — precisely the register cost that was + removed to make the `128x256` tile fit. + +The stream that is genuinely deletable is the one the *producer* writes, not the +one the GEMM reads, which is contract 1. `qwen3 down` (`N = 2048, K = 768`) stays the outlier at ~64–70 TFLOPS: 12 k-tiles per tile is the shortest mainloop of the four, its output is as large as its weights, and its ceiling is the lowest of the set at every routing. It is -also the shape the traffic-side leads above would help most. +also the shape the two contracts help most, and the only one that needs both of +them. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index e61eb7138e..1f8fd6a780 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -187,6 +187,100 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 0.55–0.71× 是预期结果:W4A8 是面向大 batch prefill 的优化,在 decode 阶段只能通过 改善**访存**路径来获益。 +## 削减 prefill 流量:两个可选的调用契约 + +把 roofline 反过来读。计算受限 batch 下四个形状跑在各自天花板的 61–76%,也就是说 +把 kernel 做到完美最多也只有 1.3–1.6× — 但 `qwen3 down` 在每专家 384 行时的天花板 +本身只有 109 TFLOPS,而且**当每专家行数趋于无穷时也只有 105 TFLOPS**:那时权重流 +已经被摊薄掉,剩下的每行 `2 × K` 字节激活流量和 `N × 2` 字节输出完全不会被摊薄。 +所以无论 batch 多大、mainloop 怎么调,这个形状都到不了 100 TFLOPS。必须减它的流 +量。每专家 384 行时它搬运的那 554 MB 具体去了哪里: + +| 数据流 | 字节 | 占比 | +|---|---|---| +| 权重 (唯一不随 token 数增长的一条) | 201 MB | 36% | +| 输出 `[T, N]` fp16 | 201 MB | 36% | +| 激活量化的来回 (读 fp16、写 int8、再读回 int8) | 151 MB | 27% | + +权重那三分之一动不了。另外三分之二并不是 kernel 写得不好 — 它们是**接口层面**的 +冗余,而且只有站在调用之外才看得见: + +* fp16 激活是**上一个** kernel 写出来的 (down-proj 对应的是 SiLU/gate 的 elementwise + pass),它本来就可以直接写 int8; +* `D` 的每一行紧接着就会被 top-k 加权规约吃掉,`top_k` 行合成 1 行。 + +因此 `moe_gemm_w4a8` 增加了两个可选契约,让同时掌握两端的调用方把这些流删掉。两者 +都是 opt-in、默认关闭,原有调用方式完全不变。 + +### 契约 1 — 调用方直接提供 int8 激活 + +```python +out = ark.moe_gemm_w4a8( + qact, # [T, K] int8,按专家排序的行 + weights_s8, wscales, num_tokens_per_expert, + activation_scale=ascale, # [T] fp32,每行一个反量化 scale + out_dtype=torch.bfloat16, # 期望的 fp16/bf16 输出类型 +) +``` + +`qact[r, k] × ascale[r]` 必须能还原出原来的 fp16 行,这正是 kernel 自带量化器产生 +的结果 (`round(x × 127 / absmax)`、`absmax / 127`)。生产者在它本来就持有整行的那批 +寄存器里就能算出 absmax,所以上游是零成本;在本次调用里则删掉了五条流中的三条 — +fp16 读、int8 写、int8 读回 — 外加一次 kernel launch。GEMM 本身没有任何改动,因此 +结果与传 fp16 让 kernel 自己量化**逐位相同** +(`test_prequantized_activations_match_internal` 及其 decode 版本断言的就是这一点)。 + +### 契约 2 — 把 top-k 规约折进 epilogue + +```python +out = ark.moe_gemm_w4a8( + activations, weights_s8, wscales, num_tokens_per_expert, + row_to_token=row_to_token, # [T] int32,路由行 -> 模型 token + routing_weights=routing_weights, # [T] fp32,该行的门控权重 + output_rows=batch, # -> [batch, N] fp32,需预先清零 +) +``` + +epilogue 不再写出 `[T, N]` 让调用方自己规约,而是把每个元素乘上该行的 routing +weight 之后 `atomic_add` 到 `out[row_to_token[r]]`。`T × N × 2` 的写变成 +`batch × N × 4` 的读改写 — 在 `top_k = 8` 时即使按双向计费也只有原来的四分之一 — +而且那个独立的规约 kernel (又一次 `T × N` 读加 `batch × N` 写,本文的流量模型从来 +没算过它,因为那是另一次调用) 整个消失。 + +它只适用于**第二个**投影:up/gate 投影的输出要按路由行进入 SiLU,必须保持未规约状 +态。启用前还有两点需要知道: + +* fp32 atomic 的累加顺序不确定,因此结果**不是逐位相同**的 — 两次运行之间也不是。 + 它是用 harness 的 SNR/余弦门槛而不是相等断言来对照无融合路径验证的 + (`test_fused_reduce_matches_unfused`)。 +* 输出缓冲区必须由调用方清零;当分配由 Python 包装层负责时,它会分配一个已清零的。 + +### 这两项值多少 + +保持每个形状**实测**的有效带宽不变 — 即假设 kernel 一点没变好,只是搬得更少 — 并 +使用 harness 自己的流量模型: + +| 形状 | 路由 | 实测 | + int8 输入 | + 融合规约 | 两者 | +|---|---|---|---|---|---| +| qwen3 up (N=1536, K=2048) | 384 行/专家 | 93.8 | **137** | 不适用 | 不适用 | +| qwen3 down (N=2048, K=768) | 384 行/专家 | 66.8 | 84.0 | 81.6 | **109** | +| minimax up (N=1536, K=3072) | 384 行/专家 | 101.0 | **152** | 不适用 | 不适用 | +| minimax down (N=3072, K=1536) | 384 行/专家 | 104.8 | 129 | 120 | **152** | +| qwen3 up | 8K 提示词 | 98.2 | **152** | 不适用 | 不适用 | +| qwen3 down | 8K 提示词 | 70.5 | 91.0 | 88.1 | **123** | +| minimax up | 8K 提示词 | 93.5 | **137** | 不适用 | 不适用 | +| minimax down | 8K 提示词 | 95.4 | 116 | 108 | **135** | + +(「不适用」= 融合规约不适用于 up/gate 投影,所以这些形状能达到的数字看 +`+ int8 输入` 那一列。) + +两个 qwen3 形状都能越过 100 TFLOPS:up 投影只靠契约 1 就够,down 投影需要**两者一 +起** — 在两种路由下,单独任何一项都不够。`qwen3 down` 的渐近天花板从 105 TFLOPS 变 +为 155 (int8 输入)、147 (融合规约) 或 267 (两者),这才是真正的结论:有了这两个契 +约,这个形状不再受路由限制。 + +以上是流量模型的推算,不是实测 — 见[状态](#状态)。 + ## Decode:合并访存的 K-split 映射 decode GEMV 最初为**每个输出元素分配一个 work-item**:sub-group 中的第 `l` 号 lane @@ -475,8 +569,19 @@ pytest -v -s test_moe_w4a8_perf.py -k perf --models minimax # 扫描 kernel 的各种 dispatch 配置,并打印最快且数值等价的一个 pytest -v -s test_moe_w4a8_perf.py -k sweep + +# 削减流量的调用契约:A/B 扫描、8K 提示词、等价性测试 +pytest -v -s test_moe_w4a8_perf.py -k contract +pytest -v -s test_moe_w4a8_perf.py -k "prequantized or fused_reduce" ``` +`test_perf_prefill_contract_sweep` 和 `test_perf_prefill_contracts_long_seq` 会在 +两种 prefill 路由下,对两个[调用契约](#削减-prefill-流量两个可选的调用契约)的四种 +组合分别计时。由于每个契约都会改变这次调用搬运的内容,每一行的 `DRAM GB/s`、 +`BW@100T` 和天花板都按**该行自己的**流量模型计算,因此各列在不同契约之间仍然可比。 +融合规约的行是与规范化后的基线 (由 harness 对无融合输出做规约) 在 SNR 门槛下比较 +的,而不是其它扫描使用的逐位相同。 + `test_perf_decode_config_sweep`、`test_perf_prefill_tile_sweep`、 `test_perf_prefill_act_quant_sweep`、`test_perf_prefill_act_quant_unroll_sweep` 和 `test_perf_prefill_epilogue_sweep` 只构造一 @@ -503,13 +608,14 @@ python test_moe_w4a8_perf.py --phase decode # 仅 decode python test_moe_w4a8_perf.py --skip-accuracy # 仅性能 python test_moe_w4a8_perf.py --compute-bound # 追加 6144 token 的 prefill 用例 python test_moe_w4a8_perf.py --long-seq # 追加 8K 提示词的 prefill 用例 +python test_moe_w4a8_perf.py --contracts # 追加 int8 输入 + 融合规约的 prefill 用例 python test_moe_w4a8_perf.py --dtype fp16 # fp16 激活 python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 ``` `--long-seq` 与 `--sweep-configs` 一起使用时,还会在 8K 提示词下重跑一遍 prefill 的 -tile 扫描。 +tile 扫描;`--contracts` 以同样方式追加契约的 A/B 扫描。 任何精度门限未通过时,脚本以非 0 状态码退出。 @@ -534,6 +640,35 @@ out = ark.moe_gemm_w4a8( ) ``` +两个可选的 prefill 契约可以削减本次调用搬运的流量 (见 +[削减 prefill 流量](#削减-prefill-流量两个可选的调用契约)),两者默认关闭: + +```python +# 调用方已经持有 int8 激活以及每行一个的反量化 scale。 +out = ark.moe_gemm_w4a8( + qact, # [total_tokens, K] int8 + weights_s8, + wscales, + num_tokens_per_expert, + activation_scale=ascale, # [total_tokens] fp32 + out_dtype=torch.bfloat16, # 返回的 fp16/bf16 输出类型 + rescale_block_size=block, +) + +# 仅第二个投影:把 top-k 加权规约折进 epilogue。 +out = ark.moe_gemm_w4a8( # -> [batch, N] fp32 + activations, + weights_s8, + wscales, + num_tokens_per_expert, + row_to_token=row_to_token, # [total_tokens] int32 + routing_weights=routing_weights, # [total_tokens] fp32 + output_rows=batch, + rescale_block_size=block, + phase="prefill", +) +``` + 下面的便捷封装会同时完成两步,并按权重/scale 张量的标识缓存转换结果: ```python @@ -760,7 +895,8 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认,实测最快)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化的**两遍**映射生效:下面的单遍 kernel 一次性发出整行,会忽略这个开关。 | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | -| `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。 | +| `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` 会对 `2 / 3 / 4 / 6` 计时。超出 `1`–`8` 的取值回退到默认值。 | ## 形状约束 @@ -822,9 +958,25 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo 起来像是 kernel 的缺陷 (参见 [roofline](#权重并不是唯一的数据流))。把所有数据流都计入 之后,四个受算力约束的形状实际上跑在各自真实天花板的 60–74%,而受算力约束的 batch 也 从每专家 256 行提高到 384 行,好让 100 TFLOPS 在所有形状上都是可达的。剩下的差距在访 -存而不是算术:目前仍摆在桌面上的最大一项收益,是把激活量化融合进 GEMM 的 A-tile 加载 -中,这将同时消掉 int8 副本的写与读——5 条数据流中的 2 条,视 K 而定约占 14–22% 的流 -量——但那是主循环的改动,需要在有设备的环境里开发。 +存而不是算术 — 但真正值得删掉的流量原来在调用边界上,而不在主循环里,这正是那两个 +[调用契约](#削减-prefill-流量两个可选的调用契约)所做的事。 + +这两个契约以及 `ARK_MOE_W4A8_PREFILL_PREFETCH` 这个扫描点,是当前的 +`NEEDS-HARDWARE-VALIDATION` 项:它们经过了推导、通过了 lint,但既没有编译过也没有计 +过时,因为撰写环境既没有 XPU 也没有 SYCL 编译器。需要在设备上按顺序验证的是: + +1. `test_prequantized_activations_match_internal` 及其 decode 版本 — int8 输入路径与 + kernel 自带量化器逐位相同。不一致说明 harness 的参考量化器与 + `launch_act_dynamic_quant` 在舍入上有分歧,而不是契约本身有问题。 +2. `test_fused_reduce_matches_unfused` — 与无融合路径的 SNR/余弦对比。预期约 54 dB + (无融合那一侧的 bf16 舍入误差主导,超过 fp32 atomic 的重结合误差),门槛是 + 20 dB / 0.99。 +3. `test_perf_prefill_contract_sweep` 与 `test_perf_prefill_contracts_long_seq` — 实测 + 加速比是否跟得上流量模型。如果 int8 输入路径的收益明显**超过**模型预测,说明原来 + fp16 的 A 读没有命中 L2,主循环侧还有 blocking 可做;明显低于预测,则说明量化那一 + 遍原本与 GEMM 的重叠程度好于按字节数的估计。 +4. `test_perf_prefill_prefetch_sweep` — 纯 kernel 侧的 A/B,若有收益,在短主循环的形状 + 上约为 3–8%。 ### prefill 还剩下多少空间 @@ -833,14 +985,40 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo | 方向 | 会改变什么 | 体现在哪里 | |---|---|---| -| 把激活量化融合进 GEMM 的 A-tile 加载 | 消掉 5 条数据流中的 2 条 (int8 副本的写、以及随后的读回) — `K = 768` 时占 14%、`K = 2048` 时 21%、`K = 3072` 时 22% | 所有形状;这是仍未做的最大一项 | -| 让每个专家分到更多行 | kernel 里什么都不用改 — 它*抬高*的是天花板,因为只有权重这一条流不随 token 数增长 | 8K 提示词对 Qwen3-MoE 正是这个实验:每专家 512 行把天花板从 129 / 112 抬到 145 / 123 TFLOPS,实测的 98.2 / 70.5 TFLOPS 也随之上移 | -| `K = 3072` 的单遍激活量化 | 省掉对 `[T, K]` 的第二次读,在受算力约束的 batch 下约 450 MB | 仅 minimax up;它的一行是每 lane 96 个 dword,超过了 16 向量那一档 | - -还有第四个方向已经收敛:256 行的 tile 能把每个 M tile 重复读 B 的次数减半,但 8K 提示词 -的扫描已经在唯一一种它不比 128 行 tile 多 padding 的路由上测过它,结果最好也只是持平 -(参见 [Prefill tile](#prefill-tile)),因此阶梯不再选用它。 +| 调用方直接提供 int8 激活 ([契约 1](#契约-1--调用方直接提供-int8-激活)) | 消掉 5 条数据流中的 3 条 — `K = 768` 时占 27%、`K = 2048` 时 37%、`K = 3072` 时 44% | 所有形状;这是最大的一项,也是唯一能单独把 `qwen3 up` 送过 100 的一项 | +| 融合 top-k 规约 ([契约 2](#契约-2--把-top-k-规约折进-epilogue)) | 把 `T × N` 的 fp16 写变成 `batch × N` 的 fp32 读改写,并删掉独立的规约 kernel | 仅第二个投影;与契约 1 合起来才是把 `qwen3 down` 送过 100 的那一步 | +| 让每个专家分到更多行 | kernel 里什么都不用改 — 它*抬高*的是天花板,因为只有权重这一条流不随 token 数增长 | 有效但有上限:每专家行数趋于无穷时 `qwen3 down` 的天花板收敛到 105 TFLOPS,因此单靠这一项在该形状上永远到不了目标 | +| 预取深度、调度器 tile 顺序、非临时 (non-temporal) 的 D store | 纯主循环/epilogue 侧的工作,对手是四个形状当前 239–296 GB/s 的实际带宽 | `qwen3 down` 是四者中最低的 (239 GB/s):它的 D 是**写**,而 12 个 k-tile 是最短的主循环,因此 prologue/epilogue 摊得最差 | +| `K = 3072` 的单遍激活量化 | 省掉对 `[T, K]` 的第二次读,在受算力约束的 batch 下约 450 MB | 仅 minimax up;它的一行是每 lane 96 个 dword,超过了 16 向量那一档 — 而且在契约 1 之下已无意义,因为那一遍整个被删掉了 | + +有两个方向是靠分析而不是靠实测收敛的: + +**256 行的 tile** 能把每个 M tile 重复读 B 的次数减半,但 8K 提示词的扫描已经在唯一一种 +它不比 128 行 tile 多 padding 的路由上测过它,结果最好也只是持平 (参见 +[Prefill tile](#prefill-tile)),因此阶梯不再选用它。 + +**为 up/gate 投影去重 A。** 它的 `[T, K]` 输入把每个 token 重复了 `top_k = 8` 次,所以 +改成接收 `[batch, K]` 加 `sorted_token_ids` 再按索引取行,可以把激活相关的数据流减少 +8× — vLLM 的 `fused_moe` 就是这么做的。但它搬不过来:A tile 是通过 Xe 的 **2D block +描述符**加载的,那个描述符描述的是一块规则表面 (基址、pitch、高度) 上的矩形,无法做 +gather,因此 gather 版的 A 会退化成 tile 每行一条单行 block load。只在**量化器**里做 +gather 同样省不到:一个 token 的 8 份拷贝散布在整个按专家排序的区间里,命不中 L2。契约 +1 在不动加载路径的前提下,为同样的形状删掉了同样的字节,所以最后实现的是它。 + +**把激活量化融合进 GEMM 的 A-tile 加载** — 本文档早先版本把它列为仍未做的最大一项收益、 +占流量的 14–22% — 在算术上同样站不住,那个判断是错的。它只有在量化后的 A panel 能常驻 +SLM **且**每行的 absmax 仍然可得时才划算: + +* 每行一个 absmax 意味着必须先看完整行才能量化其中任何一个元素,所以在 A-tile 加载时做 + 量化的主循环只能按 **fp16** (每元素 2 字节) 读 A — 这恰好抵消掉省下来的 int8 写 + 读 + 回 (1 + 1 字节/元素)。净收益为零。 +* 若改为让 int8 panel 常驻,`128 × 768` 的 int8 panel 是 96 KB,塞不进一个 work-group 的 + SLM 预算;即使 `TileM = 64` 也要 48 KB,超出了该 tile 配置允许的范围。 +* 改用按 k-block 的激活 scale 可以绕开 absmax,但需要把 fp32 shadow accumulator 加回来 — + 那正是当初为了让 `128x256` tile 放得下而删掉的寄存器开销。 + +真正可删的那条流是**生产者写出的**那条,而不是 GEMM 读进来的那条,也就是契约 1。 `qwen3 down` (`N = 2048, K = 768`) 仍是那个异常值,只有约 64–70 TFLOPS:每个 tile 只有 12 个 k-tile,是四者中最短的主循环,其输出与权重一样大,而它在任何路由下的天花板也都是 -四者中最低的。它同时也是上面这些访存侧改动收益最大的形状。 +四者中最低的。它同时也是这两个契约收益最大的形状,也是唯一两个契约都需要的形状。 From a3b1241e846ab031335dce001301ef3da17e5d1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:25:07 +0000 Subject: [PATCH 078/112] fix: pass prefetch_dist and reduce through the W4A8 prefill launch macro Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 356bfa6b85..078fdbf3af 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -1558,7 +1558,7 @@ void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* #define ARK_MOE_W4A8_LAUNCH(policy) \ MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ num_tokens_per_expert, E, blocksize, blks, allow_full_tile, \ - allow_block_2d_store, atomic_buffer); + allow_block_2d_store, prefetch_dist, reduce, atomic_buffer); const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); if (tile_env != nullptr) { From 49307cd9deb7b4bcfe995b6f4d759b8a31854665 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:09:18 +0000 Subject: [PATCH 079/112] fix: bound the W4A8 pre-quantized contract test to one ULP instead of bit-identity Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_common.hpp | 16 ++- .../ark/test/README_MOE_W4A8.md | 22 ++-- .../ark/test/README_MOE_W4A8_CN.md | 13 ++- .../ark/test/test_moe_w4a8_perf.py | 101 ++++++++++++++---- 4 files changed, 121 insertions(+), 31 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp index bd229b03a4..2bf2eedead 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp @@ -177,13 +177,25 @@ void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weig * `moe_w4a8_rescale_block_size`); must be a multiple of 64 dividing K. * @param phase 0 = auto (decode when the batch is small), 1 = force decode, * 2 = force prefill. + * @param qact_in Optional `[total_tokens, K]` int8 activations already + * quantized by the caller, with `ascale_in` their `[total_tokens]` fp32 + * per-row scales (`absmax / 127`). Supplying both skips the internal + * quantization pass and `activations` is then unused; neither may be + * given without the other. + * @param row_to_token / routing_weights / fused_out / fused_batch Optional + * fused top-k reduction: the epilogue scales each row and scatter-adds + * it into the zeroed `[fused_batch, N]` fp32 `fused_out` instead of + * writing the unreduced `[total_tokens, N]` to `outputs`. Prefill only, + * and all four must be given together. * * STATUS: NEEDS-HARDWARE-VALIDATION. Implementation is header-only in - * `sycl_tla_moe_w4a8.hpp`. + * `sycl_tla_moe_w4a8.hpp`, which is also where the trailing optional + * parameters get their defaults. */ void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, int* num_tokens_per_expert, - int num_experts, int total_tokens, int phase); + int num_experts, int total_tokens, int phase, const void* qact_in, const float* ascale_in, + const int* row_to_token, const float* routing_weights, float* fused_out, int fused_batch); /** * @brief Resolve the effective W4A8 AUTO_S8 re-scale block size for a given diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 6d6f9ec711..3b2cd34c9d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -256,9 +256,12 @@ kernel's own quantizer produces (`round(x × 127 / absmax)`, `absmax / 127`). Th producer computes that absmax in the same registers it already holds the row in, so upstream this is free; here it deletes three of the five streams — the fp16 read, the int8 write and the int8 read-back — plus one kernel launch. The GEMM -itself is untouched, so results are **bit-identical** to passing fp16 and letting -the kernel quantize (`test_prequantized_activations_match_internal`, and the -decode equivalent, assert exactly that). +itself is untouched, so results agree with passing fp16 and letting the kernel +quantize **to within one step of the output format** — a caller that reproduces +the formula in exact fp32 will not match the device's own divisions bit for bit, +which moves a handful of elements by one bf16 ulp and nothing further +(`test_prequantized_activations_match_internal`, and the decode equivalent, +assert exactly that bound). ### Contract 2 — the top-k reduction fused into the epilogue @@ -1097,10 +1100,15 @@ environment has no XPU and no SYCL compiler. What needs to be checked on device, in order: 1. `test_prequantized_activations_match_internal` and its decode counterpart — - bit-identity of the int8-in path against the kernel's own quantizer. A - mismatch means the harness's reference quantizer and - `launch_act_dynamic_quant` disagree on rounding, not that the contract is - wrong. + the int8-in path against the kernel's own quantizer, to within one step of + the output format. That bound, not exact equality, is what the contract can + promise: the harness runs `127 / absmax` and `absmax / 127` in exact fp32 + while SPIR-V allows a division a few ulp of error, so a few products land on + the other side of a rounding tie. A failure therefore means a genuine + contract bug — a transposed scale, an off-by-one row, the scale read as its + reciprocal — since none of those are worth one step. The assertion prints + the max ULP distance and how much of the tensor moved, which separates the + two cases immediately. 2. `test_fused_reduce_matches_unfused` — SNR/cosine against the unfused path. Expected around 54 dB (bf16 rounding of the unfused rows dominates the fp32 atomic's reassociation), against a 20 dB / 0.99 gate. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 1f8fd6a780..620e046e7f 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -227,8 +227,10 @@ out = ark.moe_gemm_w4a8( 的结果 (`round(x × 127 / absmax)`、`absmax / 127`)。生产者在它本来就持有整行的那批 寄存器里就能算出 absmax,所以上游是零成本;在本次调用里则删掉了五条流中的三条 — fp16 读、int8 写、int8 读回 — 外加一次 kernel launch。GEMM 本身没有任何改动,因此 -结果与传 fp16 让 kernel 自己量化**逐位相同** -(`test_prequantized_activations_match_internal` 及其 decode 版本断言的就是这一点)。 +结果与传 fp16 让 kernel 自己量化的差距**不超过输出格式的一个步长** — 调用方即便用 +精确的 fp32 复现同一套公式,也无法与设备自身的除法逐位相同,这会让极少数元素相差 +一个 bf16 ulp,仅此而已 +(`test_prequantized_activations_match_internal` 及其 decode 版本断言的就是这个界)。 ### 契约 2 — 把 top-k 规约折进 epilogue @@ -966,8 +968,11 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo 过时,因为撰写环境既没有 XPU 也没有 SYCL 编译器。需要在设备上按顺序验证的是: 1. `test_prequantized_activations_match_internal` 及其 decode 版本 — int8 输入路径与 - kernel 自带量化器逐位相同。不一致说明 harness 的参考量化器与 - `launch_act_dynamic_quant` 在舍入上有分歧,而不是契约本身有问题。 + kernel 自带量化器的差距不超过输出格式的一个步长。契约能保证的是这个界而不是完全 + 相等:harness 用精确的 fp32 计算 `127 / absmax` 与 `absmax / 127`,而 SPIR-V 允许 + 除法带若干 ulp 的误差,于是少量乘积会落到舍入分界的另一侧。因此一旦失败,就说明 + 契约确有问题 — scale 转置、行错位、把 scale 当成其倒数 — 因为这些都不止一个步长。 + 断言会打印最大 ULP 距离以及有多少元素发生了变化,可以立刻区分这两种情况。 2. `test_fused_reduce_matches_unfused` — 与无融合路径的 SNR/余弦对比。预期约 54 dB (无融合那一侧的 bf16 舍入误差主导,超过 fp32 atomic 的重结合误差),门槛是 20 dB / 0.99。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 376f5bd829..91d79229c4 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -271,6 +271,53 @@ def _max_rel_err(reference: torch.Tensor, actual: torch.Tensor) -> float: return float(((ref - act).abs() / denom).max().item()) +_ULP_INT_DTYPE = { + torch.bfloat16: torch.int16, + torch.float16: torch.int16, + torch.float32: torch.int32, +} + + +def _ulp_diff(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + """Elementwise distance between two tensors counted in representable steps. + + ``1`` means the two values are neighbours in the format -- the smallest + disagreement that can be expressed -- and ``0`` means identical. This is + the right unit for comparing two computations that are algebraically the + same but not required to round identically: a tolerance in absolute or + relative terms has to be picked to fit the magnitudes at hand, while "one + step apart" is a property of the format and holds across the whole range. + + Floats are ordered by their bit pattern within a sign, so the pattern read + as an integer counts steps directly; the negatives run backwards, so their + magnitude is negated to get one monotone key over the whole line (which + also makes ``-0`` and ``+0`` the same key). + """ + if a.dtype != b.dtype: + raise TypeError(f"_ulp_diff needs one dtype, got {a.dtype} and {b.dtype}") + int_dtype = _ULP_INT_DTYPE.get(a.dtype) + if int_dtype is None: + raise TypeError(f"_ulp_diff does not know the bit layout of {a.dtype}") + magnitude = torch.iinfo(int_dtype).max # 0x7fff / 0x7fffffff: everything but the sign + + def key(t: torch.Tensor) -> torch.Tensor: + bits = t.contiguous().view(int_dtype).to(torch.int64) + return torch.where(bits < 0, -(bits & magnitude), bits) + + return (key(a) - key(b)).abs() + + +def _ulp_report(a: torch.Tensor, b: torch.Tensor) -> str: + """``max ULP`` plus how much of the tensor moved, for assertion messages.""" + ulp = _ulp_diff(a, b) + differing = int((ulp > 0).sum().item()) + total = ulp.numel() + return ( + f"max {int(ulp.max().item())} ULP, {differing}/{total} elements differ " + f"({differing / total:.3%}), max |diff| {(a.float() - b.float()).abs().max().item():.6g}" + ) + + # --------------------------------------------------------------------------- # Shapes # @@ -581,12 +628,18 @@ def _w4a16(case, phase): def _quantize_rows(activations): """Per-row absmax int8 quantization -- the reference for what the kernel does. - Reproduces ``launch_act_dynamic_quant`` element for element: the absmax is - an exact reduction, ``inv = 127 / absmax`` and ``scale = absmax / 127`` are - the same two fp32 operations, and ``rint`` is round-half-to-even, which is - what ``torch.round`` does as well. So handing the result back through the - pre-quantized entry point must reproduce the internally-quantized call - bit-for-bit, not merely closely. + Reproduces ``launch_act_dynamic_quant`` expression for expression: the + absmax is an exact reduction, ``inv = 127 / absmax`` and + ``scale = absmax / 127`` are the same two fp32 operations, and ``rint`` is + round-half-to-even, which is what ``torch.round`` does as well. + + Same expressions is not the same bits, though. Those two divisions are + exact in IEEE fp32 here and only approximate on the device -- SPIR-V lets + a division carry a few ulp of error, and the kernel is not built with the + flags that would forbid that -- so ``inv`` can land one step off. Almost + every product then rounds to the same int8 anyway, and the handful within + a hair of a ``.5`` tie do not, which is why the pre-quantized entry point + is checked against the internal one to within a step rather than exactly. """ a = activations.to(torch.float32) absmax = a.abs().amax(dim=1) @@ -1870,12 +1923,23 @@ def test_prequantized_activations_match_internal(self): The pre-quantized entry point does not change any arithmetic: it removes the pass that computes the int8 copy and takes the caller's - instead. :func:`_quantize_rows` is that pass, expression for - expression -- an exact absmax, the same ``127 / absmax`` - reciprocal, the same round-half-to-even and the same clamp -- so - the GEMM sees the same bytes and the results must be bit-identical. - Any difference is a contract bug: a transposed scale, an off-by-one - row, or the scale being interpreted as its reciprocal. + instead. Everything downstream -- the policy, the tiling, the + accumulation order, the epilogue -- is selected from the same + arguments on both contracts, so the only thing that can differ is + the int8 bytes and the row scales. + + :func:`_quantize_rows` is that pass expression for expression, but + it runs its two divisions in exact fp32 while the device is allowed + a few ulp on them, so a few products fall on the other side of a + rounding tie and a scale can land one step off. That moves an + output by at most one step of the output format: one flipped int8 + perturbs a dot product by a small fraction of a bf16 ulp, and so + does a one-ulp scale. Hence the bound below, which is not a fitted + tolerance but the smallest difference the format can express. + + It still fails loudly for the bugs this is here to catch -- a + transposed scale, an off-by-one row, the scale read as its + reciprocal -- because none of those are worth one step. Both K are covered because the internal quantizer picks its lane mapping from K, and only one of the two rungs would be exercised by @@ -1897,9 +1961,9 @@ def test_prequantized_activations_match_internal(self): ) internal = _w4a8(case, weights_s8, wscales, block, "prefill").clone() external = _w4a8(case, weights_s8, wscales, block, "prefill", prequant=True).clone() - assert torch.equal(internal, external), ( + assert int(_ulp_diff(internal, external).max().item()) <= 1, ( f"{nk_label.strip()} (K={K}): pre-quantized activations disagree with the in-call " - f"quantizer: max |diff| {(internal.float() - external.float()).abs().max().item():.6g}" + f"quantizer by more than a rounding step: {_ulp_report(internal, external)}" ) case = weights_s8 = wscales = internal = external = None ark.clear_moe_w4a8_prepack_cache() @@ -1916,7 +1980,8 @@ def test_prequantized_activations_match_internal_decode(self): standalone scan instead -- a different code path producing a value the GEMV indexes its weights with. If it were wrong every token would read another expert's weights, which this test sees as a - gross mismatch rather than a rounding difference. + gross mismatch rather than the one-step rounding difference the + two quantizers are entitled to. """ case = _build_case( _QWEN3_NK[0][1], @@ -1933,9 +1998,9 @@ def test_prequantized_activations_match_internal_decode(self): ) internal = _w4a8(case, weights_s8, wscales, block, "decode").clone() external = _w4a8(case, weights_s8, wscales, block, "decode", prequant=True).clone() - assert torch.equal(internal, external), ( - "pre-quantized activations disagree with the in-call quantizer on decode: " - f"max |diff| {(internal.float() - external.float()).abs().max().item():.6g}" + assert int(_ulp_diff(internal, external).max().item()) <= 1, ( + "pre-quantized activations disagree with the in-call quantizer on decode by more than a " + f"rounding step: {_ulp_report(internal, external)}" ) def test_fused_reduce_matches_unfused(self): From 064b3cf8fa5fdecf2dea0d33dcd21349e6a70645 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:08:03 +0000 Subject: [PATCH 080/112] fix: build W4A8 pre-quantized contract cases on the int8 grid Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 43 ++++--- .../ark/test/README_MOE_W4A8_CN.md | 28 +++-- .../ark/test/test_moe_w4a8_perf.py | 111 +++++++++++++++--- 3 files changed, 142 insertions(+), 40 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 3b2cd34c9d..5ec49e2a85 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -256,12 +256,23 @@ kernel's own quantizer produces (`round(x × 127 / absmax)`, `absmax / 127`). Th producer computes that absmax in the same registers it already holds the row in, so upstream this is free; here it deletes three of the five streams — the fp16 read, the int8 write and the int8 read-back — plus one kernel launch. The GEMM -itself is untouched, so results agree with passing fp16 and letting the kernel -quantize **to within one step of the output format** — a caller that reproduces -the formula in exact fp32 will not match the device's own divisions bit for bit, -which moves a handful of elements by one bf16 ulp and nothing further -(`test_prequantized_activations_match_internal`, and the decode equivalent, -assert exactly that bound). +itself is untouched, so the same int8 in gives the same result out, to within +the single step of the output format that the row scale's own division is +allowed (`test_prequantized_activations_match_internal`, and the decode +equivalent, assert exactly that bound). + +What the contract does *not* promise is that a caller who re-derives the int8 +from the same fp16 gets the bytes the kernel would have computed. Both round +`x × 127 / absmax`, but SPIR-V allows a division a few ulp of error and a 16-bit +activation grid is coarse enough that exact ties are common, so a handful of +elements can round the other way. Each one is then a different *input* to every +dot product it takes part in — an absolute perturbation, unbounded in ULP +wherever an accumulator cancels to near zero, even though the energy involved is +negligible. That difference belongs to the caller's quantizer, not to this call: +hand over the bytes the producer actually computed and the question does not +arise. The two tests above avoid it by construction, quantizing rows that lie on +the int8 grid (`a = q × 2^-e`, `|q| ≤ 127`), where every product is an integer +and neither quantizer has a tie to break. ### Contract 2 — the top-k reduction fused into the epilogue @@ -1100,15 +1111,17 @@ environment has no XPU and no SYCL compiler. What needs to be checked on device, in order: 1. `test_prequantized_activations_match_internal` and its decode counterpart — - the int8-in path against the kernel's own quantizer, to within one step of - the output format. That bound, not exact equality, is what the contract can - promise: the harness runs `127 / absmax` and `absmax / 127` in exact fp32 - while SPIR-V allows a division a few ulp of error, so a few products land on - the other side of a rounding tie. A failure therefore means a genuine - contract bug — a transposed scale, an off-by-one row, the scale read as its - reciprocal — since none of those are worth one step. The assertion prints - the max ULP distance and how much of the tensor moved, which separates the - two cases immediately. + the int8-in path against the kernel's own quantizer. Both cases are built on + the int8 grid (`a = q × 2^-e`, `|q| ≤ 127`), where the row absmax is exactly + `127 × 2^-e` and both `127 / absmax` and `absmax / 127` are powers of two, so + no product is anywhere near a rounding tie and the two paths must produce + identical int8. What is left is the device's own division for the row scale, + worth at most an ulp — and it multiplies the whole row, so it moves an output + by at most one step of the output format wherever that output lies. A failure + therefore means a genuine contract bug — a transposed scale, an off-by-one + row, the scale read as its reciprocal — since none of those are worth one + step. The assertion prints the max ULP distance, how much of the tensor moved + and the SNR, which separates a last-bit difference from a structural one. 2. `test_fused_reduce_matches_unfused` — SNR/cosine against the unfused path. Expected around 54 dB (bf16 rounding of the unfused rows dominates the fp32 atomic's reassociation), against a 20 dB / 0.99 gate. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 620e046e7f..6dd1416dd9 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -227,10 +227,18 @@ out = ark.moe_gemm_w4a8( 的结果 (`round(x × 127 / absmax)`、`absmax / 127`)。生产者在它本来就持有整行的那批 寄存器里就能算出 absmax,所以上游是零成本;在本次调用里则删掉了五条流中的三条 — fp16 读、int8 写、int8 读回 — 外加一次 kernel launch。GEMM 本身没有任何改动,因此 -结果与传 fp16 让 kernel 自己量化的差距**不超过输出格式的一个步长** — 调用方即便用 -精确的 fp32 复现同一套公式,也无法与设备自身的除法逐位相同,这会让极少数元素相差 -一个 bf16 ulp,仅此而已 -(`test_prequantized_activations_match_internal` 及其 decode 版本断言的就是这个界)。 +传入同样的 int8 就会得到同样的结果,差距不超过行 scale 那次除法本身被允许的**一个 +输出格式步长** (`test_prequantized_activations_match_internal` 及其 decode 版本断言 +的就是这个界)。 + +这个契约**不**保证的是:调用方从同一份 fp16 重新推导出的 int8 一定与 kernel 自己算 +出来的字节相同。两边都在对 `x × 127 / absmax` 做舍入,但 SPIR-V 允许除法带若干 ulp +的误差,而 16 位激活的取值网格又粗到经常出现精确的舍入分界,于是少数元素会朝另一侧 +舍入。这样的元素随后就是它参与的每一个点积的另一个**输入** — 是绝对量的扰动,只要 +某个累加器抵消到接近零,用 ULP 衡量就没有上界,尽管其能量微不足道。这个差异属于调用 +方的量化器,而不属于本次调用:把生产者实际算出的字节直接传进来,这个问题就不存在。 +上面那两个测试则是从构造上回避它 — 量化的行落在 int8 网格上 (`a = q × 2^-e`、 +`|q| ≤ 127`),每个乘积都是整数,两边的量化器都没有分界要打破。 ### 契约 2 — 把 top-k 规约折进 epilogue @@ -968,11 +976,13 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo 过时,因为撰写环境既没有 XPU 也没有 SYCL 编译器。需要在设备上按顺序验证的是: 1. `test_prequantized_activations_match_internal` 及其 decode 版本 — int8 输入路径与 - kernel 自带量化器的差距不超过输出格式的一个步长。契约能保证的是这个界而不是完全 - 相等:harness 用精确的 fp32 计算 `127 / absmax` 与 `absmax / 127`,而 SPIR-V 允许 - 除法带若干 ulp 的误差,于是少量乘积会落到舍入分界的另一侧。因此一旦失败,就说明 - 契约确有问题 — scale 转置、行错位、把 scale 当成其倒数 — 因为这些都不止一个步长。 - 断言会打印最大 ULP 距离以及有多少元素发生了变化,可以立刻区分这两种情况。 + kernel 自带量化器的对比。两个用例都构造在 int8 网格上 (`a = q × 2^-e`、 + `|q| ≤ 127`):行 absmax 恰好是 `127 × 2^-e`,`127 / absmax` 与 `absmax / 127` 都是 + 2 的幂,因此没有任何乘积靠近舍入分界,两条路径必然产生完全相同的 int8。剩下的只有 + 设备自身那次求行 scale 的除法,最多差一个 ulp — 而它是整行的乘性因子,所以无论输出 + 落在哪里,都最多移动一个输出格式的步长。因此一旦失败,就说明契约确有问题 — scale + 转置、行错位、把 scale 当成其倒数 — 因为这些都不止一个步长。断言会打印最大 ULP + 距离、有多少元素发生了变化以及 SNR,可以区分末位差异与结构性错误。 2. `test_fused_reduce_matches_unfused` — 与无融合路径的 SNR/余弦对比。预期约 54 dB (无融合那一侧的 bf16 舍入误差主导,超过 fp32 atomic 的重结合误差),门槛是 20 dB / 0.99。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 91d79229c4..0c926e6d64 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -308,13 +308,22 @@ def key(t: torch.Tensor) -> torch.Tensor: def _ulp_report(a: torch.Tensor, b: torch.Tensor) -> str: - """``max ULP`` plus how much of the tensor moved, for assertion messages.""" + """``max ULP`` plus how much of the tensor moved, for assertion messages. + + The SNR is printed alongside because the two numbers fail differently: a + contract that reads the wrong scale or the wrong row misses by orders of + magnitude and takes the SNR down with it, while a difference confined to + the last bits leaves the SNR high however large the worst ULP distance is + (an output that cancels to near zero is many steps from its neighbour at + no cost in energy). + """ ulp = _ulp_diff(a, b) differing = int((ulp > 0).sum().item()) total = ulp.numel() return ( f"max {int(ulp.max().item())} ULP, {differing}/{total} elements differ " - f"({differing / total:.3%}), max |diff| {(a.float() - b.float()).abs().max().item():.6g}" + f"({differing / total:.3%}), max |diff| {(a.float() - b.float()).abs().max().item():.6g}, " + f"SNR {_snr_db(a, b):.2f} dB" ) @@ -490,6 +499,49 @@ def _prefill_batches(all_shapes: bool) -> list: # --------------------------------------------------------------------------- +def _int8_grid_activations(total_tokens, K, dtype, generator): + """Activations every correct absmax quantizer maps to the *same* int8 bytes. + + The pre-quantized contract is only testable if the caller's int8 and the + kernel's own agree, and on arbitrary data they are not required to. Both + compute ``inv = 127 / absmax`` and round ``a * inv``, but the harness runs + that division in exact fp32 while SPIR-V lets the device's carry a few ulp, + so any product sitting on a ``.5`` tie can land on either side. With 16-bit + activations that is not a rare accident -- a bf16 row carries 8 mantissa + bits, so exact ties are common -- and a flipped int8 is *not* a rounding + difference in the output: it is a different input to the dot product, which + moves the result by ``|w| * scales`` in absolute terms, i.e. without bound + in ULP wherever the accumulator happens to cancel to near zero. + + So the rows are put on the quantizer's own grid instead. ``a = q * 2^-e`` + with integer ``|q| <= 127`` is exact in every 16-bit float format, ``127`` + is planted in each row so its absmax is exactly ``127 * 2^-e``, and both + ``127 / absmax = 2^e`` and ``absmax / 127 = 2^-e`` are powers of two. Every + product is then the integer ``q`` itself -- half a step from the nearest + tie, a margin no plausible division error comes close to bridging -- so the + two quantizers must produce identical bytes and any difference in the + output belongs to the contract rather than to the rounding. + + ``e`` walks 4..8 down the rows so neighbouring rows have different scales: + a scale read from the wrong row is then a factor of two, not a coincidence. + """ + q = torch.randint(-127, 128, (total_tokens, K), generator=generator, dtype=torch.int32) + q[:, 0] = 127 + exponent = -(4 + torch.arange(total_tokens, dtype=torch.int32) % 5) + activations = torch.ldexp(q.to(torch.float32), exponent.unsqueeze(1)).to(dtype) + + # The construction is only worth anything if it survives the cast to + # `dtype` and the reference quantizer's own two divisions, so it is checked + # here -- cheaply, on the host, before the rows reach the device -- rather + # than left for a later edit to reintroduce the ties unnoticed. + q_back, scale_back = _quantize_rows(activations) + assert torch.equal(q_back.to(torch.int32), q), f"the int8 grid does not survive {dtype}" + assert torch.equal( + scale_back, torch.ldexp(torch.ones(total_tokens), exponent) + ), "the row scales are not the powers of two the grid was built from" + return activations + + def _build_case( N, K, @@ -502,6 +554,7 @@ def _build_case( need_reference=True, need_dequant=True, topk=None, + act_int8_grid=False, ): """Build one W4A8 MoE test case. @@ -525,13 +578,22 @@ def _build_case( -- because an expert's block of rows is shorter than ``batch`` at every shipped ``E`` -- never twice on the same expert, i.e. the same structure a real router produces after the rows are sorted by expert. + + ``act_int8_grid`` swaps the normally-distributed activations for rows that + quantize exactly (:func:`_int8_grid_activations`). It is for the tests that + compare two *quantizers* against each other rather than the kernel against + a reference, where a tie broken differently on either side would swamp what + is being measured. """ generator = torch.Generator(device="cpu").manual_seed(seed) w_float = torch.randn(E, N, K, generator=generator, dtype=torch.float32) * 0.05 scales = torch.empty(E, N, K // group_size, dtype=dtype) packed = _pack_int4_sym(w_float, scales, group_size) - activations = (torch.randn(total_tokens, K, generator=generator, dtype=torch.float32) * 0.5).to(dtype) + if act_int8_grid: + activations = _int8_grid_activations(total_tokens, K, dtype, generator) + else: + activations = (torch.randn(total_tokens, K, generator=generator, dtype=torch.float32) * 0.5).to(dtype) tpe = _spread_tokens(total_tokens, E) ntpe = torch.tensor(tpe, dtype=torch.int32) @@ -636,10 +698,11 @@ def _quantize_rows(activations): Same expressions is not the same bits, though. Those two divisions are exact in IEEE fp32 here and only approximate on the device -- SPIR-V lets a division carry a few ulp of error, and the kernel is not built with the - flags that would forbid that -- so ``inv`` can land one step off. Almost - every product then rounds to the same int8 anyway, and the handful within - a hair of a ``.5`` tie do not, which is why the pre-quantized entry point - is checked against the internal one to within a step rather than exactly. + flags that would forbid that -- so ``inv`` can land one step off, and any + product that sits on a ``.5`` tie then rounds the other way. The callers + that need the two to agree byte for byte hand this function rows that + cannot tie (:func:`_int8_grid_activations`); on arbitrary rows it is a + faithful model of the kernel, not a bit-exact one. """ a = activations.to(torch.float32) absmax = a.abs().amax(dim=1) @@ -1930,16 +1993,30 @@ def test_prequantized_activations_match_internal(self): :func:`_quantize_rows` is that pass expression for expression, but it runs its two divisions in exact fp32 while the device is allowed - a few ulp on them, so a few products fall on the other side of a - rounding tie and a scale can land one step off. That moves an - output by at most one step of the output format: one flipped int8 - perturbs a dot product by a small fraction of a bf16 ulp, and so - does a one-ulp scale. Hence the bound below, which is not a fitted - tolerance but the smallest difference the format can express. + a few ulp on them, so on ordinary activations a product that sits + on a rounding tie can round the other way. That is not a rounding + difference in the *output*: a flipped int8 is a different input to + every dot product it takes part in, and one that lands next to a + cancelling accumulator moves the result by any number of steps. No + useful bound survives it, so the case is built on the quantizer's + own grid instead (:func:`_int8_grid_activations`), where every + product is an integer half a step from the nearest tie and both + quantizers must emit the same bytes. + + What is left is the row scale. ``absmax / 127`` is a power of two + on this grid, so the exact result is representable and the device's + division can miss it by at most an ulp -- and it enters the + epilogue as a *factor*, so it perturbs every output by the same + relative amount rather than by an absolute one, which is at most + one step of the output format wherever the output lies. Hence the + bound below, which is not a fitted tolerance but the smallest + difference the format can express. It still fails loudly for the bugs this is here to catch -- a transposed scale, an off-by-one row, the scale read as its - reciprocal -- because none of those are worth one step. + reciprocal -- because none of those are worth one step, and the + per-row exponent makes a scale taken from the wrong row a factor of + two rather than a coincidence. Both K are covered because the internal quantizer picks its lane mapping from K, and only one of the two rungs would be exercised by @@ -1955,6 +2032,7 @@ def test_prequantized_activations_match_internal(self): torch.bfloat16, need_reference=False, need_dequant=False, + act_int8_grid=True, ) weights_s8, wscales, block = ark.moe_w4a8_prepack( case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE @@ -1980,8 +2058,8 @@ def test_prequantized_activations_match_internal_decode(self): standalone scan instead -- a different code path producing a value the GEMV indexes its weights with. If it were wrong every token would read another expert's weights, which this test sees as a - gross mismatch rather than the one-step rounding difference the - two quantizers are entitled to. + gross mismatch rather than the one-step scale difference the two + paths are entitled to. """ case = _build_case( _QWEN3_NK[0][1], @@ -1992,6 +2070,7 @@ def test_prequantized_activations_match_internal_decode(self): torch.bfloat16, need_reference=False, need_dequant=False, + act_int8_grid=True, ) weights_s8, wscales, block = ark.moe_w4a8_prepack( case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE From 1649b92f4b5d1471dfb5c3d4189cc06259a72a7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:00:40 +0000 Subject: [PATCH 081/112] style: fix continuation indent on sycl_tla_moe_decode_fill_expert_id decl Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp index 1b77c8583f..544ca9017c 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp @@ -96,7 +96,7 @@ struct MoeDecodeParams { }; void sycl_tla_moe_decode_fill_expert_id(sycl::queue* q, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts, int total_tokens); + const int* num_tokens_per_expert, int num_experts, int total_tokens); void sycl_tla_moe_decode_fp(const MoeDecodeParams& params); void sycl_tla_moe_decode_int4(const MoeDecodeParams& params); void sycl_tla_moe_decode_int8(const MoeDecodeParams& params); From ff1ca3e607d3dc520029d3fadde3731d68ddf1cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:31:37 +0000 Subject: [PATCH 082/112] fix: emit ark::moe_decode_release_scratch from generated int4 decode TU Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/sycl_tla_moe.cpp.in | 7 +++++++ .../wrapper/include/sycl_tla_common.hpp | 14 ++++++++++++++ .../wrapper/include/sycl_tla_moe_decode.hpp | 16 +++++++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in index 1fb29e1b22..c31b59c26c 100644 --- a/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in +++ b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in @@ -844,6 +844,13 @@ void sycl_tla_moe_decode_int4(const MoeDecodeParams& params) { } } +// Public entry point for the int4 decode scratch pools. It lives in this TU +// because `ark.cpp` only includes `sycl_tla_common.hpp` and pybind takes the +// function's address, so an inline definition in `sycl_tla_moe_decode.hpp` +// would not be emitted anywhere. The pools are `inline` accessors over +// function-local statics, so this releases the buffers for every decode TU. +void moe_decode_release_scratch() { moe_decode_detail::release_scratch(); } + } // namespace ark #elif @MOE_SOURCE_MODE@ == 19 diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp index 544ca9017c..dd0fb0d111 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp @@ -115,6 +115,20 @@ void sycl_tla_moe_decode_fp8(const MoeDecodeParams& params); bool sycl_tla_moe_decode_int4_dpas_fastpath(const MoeDecodeParams& params); bool sycl_tla_moe_decode_fp8_dpas_fastpath(const MoeDecodeParams& params); +/** + * @brief Release the device scratch buffers the int4 decode fallbacks hold + * (the N-tiled weight repack and the activation-sum table). + * + * Both are grow-on-demand per-queue slabs normally kept for the process + * lifetime; call this to hand the memory back, or to drop a repack cached + * under `ARK_MOE_DECODE_INT4_REPACK_CACHE` before the underlying weight buffer + * is freed. Safe to call at any time -- the next decode simply reallocates. + * + * Defined by the generated `sycl_tla_moe_decode_int4.cpp` translation unit + * (MOE_SOURCE_MODE 18); the pools are shared across every decode TU. + */ +void moe_decode_release_scratch(); + /** * @brief MoE Grouped GEMM optimized for the prefill phase, supporting the * same set of weight encodings as `moe_gemm_decode` (FP16/BF16, INT8 sym/asym, diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp index 6ede6b540c..0f2122639e 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_decode.hpp @@ -1744,11 +1744,21 @@ void launch_fp8_by_mode(sycl::queue* q, const ScalarT* activations, const uint8_ // process; call this to hand the memory back, or to drop a repack cached under // `ARK_MOE_DECODE_INT4_REPACK_CACHE` before the underlying weight buffer is // freed. Safe to call at any time -- the next decode simply reallocates. +// +// The public `ark::moe_decode_release_scratch` entry point is emitted by the +// generated `sycl_tla_moe_decode_int4.cpp` translation unit (MOE_SOURCE_MODE +// 18) -- `ark.cpp` only sees `sycl_tla_common.hpp`, and pybind takes the +// function's address, so it needs a real external definition rather than an +// inline one. Both pools are `inline` accessors over function-local statics, +// so every translation unit shares the same instance and releasing from mode +// 18 drops the buffers used by the other decode TUs too. // ---------------------------------------------------------------------------- -inline void moe_decode_release_scratch() { - moe_decode_detail::int4_repack_pool().release_all(); - moe_decode_detail::act_group_sum_pool().release_all(); +namespace moe_decode_detail { +inline void release_scratch() { + int4_repack_pool().release_all(); + act_group_sum_pool().release_all(); } +} // namespace moe_decode_detail // ---------------------------------------------------------------------------- // Env-flag helper -- `ARK_MOE_DECODE_DPAS_S4` (default ON). When ON, int4-sym From 170be37fad34ae2854fc78a9f86d87e792af34dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:44:01 +0000 Subject: [PATCH 083/112] docs: point W4A8 declarations at MOE_SOURCE_MODE 21, not 19 Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp index ae495d2001..7aae54010e 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp @@ -236,7 +236,7 @@ void moe_gemm_prefill_int_dpas(sycl::queue* q, void* activations, void* weights, * STATUS: NEEDS-HARDWARE-VALIDATION. The kernels live in * `sycl_tla_moe_w4a8.hpp` (namespace `moe_w4a8_detail`); these `ark::` entry * points are emitted by the generated `sycl_tla_moe_w4a8.cpp` translation unit - * (MOE_SOURCE_MODE 19). + * (MOE_SOURCE_MODE 21). */ void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, From d7b1e0f2fa8a58857b0e2e8215692fb076356e22 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:26:41 +0000 Subject: [PATCH 084/112] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../ark/test/README_MOE_W4A8.md | 17 +++++++++++------ .../ark/test/README_MOE_W4A8_CN.md | 17 +++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 5ec49e2a85..f893e0b0da 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -244,10 +244,12 @@ call is unchanged. ```python out = ark.moe_gemm_w4a8( - qact, # [T, K] int8, rows sorted by expert - weights_s8, wscales, num_tokens_per_expert, + qact, # [T, K] int8, rows sorted by expert + weights_s8, + wscales, + num_tokens_per_expert, activation_scale=ascale, # [T] fp32, dequant scale per row - out_dtype=torch.bfloat16, # what the fp16/bf16 output should be + out_dtype=torch.bfloat16, # what the fp16/bf16 output should be ) ``` @@ -278,10 +280,13 @@ and neither quantizer has a tie to break. ```python out = ark.moe_gemm_w4a8( - activations, weights_s8, wscales, num_tokens_per_expert, - row_to_token=row_to_token, # [T] int32, routed row -> model token + activations, + weights_s8, + wscales, + num_tokens_per_expert, + row_to_token=row_to_token, # [T] int32, routed row -> model token routing_weights=routing_weights, # [T] fp32, that row's gate weight - output_rows=batch, # -> [batch, N] fp32, pre-zeroed + output_rows=batch, # -> [batch, N] fp32, pre-zeroed ) ``` diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 6dd1416dd9..12e23eb743 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -216,10 +216,12 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 ```python out = ark.moe_gemm_w4a8( - qact, # [T, K] int8,按专家排序的行 - weights_s8, wscales, num_tokens_per_expert, + qact, # [T, K] int8,按专家排序的行 + weights_s8, + wscales, + num_tokens_per_expert, activation_scale=ascale, # [T] fp32,每行一个反量化 scale - out_dtype=torch.bfloat16, # 期望的 fp16/bf16 输出类型 + out_dtype=torch.bfloat16, # 期望的 fp16/bf16 输出类型 ) ``` @@ -244,10 +246,13 @@ fp16 读、int8 写、int8 读回 — 外加一次 kernel launch。GEMM 本身 ```python out = ark.moe_gemm_w4a8( - activations, weights_s8, wscales, num_tokens_per_expert, - row_to_token=row_to_token, # [T] int32,路由行 -> 模型 token + activations, + weights_s8, + wscales, + num_tokens_per_expert, + row_to_token=row_to_token, # [T] int32,路由行 -> 模型 token routing_weights=routing_weights, # [T] fp32,该行的门控权重 - output_rows=batch, # -> [batch, N] fp32,需预先清零 + output_rows=batch, # -> [batch, N] fp32,需预先清零 ) ``` From a5f0f756ae3620acb133b3ce4f463a87dcb2b5fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:58:39 +0000 Subject: [PATCH 085/112] refactor: split W4A8 MoE kernels across translation units to cut compile memory Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../sycl_tla_generation.cmake | 59 + .../ark/auto_round_kernel/sycl_tla_moe.cpp.in | 76 +- .../wrapper/include/sycl_tla_common.hpp | 11 +- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 1482 +---------------- .../include/sycl_tla_moe_w4a8_helpers.hpp | 664 ++++++++ .../include/sycl_tla_moe_w4a8_kernels.hpp | 1036 ++++++++++++ 6 files changed, 1893 insertions(+), 1435 deletions(-) create mode 100644 auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp create mode 100644 auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp diff --git a/auto_round_extension/ark/auto_round_kernel/sycl_tla_generation.cmake b/auto_round_extension/ark/auto_round_kernel/sycl_tla_generation.cmake index 5d3415a331..6d9a54370c 100644 --- a/auto_round_extension/ark/auto_round_kernel/sycl_tla_generation.cmake +++ b/auto_round_extension/ark/auto_round_kernel/sycl_tla_generation.cmake @@ -449,9 +449,68 @@ generate_sycl_tla_source(sycl_tla_moe.cpp.in sycl_tla_moe_prefill_s4.cpp) set(MOE_SOURCE_MODE 17) generate_sycl_tla_source(sycl_tla_moe.cpp.in sycl_tla_moe_prefill_native_fp8.cpp) # W4A8 (int4 weights re-scaled to int8, int8 activations) prepack + GEMM. +# +# This used to be a single `sycl_tla_moe_w4a8.cpp`, which instantiated all 52 +# W4A8 kernels -- 12 grouped DPAS GEMMs, 14 decode GEMVs, 22 activation-quant +# and 4 prepack kernels -- and peaked at ~4.2 GB of compiler RSS. It is now +# fanned out the same way the S4 prefill is (see MOE_SOURCE_MODE 8): one TU per +# variant, with the dispatcher left holding no kernels at all. +# +# mode 21 dispatcher 0 kernels, cutlass-free +# mode 22 prefill, dtype x tile 1 DPAS kernel each (12 TUs) +# mode 23 decode, dtype 7 kernels each (2 TUs, cutlass-free) +# mode 24 act quant, dtype 11 kernels each (2 TUs, cutlass-free) +# mode 25 prepack, dtype 2 kernels each (2 TUs, cutlass-free) +# +# Only the 12 prefill TUs include the CuTe stack; the other seven take +# `sycl_tla_moe_w4a8_kernels.hpp` / `..._helpers.hpp`, which stop at plain SYCL. set(MOE_SOURCE_MODE 21) generate_sycl_tla_source(sycl_tla_moe.cpp.in sycl_tla_moe_w4a8.cpp) +# Prefill tile ladder: the policy names must match `W4A8PrefillTile` / +# `moe_w4a8_prefill_select_tile` in `sycl_tla_moe_w4a8_helpers.hpp`, and the +# function names the `prefill_*` declarations there. +set(_w4a8_tiles m8 m64 m128 m128n256 m256n128 large) +foreach(_w4a8_dtype IN ITEMS f16 bf16) + if(_w4a8_dtype STREQUAL "f16") + set(MOE_SCALAR_TYPE sycl::half) + else() + set(MOE_SCALAR_TYPE sycl::ext::oneapi::bfloat16) + endif() + + set(MOE_SOURCE_MODE 22) + foreach(_w4a8_tile IN LISTS _w4a8_tiles) + if(_w4a8_tile STREQUAL "m8") + set(MOE_POLICY_NAME w4a8_policy_m_8) + elseif(_w4a8_tile STREQUAL "m64") + set(MOE_POLICY_NAME w4a8_policy_m_64) + elseif(_w4a8_tile STREQUAL "m128") + set(MOE_POLICY_NAME w4a8_policy_m_128) + elseif(_w4a8_tile STREQUAL "m128n256") + set(MOE_POLICY_NAME w4a8_policy_m_128_n256) + elseif(_w4a8_tile STREQUAL "m256n128") + set(MOE_POLICY_NAME w4a8_policy_m_256_n128) + else() + set(MOE_POLICY_NAME w4a8_policy_large) + endif() + set(MOE_FUNCTION_NAME prefill_${_w4a8_dtype}_${_w4a8_tile}) + generate_sycl_tla_source(sycl_tla_moe.cpp.in + sycl_tla_moe_w4a8_prefill_${_w4a8_dtype}_${_w4a8_tile}.cpp) + endforeach() + + set(MOE_SOURCE_MODE 23) + set(MOE_FUNCTION_NAME decode_${_w4a8_dtype}) + generate_sycl_tla_source(sycl_tla_moe.cpp.in sycl_tla_moe_w4a8_decode_${_w4a8_dtype}.cpp) + + set(MOE_SOURCE_MODE 24) + set(MOE_FUNCTION_NAME quant_${_w4a8_dtype}) + generate_sycl_tla_source(sycl_tla_moe.cpp.in sycl_tla_moe_w4a8_quant_${_w4a8_dtype}.cpp) + + set(MOE_SOURCE_MODE 25) + set(MOE_FUNCTION_NAME prepack_${_w4a8_dtype}) + generate_sycl_tla_source(sycl_tla_moe.cpp.in sycl_tla_moe_w4a8_prepack_${_w4a8_dtype}.cpp) +endforeach() + foreach(_moe_dtype IN ITEMS f16 bf16) if(_moe_dtype STREQUAL "f16") set(MOE_SCALAR_TYPE sycl::half) diff --git a/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in index 1da38d2e3a..6b0f1c6918 100644 --- a/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in +++ b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in @@ -1042,8 +1042,12 @@ void dispatch_@MOE_DISPATCH_DTYPE@_@MOE_TILE_SUFFIX@(sycl::queue* q, const void* #elif @MOE_SOURCE_MODE@ == 21 +// W4A8 dispatcher. Deliberately includes only the cutlass-free front end: the +// tile choice and the public entry points are host code, and every kernel +// lives in one of the per-variant TUs generated by modes 22-25. This TU +// therefore instantiates no kernels at all. #include "sycl_tla_common.hpp" -#include "sycl_tla_moe_w4a8.hpp" +#include "sycl_tla_moe_w4a8_helpers.hpp" namespace ark { @@ -1071,6 +1075,76 @@ void moe_w4a8_release_scratch() { moe_w4a8_detail::moe_w4a8_release_scratch(); } } // namespace ark +#elif @MOE_SOURCE_MODE@ == 22 + +// W4A8 prefill, one DPAS policy per translation unit. +// +// The single TU this replaced instantiated 52 kernels -- 12 of them grouped +// DPAS GEMMs -- and peaked at ~4.2 GB of compiler RSS. Fanning the tile ladder +// out over (dtype x tile) leaves exactly one DPAS kernel here, mirroring how +// `sycl_tla_moe_prefill_s4_*.cpp` splits the S4 prefill. +#include "sycl_tla_moe_w4a8.hpp" + +namespace ark { +namespace moe_w4a8_detail { + +void @MOE_FUNCTION_NAME@(const W4A8PrefillParams& params) { + moe_w4a8::moe_w4a8_prefill_launch(params); +} + +} // namespace moe_w4a8_detail +} // namespace ark + +#elif @MOE_SOURCE_MODE@ == 23 + +// W4A8 decode GEMV, one dtype per translation unit (7 kernels: the legacy +// GEMV plus the six K-split (NCOLS, CH) specializations). Takes the +// cutlass-free kernel header, so this TU never parses CuTe. +#include "sycl_tla_moe_w4a8_kernels.hpp" + +namespace ark { +namespace moe_w4a8_detail { + +void @MOE_FUNCTION_NAME@(const W4A8DecodeParams& params) { + moe_w4a8::moe_w4a8_decode_launch<@MOE_SCALAR_TYPE@>(params); +} + +} // namespace moe_w4a8_detail +} // namespace ark + +#elif @MOE_SOURCE_MODE@ == 24 + +// W4A8 per-token activation quantization, one dtype per translation unit +// (11 kernels: the message-width x unroll grid, the single-pass variants and +// the scalar fallback). Cutlass-free. +#include "sycl_tla_moe_w4a8_kernels.hpp" + +namespace ark { +namespace moe_w4a8_detail { + +void @MOE_FUNCTION_NAME@(const W4A8QuantParams& params) { + moe_w4a8::moe_w4a8_quant_launch<@MOE_SCALAR_TYPE@>(params); +} + +} // namespace moe_w4a8_detail +} // namespace ark + +#elif @MOE_SOURCE_MODE@ == 25 + +// W4A8 one-shot AUTO_S8 prepack, one dtype per translation unit (2 kernels: +// the per-block scale reduction and the int4 -> int8 re-scale). Cutlass-free. +#include "sycl_tla_moe_w4a8_kernels.hpp" + +namespace ark { +namespace moe_w4a8_detail { + +void @MOE_FUNCTION_NAME@(const W4A8PrepackParams& params) { + moe_w4a8::moe_w4a8_prepack_launch<@MOE_SCALAR_TYPE@>(params); +} + +} // namespace moe_w4a8_detail +} // namespace ark + #endif #endif // ARK_XPU && ARK_SYCL_TLA \ No newline at end of file diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp index 7aae54010e..07afa30645 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp @@ -233,10 +233,13 @@ void moe_gemm_prefill_int_dpas(sycl::queue* q, void* activations, void* weights, * Use `moe_w4a8_rescale_block_size` to resolve the effective block size (and * therefore the `wscales` shape) before allocating. * - * STATUS: NEEDS-HARDWARE-VALIDATION. The kernels live in - * `sycl_tla_moe_w4a8.hpp` (namespace `moe_w4a8_detail`); these `ark::` entry - * points are emitted by the generated `sycl_tla_moe_w4a8.cpp` translation unit - * (MOE_SOURCE_MODE 21). + * STATUS: NEEDS-HARDWARE-VALIDATION. These `ark::` entry points are emitted by + * the generated `sycl_tla_moe_w4a8.cpp` translation unit (MOE_SOURCE_MODE 21), + * which forwards to the inline front end in `sycl_tla_moe_w4a8_helpers.hpp` + * (namespace `moe_w4a8_detail`). The kernels themselves are split across the + * per-variant TUs generated by MOE_SOURCE_MODE 22-25 so that no single + * translation unit instantiates more than a handful of them; see + * `sycl_tla_generation.cmake`. */ void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 3fb951fc58..2117f692d1 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -26,6 +26,23 @@ // `sycl_tla_moe_prefill_fp8_dpas.hpp`; nothing is currently in that state. // --------------------------------------------------------------------------- // +// File layout +// ----------- +// The W4A8 path is spread over three headers so that no translation unit pays +// for more than it uses: +// +// sycl_tla_moe_w4a8_helpers.hpp declarations, host helpers, scratch pools +// (cutlass-free; what the dispatcher sees) +// sycl_tla_moe_w4a8_kernels.hpp activation quant, AUTO_S8 prepack, decode +// GEMV (cutlass-free, plain SYCL) +// sycl_tla_moe_w4a8.hpp this file: DPAS tile policies, the grouped +// prefill GEMM and its launcher (needs CuTe) +// +// `sycl_tla_generation.cmake` then emits one translation unit per variant, so +// the twelve (dtype x tile) prefill instantiations compile separately rather +// than all landing in one 4.2 GB TU. The design notes below cover the path as +// a whole. +// // What this file implements // ------------------------- // A W4A8 MoE path: **weights are stored as int4** (auto-round's packed @@ -117,718 +134,25 @@ // Pulls in the cutlass-sycl / CuTe include set, the `dpas_policy_base` policy // root, `make_moe_tensor`, and `get_atomic_scratch_buffer` (via the FP8 -// header it includes). +// header it includes). This is the expensive include, so only the generated +// per-tile prefill TUs see this header at all -- the decode / quant / prepack +// TUs take `sycl_tla_moe_w4a8_kernels.hpp` and the dispatcher TU takes +// `sycl_tla_moe_w4a8_helpers.hpp`, neither of which reaches cutlass. #include "sycl_tla_moe_prefill_int_dpas.hpp" -// `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE`, and -// the shared nibble decoders. -#include "sycl_tla_moe_decode.hpp" +// The cutlass-free half: activation quant, AUTO_S8 prepack and decode GEMV, +// plus the scratch pools and parameter structs they share with the prefill. +#include "sycl_tla_moe_w4a8_kernels.hpp" namespace ark { namespace moe_w4a8 { using namespace cute; -using moe_decode_detail::N_TILE; -using moe_decode_detail::SG_SIZE; -using moe_dequant::decode_int4_octet; - -// Symmetric int4 full range: 2^(bits-1). Matches `fullrange` in -// `xpu_wrapper.hpp`'s `packscale` rescale kernel. -constexpr float kInt4FullRange = 8.0f; -constexpr float kInt8Max = 127.0f; - -// K elements decoded per work-item in the prepack kernel (one 32-bit word of -// packed nibbles). Requires `K % 8 == 0`, which the shape gate enforces. -constexpr int kPrepackOctet = 8; - -// --------------------------------------------------------------------------- -// Kernel name tags (one per specialization, required for SYCL kernel naming) -// --------------------------------------------------------------------------- -template -class MoEW4A8ActQuant; - -template -class MoEW4A8ActQuantVec; - -template -class MoEW4A8ActQuantSingle; - -template -class MoEW4A8ScaleReduce; - -template -class MoEW4A8Repack; - -template -class MoEW4A8DecodeGemv; - -template -class MoEW4A8DecodeKSplit; - +// Kernel name tag for the grouped prefill GEMM (the other tags live in +// `sycl_tla_moe_w4a8_kernels.hpp`). template class MoEW4A8GemmName; -// --------------------------------------------------------------------------- -// Scratch pools. -// -// The activation-quantization buffers (`[total_tokens, K]` int8 + -// `[total_tokens]` fp32) and the decode expert map (`[total_tokens]` int32) -// are recomputed on every call, so they come from persistent slabs instead of -// a hot-path `malloc_device`. -// -// The slabs are served from the extension-wide `DeviceMemoryPool`, which keys -// on the device UUID rather than on a `sycl::queue*`: a slab therefore follows -// the device and is immune to the caller destroying its queue and to a later -// queue landing on the same address. This mirrors how the int4 decode scratch -// is managed in `sycl_tla_moe_decode_scratch.{hpp,cpp}`. -// -// Slabs are intentionally never freed from a static destructor -- the SYCL -// context may already be torn down by then. `moe_w4a8_release_scratch` -// provides the explicit teardown (exposed to Python under the same name). -// -// Sharing one slab per device means these entry points must not be driven -// concurrently from two queues on one device, which matches every other -// `DeviceMemoryPool` slot. -// --------------------------------------------------------------------------- - -// `DeviceMemoryPool` slots owned by the W4A8 path. Slots 0-7 belong to the -// dnnl / xpu / sycl-s8 / cpu wrappers and the SDPA kernels, slot 8 to the DPAS -// work-group counter, and slots 9-10 to the int4 decode scratch. -inline constexpr size_t kW4A8QactScratchLoc = 11; -inline constexpr size_t kW4A8ExpertMapScratchLoc = 12; - -struct W4A8ScratchState { - std::mutex mu; - // Device key (`DeviceMemoryPool::get_device_key`) -> a queue handle on that - // device, held *by value*: a `sycl::queue` is a reference-counted handle, so - // keeping a copy guarantees the queue outlives the memory allocated against - // it. - std::map queues; -}; - -// Intentionally leaked, see above. -inline W4A8ScratchState& w4a8_scratch_state() { - static W4A8ScratchState* s = new W4A8ScratchState(); - return *s; -} - -// Acquire a slab from the shared pool, synchronizing first when the request -// grows it: `DeviceMemoryPool` frees the old pointer in place when it grows a -// slot, and in-flight kernels may still be reading the old slab, so the wait -// has to happen before the call rather than after. -// -// The caller must hold `W4A8ScratchState::mu`. -inline void* acquire_w4a8_slab(sycl::queue* q, size_t bytes, size_t buf_loc) { - auto* pool = DeviceMemoryPool::Instance(); - const size_t held = pool->get_scratch_size(buf_loc, q); - if (held != 0 && held < bytes) { - q->wait(); - } - void* ptr = pool->get_scratch_mem(bytes, buf_loc, q); - if (ptr == nullptr) { - // The pool records the slot before checking the result, so a failed - // allocation leaves a {bytes, nullptr} entry behind that would satisfy - // every later request of this size or smaller without ever retrying. - // Drop it so the next call allocates again. - pool->detach_scratch_mem(buf_loc, q); - throw std::runtime_error("moe_gemm_w4a8: failed to allocate device scratch buffer"); - } - auto& st = w4a8_scratch_state(); - const size_t key = pool->get_device_key(q); - if (st.queues.find(key) == st.queues.end()) { - st.queues.emplace(key, *q); - } - return ptr; -} - -// Quantized activations + per-token scales. -inline uint8_t* acquire_qact_scratch(sycl::queue* q, size_t bytes) { - if (q == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); - } - if (bytes == 0) return nullptr; - auto& st = w4a8_scratch_state(); - std::lock_guard lock(st.mu); - return static_cast(acquire_w4a8_slab(q, bytes, kW4A8QactScratchLoc)); -} - -// Decode expert map (`[total_tokens]` int32). -inline int* acquire_expert_map_scratch(sycl::queue* q, size_t bytes) { - if (q == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); - } - if (bytes == 0) return nullptr; - auto& st = w4a8_scratch_state(); - std::lock_guard lock(st.mu); - return static_cast(acquire_w4a8_slab(q, bytes, kW4A8ExpertMapScratchLoc)); -} - -// --------------------------------------------------------------------------- -// Per-token dynamic activation quantization: act dtype -> int8 + fp32 scale. -// -// One sub-group per token: lanes stride the K axis (coalesced), reduce the -// absmax with `reduce_over_group`, then write back the quantized row. A row -// that is entirely zero gets `scale = 0` and quantizes to all zeros (the -// reciprocal is forced to 0 instead of inf). -// -// The decode path also needs `expert_id_per_token`, which -// `moe_decode_detail::fill_expert_id_per_token` produces in a kernel of its -// own. That kernel does one tiny scan per token, so at decode sizes it is pure -// launch overhead on a timeline where the GEMV itself is only tens of -// microseconds and one call is issued per generated token. This kernel already -// runs one sub-group per token, so when `expert_id_per_token != nullptr` lane 0 -// folds the same scan in and the separate launch disappears -- the same "one -// fewer kernel launch on the decode timeline" the FP8 DPAS decode dispatch -// gets by consuming `num_tokens_per_expert` directly. The scan is the verbatim -// body of `fill_expert_id_per_token`, including its clamp to -// `num_experts - 1` for a routing table that sums to less than `total_tokens`. -// -// Message width (the prefill cost that matters) -// --------------------------------------------- -// This kernel is a pure streaming pass -- it reads `[T, K]` activations twice -// (absmax, then quantize) and writes `[T, K]` int8 -- so at prefill sizes it is -// a sizeable fraction of the whole `moe_gemm_w4a8` call, not a preamble. At -// 32768 routed rows and K = 2048 it touches ~200 MB, next to the ~400 MB the -// grouped GEMM streams for the qwen3 up-proj weights. -// -// The scalar mapping below (`k = lane; k < K; k += SG_SIZE`) moves that traffic -// in the *narrowest* messages the sub-group can issue: 16 lanes x one 16-bit -// element is a 32-byte load and 16 lanes x one int8 is a **16-byte** store, i.e. -// a quarter of a cache line per store message. That is the same defect the -// decode GEMV had before the K-split rewrite, and it was worth 1.09-1.93x there. -// -// `launch_act_dynamic_quant_vec` fixes it the same way: each lane owns `VEC` -// *consecutive* elements, so one message covers `SG_SIZE * VEC` contiguous -// elements -- 256 bytes of activations and 128 bytes of int8 at `VEC = 8`. -// Both passes read the same `sycl::vec`, and the second pass re-reads a row the -// first pass just touched, so it is served by the cache rather than DRAM. -// -// `VEC` is chosen from K: 8 when `K % 128 == 0` (every shipped MoE shape -- -// 768 / 1536 / 2048 / 3072), otherwise 4, which the `K % 64 == 0` shape gate -// always satisfies. A misaligned base pointer (never the case for torch -// allocations or the scratch pool) falls back to the scalar kernel, and -// `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. -// -// `test_perf_prefill_act_quant_sweep` on BMG (384 rows/expert, bf16 act) puts -// the widened messages at 1.12x (qwen3 up), 1.10x (qwen3 down), 1.15x (minimax -// up) and 1.07x (minimax down) of the scalar mapping on the *whole* -// `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of -// prefill than that, since the GEMM around it is unchanged. (Earlier runs of -// the same sweep read 1.05 / 1.13 / 1.11 / 1.04, 1.13 / 1.14 / 1.12 / 1.04 and -// 1.14 / 1.07 / 1.10 / 1.07: the ranking is stable, the individual ratios move -// by a few percent between runs.) -// -// `sycl::vec` is used rather than `sycl::vec` -// because `sycl::vec` of `bfloat16` is not universally available; the elements -// are `bit_cast` back one at a time, exactly like the decode kernels' -// `ActVec` loads in `sycl_tla_moe_decode.hpp`. -// -// Requests in flight (the cost widening the messages did not address) -// ------------------------------------------------------------------- -// Wide messages fix how many bytes each *request* moves; they do not change -// how many requests a work-item has outstanding. This kernel walks K with a -// runtime trip count (`steps = K / (SG_SIZE * VEC)`) and folds every vector -// into the same `local_max` accumulator, so the loop reads as: issue one load, -// stall until it returns, `fmax` it, repeat. Xe cores execute in order and -// `fmax` is not reassociated without fast-math, so each thread keeps roughly -// *one* 256-byte load in flight. -// -// That is a Little's-law problem, not a bandwidth one: 1280 concurrent -// sub-groups (the B60's occupancy ceiling -- 160 XVEs x 8 thread slots) x 256 -// bytes is ~320 KB of in-flight reads, under the ~456 KB a 456 GB/s device -// needs to stay busy across a ~1 us memory latency, and a real launch rarely -// fills every slot. The same argument is why the decode GEMV loads two chunks -// per iteration (`launch_w4a8_decode_ksplit`), and why the pair is spelled out -// there rather than left to the compiler. -// -// `UNROLL` gives the pass the same treatment: each iteration loads `UNROLL` -// *independent* vectors before consuming any of them, and reduces them into -// `UNROLL` separate partial maxima so the loads do not serialize behind the -// accumulator chain either. At the default `UNROLL = 4` a thread holds 1 KB, -// which clears the 456 KB well before every slot is occupied. The quantize -// pass batches its loads the same way, and its stores are already independent. -// `steps % UNROLL` vectors are left to a tail loop -- `K = 768` (qwen3 down) -// gives `steps = 6`, so the tail is real code, not a formality. -// -// Nothing that rounds changes: the per-lane partial reduction is still `fmax` -// over the same values (exact and order-independent, so partial maxima merge -// to the same bits), and every element goes through the same `rint`/`clamp` -// expression. `UNROLL = 1` is the previous kernel instruction for instruction, -// so `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` is an exact A/B baseline; -// `test_act_quant_unroll_matches` asserts every depth is bit-identical and -// `test_perf_prefill_act_quant_unroll_sweep` times them. -// -// That sweep keeps the default, though not by much: only minimax up is a real -// A/B in it (the other three shapes take the single-pass kernel below, where -// `UNROLL` is dead code, so their rows are three sets of identical kernels -- -// a useful noise probe, spreading 3.3-7.3% in the latest run and 0.4-3.9% in -// the one before). On that shape the three depths read 6.982 ms at 1, -// 6.795 ms at 2 and 6.837 ms at 4: the batched loads are worth 1.02-1.03x over -// `UNROLL = 1`, and the 0.6% between 2 and 4 is an order of magnitude inside -// the noise the identical-kernel rows show, so the default stays at 4 (the -// earlier run had it 8.959 / 8.967 / 9.139 ms, i.e. 4 fastest). -// -// Reading the row once (the traffic the two passes duplicate) -// ----------------------------------------------------------- -// Batching the loads did not change how many there are. The absmax has to see -// the whole row before the first element can be quantized, so the kernel reads -// `[T, K]`, reduces, then reads `[T, K]` again -- and at 384 rows per expert -// the activation matrix is 1.5 MB for K = 2048, against 3.1 MB of weights for -// the whole GEMM. The re-read is L2-resident when the row is still there, but -// the rows a work-group quantizes second are evicted by the ones it quantized -// first well before the pass ends: at 8 MB of L2 and 4 KB per bf16 row of -// K = 2048, only ~2000 of 2048 tokens' rows fit *if nothing else is resident*, -// and the GEMM's weights are competing for the same cache immediately after. -// -// A row is small enough to keep in registers instead: a lane owns `K / 16` -// elements, so `K = 2048` is 256 bytes -- 64 of the 128 dwords per lane the -// quantizer gets (it launches without `grf_size<256>`, unlike the GEMM). Load -// the row once, reduce it, then quantize out of the registers. The second read -// disappears, and every load is issued before any of them is consumed, which -// subsumes what `UNROLL` was doing (`UNROLL = steps`, effectively) rather than -// competing with it. -// -// `MAX_STEPS` is the compile-time cap that makes the fragment a register array -// rather than scratch: the loop is `#pragma unroll` over `MAX_STEPS` with an -// `if (s < steps)` guard, so every index is a constant and SROA can promote it. -// Two rungs are instantiated -- 8 vectors (K <= 1024 at VEC = 8, 32 dwords) and -// 16 (K <= 2048, 64 dwords) -- and anything longer keeps the two-pass kernel, -// which is why minimax's K = 3072 up-projection still takes the old path. The -// partial maxima stay at four accumulators, as in the two-pass kernel, so the -// reduction chain is unchanged in both cost and value. -// -// This was a register-pressure gamble -- if 64 dwords of row plus addressing -// spilled, the pass would get slower, not faster -- and the sweep settled it in -// its favour: at 384 rows/expert the single-pass kernel is 1.06x (qwen3 down, -// K = 768), 1.04x (qwen3 up, K = 2048, the rung filled exactly) and 1.02x -// (minimax down, K = 1536) against the two-pass one. Nothing spills. minimax -// up (K = 3072) is past the last rung, so both of its rows run the *same* -// two-pass kernel and their 1.01x is this sweep's noise probe. -// `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` still restores the two-pass -// kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times the -// pair, and `test_act_quant_single_pass_matches` asserts they agree bit for bit -// (same `fmax` set, same `inv`, same `rint`/`clamp`). -// --------------------------------------------------------------------------- - -// Fold the per-token expert scan (decode only) into the quantization kernel. -// Verbatim body of `moe_decode_detail::fill_expert_id_per_token`. -inline void act_quant_write_scale(float* ascale, int token, float scale, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - ascale[token] = scale; - if (expert_id_per_token == nullptr) return; - int offset = 0; - int expert = num_experts - 1; - for (int e = 0; e < num_experts; ++e) { - const int n = num_tokens_per_expert[e]; - if (token < offset + n) { - expert = e; - break; - } - offset += n; - } - expert_id_per_token[token] = expert; -} - -template -void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, - int total_tokens, int K, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); - static_assert(UNROLL >= 1 && (UNROLL & (UNROLL - 1)) == 0, "UNROLL must be a power of two"); - using ActVec = sycl::vec; - using QVec = sycl::vec; - - // Vectors a lane walks over. `K % (SG_SIZE * VEC) == 0` is checked by the - // caller, so the loop needs no tail -- but `steps` need not be a multiple of - // `UNROLL` (K = 768 gives 6 vectors at VEC = 8), hence the second loop. - const int steps = K / (SG_SIZE * VEC); - const int main_steps = steps - (steps % UNROLL); - - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int lane = static_cast(it.get_local_id(1)); - const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); - QVec* out = reinterpret_cast(qact + static_cast(token) * K); - - // One partial maximum per unrolled slot: `fmax` is exact, so merging - // them below gives the same absmax as a single chain, but the loads no - // longer wait on it. - float part_max[UNROLL]; -#pragma unroll - for (int u = 0; u < UNROLL; ++u) part_max[u] = 0.0f; - - for (int s = 0; s < main_steps; s += UNROLL) { - ActVec v[UNROLL]; -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { - v[u] = row[static_cast(s + u) * SG_SIZE + lane]; - } -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); - part_max[u] = sycl::fmax(part_max[u], sycl::fabs(static_cast(a))); - } - } - } - for (int s = main_steps; s < steps; ++s) { - const ActVec v = row[static_cast(s) * SG_SIZE + lane]; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[e])); - part_max[0] = sycl::fmax(part_max[0], sycl::fabs(static_cast(a))); - } - } - - float local_max = part_max[0]; -#pragma unroll - for (int u = 1; u < UNROLL; ++u) local_max = sycl::fmax(local_max, part_max[u]); - - auto sg = it.get_sub_group(); - const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); - - const float scale = absmax / kInt8Max; - const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; - if (lane == 0) { - act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); - } - - // Same batching on the way back: the re-read of a row the first pass - // just touched is served by the cache, but only if enough of it is - // requested at once. - for (int s = 0; s < main_steps; s += UNROLL) { - ActVec v[UNROLL]; -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { - v[u] = row[static_cast(s + u) * SG_SIZE + lane]; - } -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { - QVec qv; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); - const float x = sycl::rint(static_cast(a) * inv); - qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); - } - out[static_cast(s + u) * SG_SIZE + lane] = qv; - } - } - for (int s = main_steps; s < steps; ++s) { - const ActVec v = row[static_cast(s) * SG_SIZE + lane]; - QVec qv; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[e])); - const float x = sycl::rint(static_cast(a) * inv); - qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); - } - out[static_cast(s) * SG_SIZE + lane] = qv; - } - }); -} - -// Vectors a lane loads before it consumes any of them. `4` covers every -// shipped shape's `steps` (6 / 12 / 16 / 24 at VEC = 8) with at most a -// two-vector tail. `ARK_MOE_W4A8_ACT_QUANT_UNROLL` selects 1 (the previous -// kernel), 2 or 4; anything else falls back to the default. -constexpr int kActQuantUnrollDefault = 4; - -// Partial maxima the single-pass kernel reduces into, matching the two-pass -// kernel's default `UNROLL` so the two produce the same value bit for bit. -constexpr int kActQuantSinglePartials = 4; - -// Longest row a lane keeps in registers, in vectors. 16 vectors is 64 dwords -// per lane at VEC = 8 (K = 2048), half the default 128-dword budget; longer -// rows take the two-pass kernel rather than risk a spill. -constexpr int kActQuantSingleMaxSteps = 16; - -// Single-pass variant: the row is loaded once into registers, reduced, then -// quantized out of them. `MAX_STEPS` bounds the register array at compile time -// (see the design note above); `steps <= MAX_STEPS` is the caller's contract. -template -void launch_act_dynamic_quant_vec_single(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, - int total_tokens, int K, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); - static_assert(MAX_STEPS >= kActQuantSinglePartials, "MAX_STEPS must cover the partial accumulators"); - using ActVec = sycl::vec; - using QVec = sycl::vec; - - const int steps = K / (SG_SIZE * VEC); - - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int lane = static_cast(it.get_local_id(1)); - const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); - QVec* out = reinterpret_cast(qact + static_cast(token) * K); - - // The whole row, one load per slot, every one issued before the first - // is consumed. Constant indices under the unroll keep it in registers. - ActVec v[MAX_STEPS]; -#pragma unroll - for (int s = 0; s < MAX_STEPS; ++s) { - if (s < steps) { - v[s] = row[static_cast(s) * SG_SIZE + lane]; - } - } - - float part_max[kActQuantSinglePartials]; -#pragma unroll - for (int u = 0; u < kActQuantSinglePartials; ++u) part_max[u] = 0.0f; - -#pragma unroll - for (int s = 0; s < MAX_STEPS; ++s) { - if (s < steps) { -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); - part_max[s % kActQuantSinglePartials] = - sycl::fmax(part_max[s % kActQuantSinglePartials], sycl::fabs(static_cast(a))); - } - } - } - - float local_max = part_max[0]; -#pragma unroll - for (int u = 1; u < kActQuantSinglePartials; ++u) local_max = sycl::fmax(local_max, part_max[u]); - - auto sg = it.get_sub_group(); - const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); - - const float scale = absmax / kInt8Max; - const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; - if (lane == 0) { - act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); - } - - // No second read of the row: it is already here. -#pragma unroll - for (int s = 0; s < MAX_STEPS; ++s) { - if (s < steps) { - QVec qv; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); - const float x = sycl::rint(static_cast(a) * inv); - qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); - } - out[static_cast(s) * SG_SIZE + lane] = qv; - } - } - }); -} - -inline int moe_w4a8_act_quant_unroll() { - const char* env = std::getenv("ARK_MOE_W4A8_ACT_QUANT_UNROLL"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && (v == 1 || v == 2 || v == 4)) return static_cast(v); - } - return kActQuantUnrollDefault; -} - -// How many k-tiles of A and B the prefill mainloop keeps prefetched ahead of -// the tile it is computing. The prologue issues `prefetch_dist` pairs before -// the first DPAS and the loop then issues one pair per tile, so this is the -// depth of the memory pipeline the mainloop runs against -- too shallow and -// the DPAS waits on L2, too deep and the prefetched lines are evicted before -// use (and the prologue itself becomes a serial stall on short K). -// -// 3 is the value the mainloop was written with and the sibling prefill kernels -// use. The shapes here are short in K (12 k-tiles at K = 768), which is exactly -// where the depth is worth re-measuring, so it is a runtime knob rather than a -// constant; `test_perf_prefill_prefetch_sweep` walks it. -inline constexpr int kPrefillPrefetchDefault = 3; - -inline int moe_w4a8_prefill_prefetch_dist() { - const char* env = std::getenv("ARK_MOE_W4A8_PREFILL_PREFETCH"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && v >= 1 && v <= 8) return static_cast(v); - } - return kPrefillPrefetchDefault; -} - -// Runtime unroll depth -> compile-time bridge. -template -void launch_act_dynamic_quant_vec_unroll(int unroll, sycl::queue* q, const ScalarT* activations, int8_t* qact, - float* ascale, int total_tokens, int K, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - // Register-resident single pass when the row fits, the two-pass kernel - // otherwise. The smallest rung that covers `steps` is chosen so a short row - // does not reserve registers for slots it never loads. - const int steps = K / (SG_SIZE * VEC); - if (steps <= kActQuantSingleMaxSteps && - moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS", true)) { - if (steps <= kActQuantSingleMaxSteps / 2) { - launch_act_dynamic_quant_vec_single( - q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); - } else { - launch_act_dynamic_quant_vec_single( - q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); - } - return; - } - - if (unroll == 1) { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - } else if (unroll == 2) { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - } else { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - } -} - -template -void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, - int total_tokens, int K, int* expert_id_per_token = nullptr, - const int* num_tokens_per_expert = nullptr, int num_experts = 0) { - static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - if (total_tokens == 0) return; - - // Widest lane chunk this shape and these buffers support. The alignment - // checks never fire for torch allocations or the scratch pool (both are at - // least 256-byte aligned), but a caller-supplied activation view could be - // offset, and an unaligned `sycl::vec` access would be undefined. - if (moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_VEC", true)) { - const auto act_addr = reinterpret_cast(activations); - const auto q_addr = reinterpret_cast(qact); - const int unroll = moe_w4a8_act_quant_unroll(); - if (K % (SG_SIZE * 8) == 0 && act_addr % (8 * sizeof(ScalarT)) == 0 && q_addr % 8 == 0) { - launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - return; - } - if (K % (SG_SIZE * 4) == 0 && act_addr % (4 * sizeof(ScalarT)) == 0 && q_addr % 4 == 0) { - launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - return; - } - } - - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int lane = static_cast(it.get_local_id(1)); - const ScalarT* row = activations + static_cast(token) * K; - int8_t* out = qact + static_cast(token) * K; - - float local_max = 0.0f; - for (int k = lane; k < K; k += SG_SIZE) { - local_max = sycl::fmax(local_max, sycl::fabs(static_cast(row[k]))); - } - auto sg = it.get_sub_group(); - const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); - - const float scale = absmax / kInt8Max; - const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; - if (lane == 0) { - act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); - } - - for (int k = lane; k < K; k += SG_SIZE) { - const float v = sycl::rint(static_cast(row[k]) * inv); - out[k] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); - } - }); -} - -// --------------------------------------------------------------------------- -// AUTO_S8 stage 1: per-(expert, output channel, block) re-scale factor. -// -// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * (8 / 127) -// -// Verbatim port of the rescale branch of `packscale` in `xpu_wrapper.hpp` -// (with `fullrange = 8` for int4). An all-zero block yields `sxt = 0`; stage 2 -// turns that into all-zero int8 weights, so the (equally zero) product is -// still exact. -// --------------------------------------------------------------------------- -template -void launch_weight_scale_reduce(sycl::queue* q, const ScalarT* scales, float* wscale_out, int E, int N, int K, - int group_size, int rescale_block, int nblk) { - const int groups_k = K / group_size; - const int groups_per_block = rescale_block / group_size; - - q->parallel_for>( - sycl::range<1>(static_cast(E) * static_cast(N) * static_cast(nblk)), - [=](sycl::id<1> id) { - const size_t idx = id[0]; - const int blk = static_cast(idx % static_cast(nblk)); - const size_t row = idx / static_cast(nblk); // e * N + n - const ScalarT* s_row = - scales + row * static_cast(groups_k) + static_cast(blk) * groups_per_block; - - float absmax = 0.0f; - for (int g = 0; g < groups_per_block; ++g) { - absmax = sycl::fmax(absmax, sycl::fabs(static_cast(s_row[g]))); - } - wscale_out[idx] = absmax * (kInt4FullRange / kInt8Max); - }); -} - -// --------------------------------------------------------------------------- -// AUTO_S8 stage 2: int4 -> int8 re-scale. -// -// w8[k] = round(w4[k] * s[k / group_size] / sxt[k / rescale_block]) -// -// Verbatim port of the `CfgDequantS8Rescale` branch of `unpackq` in -// `xpu_wrapper.hpp`. One work-item decodes one 32-bit word (8 nibbles); the -// shape gate guarantees `group_size % 8 == 0` and `rescale_block % 8 == 0`, so -// all 8 K indices of a word share the same group scale and the same block -// scale and both loads hoist out of the inner loop. -// --------------------------------------------------------------------------- -template -void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, const ScalarT* scales, - const float* wscale, int8_t* w8_out, int E, int N, int K, int group_size, - int rescale_block, int nblk) { - const int groups_k = K / group_size; - const int octets = K / kPrepackOctet; - - q->parallel_for>( - sycl::range<2>(static_cast(E) * static_cast(N), static_cast(octets)), - [=](sycl::id<2> id) { - const size_t row = id[0]; // e * N + n - const int oct = static_cast(id[1]); - const int k_base = oct * kPrepackOctet; - - const uint8_t* w_ptr = weights + row * static_cast(K / 2) + static_cast(oct) * 4; - const uint32_t word = *reinterpret_cast(w_ptr); - int q4[kPrepackOctet]; - decode_int4_octet(word, q4); - - const float s = static_cast(scales[row * static_cast(groups_k) + k_base / group_size]); - const float sx = wscale[row * static_cast(nblk) + k_base / rescale_block]; - const float f = sx > 0.0f ? s / sx : 0.0f; - - int8_t* out = w8_out + row * static_cast(K) + k_base; -#pragma unroll - for (int j = 0; j < kPrepackOctet; ++j) { - const float v = sycl::rint(static_cast(q4[j]) * f); - out[j] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); - } - }); -} - // --------------------------------------------------------------------------- // Tile policies. // @@ -884,7 +208,7 @@ void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, cons // larger work-group (512 threads, one per Xe core) does not give back in // scheduling granularity. The only reading ever in its favour is an older run // at 256 rows/expert, 1.3-3.9% ahead, inside the noise floor. The ladder -// therefore stops taking it (see `moe_w4a8_prefill_dispatch`): it has no +// therefore stops taking it (see `moe_w4a8_prefill_launch`): it has no // measured upside, and a routing skewed around the average the ladder sees // puts individual experts back on the padding cliff. // @@ -1559,7 +883,7 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const } // --------------------------------------------------------------------------- -// Prefill driver: policy selection on the average per-expert M. +// Prefill launch: one instantiation per tile policy (see `moe_w4a8_prefill_select_tile`). // // The rungs match the tile ladder of `launch_igemm_kblock` in // `sycl_tla_s8_gemm.hpp`: a grouped GEMM's M is *per expert*, so the ladder @@ -1595,10 +919,10 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // column of its own sweep; it then became a padding-gated 256-row tile, which // the 8K-prompt sweep has now retired. // -// `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile -// (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything -// else -- including the default `auto` -- keeps the ladder. It exists so the -// tile can be swept on hardware without a rebuild. +// The tile choice itself lives in `moe_w4a8_prefill_select_tile` +// (`sycl_tla_moe_w4a8_helpers.hpp`) and happens before this function is +// reached, so each translation unit instantiates exactly one policy. See +// `ARK_MOE_W4A8_PREFILL_TILE` there for the override. // // `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` makes every tile take the guarded // epilogue (see `xe_gemm_w4a8`), which is the A/B baseline for the interior- @@ -1614,740 +938,38 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // anything that does not keeps the scalar store rather than risking a // misaligned descriptor. // --------------------------------------------------------------------------- -template -void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* num_tokens_per_expert, int E, - int N, int K, int blocksize, int blks, int total_tokens, - MoEFusedReduce reduce = MoEFusedReduce{}) { - if (E == 0 || N == 0 || K == 0 || total_tokens == 0) return; +template +void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { + if (p.num_experts == 0 || p.N == 0 || p.K == 0 || p.total_tokens == 0) return; + + compat::set_default_queue(*p.q); - compat::set_default_queue(*q); + auto* outputs = static_cast(p.outputs); + + MoEFusedReduce reduce{}; + if (p.fused_out != nullptr) { + reduce.row_to_token = p.row_to_token; + reduce.row_weight = p.row_weight; + reduce.out = p.fused_out; + reduce.batch = p.fused_batch; + } - const int A_avg_M = total_tokens / E; - const bool tile_n_256 = (N % 256) == 0; const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); - const bool store_2d_aligned = !reduce.enabled() && (static_cast(N) * sizeof(ElementD)) % 64 == 0 && + const bool store_2d_aligned = !reduce.enabled() && (static_cast(p.N) * sizeof(ElementD)) % 64 == 0 && reinterpret_cast(outputs) % 64 == 0; const bool allow_block_2d_store = store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); const int prefetch_dist = moe_w4a8_prefill_prefetch_dist(); - int32_t* atomic_buffer = moe_dpas_fp8::get_atomic_scratch_buffer(q); - -#define ARK_MOE_W4A8_LAUNCH(policy) \ - MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ - num_tokens_per_expert, E, blocksize, blks, allow_full_tile, \ - allow_block_2d_store, prefetch_dist, reduce, atomic_buffer); - - const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); - if (tile_env != nullptr) { - if (std::strcmp(tile_env, "8x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) - return; - } else if (std::strcmp(tile_env, "64x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - return; - } else if (std::strcmp(tile_env, "128x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) - return; - } else if (std::strcmp(tile_env, "128x256") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) - return; - } else if (std::strcmp(tile_env, "256x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) - return; - } else if (std::strcmp(tile_env, "256x256") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) - return; - } - } - - if (A_avg_M < 16) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) - } else if (A_avg_M < 128) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - } else if (tile_n_256) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) - } else { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) - } -#undef ARK_MOE_W4A8_LAUNCH -} - -// --------------------------------------------------------------------------- -// Decode GEMV: int8 x int8 -> int32, one output column per sub-group lane. -// -// Same work decomposition as `moe_decode_detail::launch_int8` (work-group = -// one sub-group covering 16 consecutive N columns of one token), with the -// per-K-group float dequantization replaced by a per-block int32 dot product. -// Two accumulators hide the multiply-add latency; int32 cannot overflow here -// (|a|,|w| <= 127 gives < 2^14 per product, so K would have to exceed 130k). -// --------------------------------------------------------------------------- -template -void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* expert_id_per_token, int total_tokens, - int N, int K, int blocksize, int blks) { - if (N % N_TILE != 0) { - throw std::invalid_argument("moe_gemm_w4a8(decode): N must be a multiple of 16"); - } - if (total_tokens == 0) return; - - const int n_tiles = N / N_TILE; - sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int n_tile = static_cast(it.get_group(1)); - const int lane = static_cast(it.get_local_id(1)); - const int n_global = n_tile * N_TILE + lane; - - const int expert = expert_id_per_token[token]; - const int8_t* act_row = qact + static_cast(token) * K; - const int8_t* w_row = weights + (static_cast(expert) * N + static_cast(n_global)) * K; - const float* s_row = - wscale + (static_cast(expert) * N + static_cast(n_global)) * blks; - - constexpr int SUB = 16; - using QVec = sycl::vec; - - float accf = 0.0f; - for (int ib = 0; ib < blks; ++ib) { - const int k_base = ib * blocksize; - int acc0 = 0; - int acc1 = 0; - int kk = 0; - const int end = (blocksize / SUB) * SUB; - for (; kk < end; kk += SUB) { - const QVec av = *reinterpret_cast(act_row + k_base + kk); - const QVec wv = *reinterpret_cast(w_row + k_base + kk); -#pragma unroll - for (int u = 0; u < SUB; u += 2) { - acc0 += static_cast(av[u]) * static_cast(wv[u]); - acc1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); - } - } - for (; kk < blocksize; ++kk) { - acc0 += static_cast(act_row[k_base + kk]) * static_cast(w_row[k_base + kk]); - } - accf += static_cast(acc0 + acc1) * s_row[ib]; - } - - outputs[static_cast(token) * N + n_global] = static_cast(accf * ascale[token]); - }); -} - -// --------------------------------------------------------------------------- -// Decode GEMV, K-split lane mapping (default) -- one sub-group per output -// element, lanes splitting K, plus N-blocking over `NCOLS` columns. -// -// `launch_w4a8_decode` above maps one *work-item* to one output element, so a -// lane walks a whole `[n_global, K]` int8 weight row on its own. That is the -// same mapping the FP8 decode GEMV started from, and it costs the same two -// things on a kernel that does exactly one multiply-add per weight byte: -// -// 1. Weight loads are not coalesced. Lanes `l` and `l+1` read bytes `K` -// apart, so each 16-byte load turns into 16 scattered cache-line -// requests. No DRAM byte is wasted (each lane consumes its lines as it -// walks the row), but the memory controller sees 16 independent streams -// per sub-group -- the pattern DRAM row buffers handle worst. -// 2. The grid is small: `total_tokens * N / 16` sub-groups, i.e. 768 SIMD16 -// threads for a Qwen3-MoE batch-1 step (8 routed rows, N = 1536). That is -// below the thread slots of a BMG-class GPU (1280 on a B60), so there are -// never enough loads in flight to cover DRAM latency. -// -// This kernel transposes the mapping exactly like `launch_fp8_ksplit`: a whole -// sub-group cooperates on one output element and lane `l` owns the `CH` -// consecutive K elements at `l * CH` inside each `SG_SIZE * CH`-wide K tile. -// One instruction then covers `SG_SIZE * CH` *contiguous* weight bytes (256 B = -// four full cache lines at the default `CH = 16`, 512 B at `CH = 32`) and the -// same span of int8 activations, every thread walks a single sequential stream, -// and the sub-group count grows by `SG_SIZE` (12288 for that batch-1 step). The -// price is one `reduce_over_group` per output element -- a handful of shuffles -// against `K` multiply-adds. -// -// On top of that the sub-group blocks N: it owns `NCOLS` consecutive columns -// and reuses one activation load for all of them, which cuts activation -// messages per weight chunk to `1 / NCOLS` and multiplies the number of -// independent weight loads in flight by `NCOLS` (see -// `moe_w4a8_decode_ksplit_ncols`). -// -// Numerics are equivalent, not bit-identical: the int32 partial sums are still -// folded to float once per AUTO_S8 block with that block's scale, but they are -// split across the 16 lanes and summed at the end. Integer addition is exact -// and associative, so the *integer* partition is lossless; only the float -// accumulation is reordered (per lane, then across lanes, instead of one lane -// folding every block in sequence), which can differ from the legacy result by -// a rounding step. A lane's chunk is `CH` consecutive K elements starting at a -// multiple of `CH`, and the shape gate requires the block to be a multiple of -// `CH`, so a chunk never straddles two blocks. -// --------------------------------------------------------------------------- - -// K elements a lane owns per step: `KSPLIT_CH_DEFAULT` is one 16-byte int8 -// weight load and one 16-byte int8 activation load, the same transactions the -// legacy GEMV issues. `ARK_MOE_W4A8_DECODE_KSPLIT_CH=32` doubles them to -// 32-byte loads, which halves the number of memory messages per byte and -// doubles the bytes a thread keeps in flight; it costs GRF (2 x NCOLS chunks -// live at once) and needs `blocksize >= SG_SIZE * CH`. -// -// Measured (`test_perf_decode_config_sweep`, BMG, 8 routed rows, bf16 act), at -// the default NCOLS: 284.0 -> 278.9 GB/s (qwen3 up), 280.1 -> 244.4 (qwen3 -// down), 268.1 -> 259.9 (minimax up), 315.5 -> 308.7 (minimax down). The wider -// chunk never wins at any NCOLS and costs up to 13%, so 16 stays the default -// and 32 stays an opt-in sweep point. -constexpr int KSPLIT_CH_DEFAULT = 16; -constexpr int KSPLIT_CH_MAX = 32; -// Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group -// covers `KSPLIT_WG_SGS * NCOLS` consecutive columns. -constexpr int KSPLIT_WG_SGS = N_TILE; -// `NCOLS = 2` is the measured default: it is the fastest configuration on two -// of the four swept shapes and within 2% of the best (`4` on qwen3 down, `1` on -// minimax up) on the other two, while `1` costs 47% on qwen3 up and `4` costs -// 14% on minimax up. -constexpr int KSPLIT_NCOLS_DEFAULT = 2; -constexpr int KSPLIT_NCOLS_MAX = 4; - -// A lane's `CH`-byte chunk, as a register type. -// -// `sycl::vec` only exists for 1, 2, 3, 4, 8 and 16 elements, so a `CH = 32` -// chunk cannot be spelled `sycl::vec`: that instantiation is a hard -// static_assert in the SYCL headers ("Invalid number of elements for -// sycl::vec"). A chunk is therefore an aggregate of `CH / 16` 16-byte -// sub-vectors covering *consecutive* bytes. The lane still reads one contiguous -// `CH`-byte span at a `CH`-aligned address (`moe_w4a8_decode_ksplit_shape_ok` -// makes every chunk offset a multiple of `CH` off a row base that is a multiple -// of `K`, itself a multiple of `CH`), the sub-vectors are adjacent both in -// memory and in the GRF, and the declared alignment lets IGC fold the pair back -// into a single wider message. At the default `CH = 16` the aggregate holds a -// single `sycl::vec`, i.e. exactly the load this kernel issued -// before. -// -// `operator[]` is only ever called from the fully unrolled inner loops, so the -// sub-vector selection folds away at compile time and no dynamic indexing -// (which would push the chunk out to scratch) is generated. -template -struct alignas(CH) QChunk { - static constexpr int kSub = KSPLIT_CH_DEFAULT; - static_assert(CH % kSub == 0, "chunk width must be a whole number of 16-byte sub-vectors"); - sycl::vec v[CH / kSub]; - - int8_t operator[](int i) const { return v[i / kSub][i % kSub]; } -}; - -// `ARK_MOE_W4A8_DECODE_KSPLIT` (default ON). Setting it to "0" / "false" / -// "off" / "no" forces the legacy per-lane-strided GEMV, for A/B comparison and -// as a regression escape. Re-read on every call so benchmarks can toggle the -// path in-process. -inline bool moe_w4a8_decode_ksplit_enabled() { - return moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_DECODE_KSPLIT", true); -} - -// Per-lane chunk width in K elements (= bytes). 16 or 32; anything else falls -// back to the default. -inline int moe_w4a8_decode_ksplit_chunk() { - const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_CH"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && (v == 16 || v == 32)) return static_cast(v); - } - return KSPLIT_CH_DEFAULT; -} - -// Shape gate. `blocksize >= SG_SIZE * ch` keeps every lane of the sub-group -// busy: below that some lanes own no chunk in a block and only pay the -// reduction, which is the one regime where splitting K cannot pay for itself. -// `blocksize % ch == 0` combined with `K % blocksize == 0` also makes every -// chunk offset a multiple of `ch` off a row base that is a multiple of `K`, so -// the vector loads stay naturally aligned. The resolved AUTO_S8 block is always -// a multiple of 64 that divides K, so the conditions hold for every shipped -// configuration and only very fine re-scale blocks fall back to the legacy -// GEMV. -inline bool moe_w4a8_decode_ksplit_shape_ok(int N, int K, int blocksize, int ch = KSPLIT_CH_DEFAULT) { - if (N % N_TILE != 0) return false; - if (blocksize < SG_SIZE * ch) return false; - if (blocksize % ch != 0) return false; - if (K % blocksize != 0) return false; - return true; -} - -// N-blocking factor. A work-group covers `KSPLIT_WG_SGS * ncols` columns, so -// `ncols` shrinks until it tiles N. `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` -// overrides the default (1, 2 or 4); `NCOLS == 1` reproduces the plain K-split -// mapping instruction for instruction. -inline int moe_w4a8_decode_ksplit_ncols(int N) { - int ncols = KSPLIT_NCOLS_DEFAULT; - const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && v >= 1 && v <= KSPLIT_NCOLS_MAX && (v & (v - 1)) == 0) { - ncols = static_cast(v); - } - } - while (ncols > 1 && (N % (KSPLIT_WG_SGS * ncols)) != 0) ncols /= 2; - return ncols; -} - -template -void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* expert_id_per_token, - int total_tokens, int N, int K, int blocksize, int blks) { - static_assert(NCOLS >= 1 && (NCOLS & (NCOLS - 1)) == 0, "NCOLS must be a power of two"); - static_assert(CH == 16 || CH == KSPLIT_CH_MAX, "CH must be 16 or 32"); - // K elements a sub-group covers per step -- the contiguous span its 16 lanes - // read in one instruction. - constexpr int STEP = SG_SIZE * CH; - if (!moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, CH) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { - throw std::invalid_argument("moe_gemm_w4a8(decode): K-split GEMV called on an unsupported shape"); - } - if (total_tokens == 0) return; - - // One sub-group per (token, NCOLS columns); `KSPLIT_WG_SGS` of them per - // work-group. - sycl::range<2> global{static_cast(total_tokens), static_cast(N / NCOLS) * SG_SIZE}; - sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const auto sg = it.get_sub_group(); - const int token = static_cast(it.get_global_id(0)); - const int local_id = static_cast(it.get_local_id(1)); - // The work-group is one row of `KSPLIT_WG_SGS * SG_SIZE` work-items, so - // sub-group index and lane index are the halves of the local id. - const int lane = local_id % SG_SIZE; - const int n_base = (static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE) * NCOLS; - - const int expert = expert_id_per_token[token]; - const int8_t* act_row = qact + static_cast(token) * K; - const size_t row0 = static_cast(expert) * N + static_cast(n_base); - const int8_t* w_rows[NCOLS]; - const float* s_rows[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - w_rows[c] = weights + (row0 + static_cast(c)) * K; - s_rows[c] = wscale + (row0 + static_cast(c)) * blks; - } - - using QVec = QChunk; - - float acc[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) acc[c] = 0.0f; - - for (int ib = 0; ib < blks; ++ib) { - const int block_begin = ib * blocksize; - const int block_end = block_begin + blocksize; - int32_t iacc[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) iacc[c] = 0; - - int k0 = block_begin + lane * CH; - // Two chunks per iteration: their loads are independent, so the pair - // doubles the weight requests a thread keeps in flight. All - // `2 * NCOLS` weight loads are issued before the first is consumed. - for (; k0 + STEP + CH <= block_end; k0 += 2 * STEP) { - const QVec av0 = *reinterpret_cast(act_row + k0); - const QVec av1 = *reinterpret_cast(act_row + k0 + STEP); - QVec wv0[NCOLS], wv1[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - wv0[c] = *reinterpret_cast(w_rows[c] + k0); - wv1[c] = *reinterpret_cast(w_rows[c] + k0 + STEP); - } -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - int p0 = 0; - int p1 = 0; -#pragma unroll - for (int u = 0; u < CH; u += 2) { - p0 += static_cast(av0[u]) * static_cast(wv0[c][u]); - p1 += static_cast(av0[u + 1]) * static_cast(wv0[c][u + 1]); - p0 += static_cast(av1[u]) * static_cast(wv1[c][u]); - p1 += static_cast(av1[u + 1]) * static_cast(wv1[c][u + 1]); - } - iacc[c] += p0 + p1; - } - } - // Tail: the chunk of a lane whose partner a full step away falls - // outside the block. At most one chunk per lane. - for (; k0 < block_end; k0 += STEP) { - const QVec av = *reinterpret_cast(act_row + k0); -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - const QVec wv = *reinterpret_cast(w_rows[c] + k0); - int p0 = 0; - int p1 = 0; -#pragma unroll - for (int u = 0; u < CH; u += 2) { - p0 += static_cast(av[u]) * static_cast(wv[u]); - p1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); - } - iacc[c] += p0 + p1; - } - } - -#pragma unroll - for (int c = 0; c < NCOLS; ++c) acc[c] += static_cast(iacc[c]) * s_rows[c][ib]; - } - - const float sa = ascale[token]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - const float total = sycl::reduce_over_group(sg, acc[c], sycl::plus{}); - if (lane == 0) { - outputs[static_cast(token) * N + n_base + c] = static_cast(total * sa); - } - } - }); -} - -// Runtime (NCOLS, CH) -> compile-time bridge, plus the K-split / legacy choice. -// `CH = 32` needs a block of at least 512 elements, so it silently falls back to -// 16 on shapes it cannot serve rather than dropping to the legacy GEMV. -template -void launch_w4a8_decode_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* expert_id_per_token, - int total_tokens, int N, int K, int blocksize, int blks) { - if (moe_w4a8_decode_ksplit_enabled() && moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize)) { - const int ncols = moe_w4a8_decode_ksplit_ncols(N); - const int ch = moe_w4a8_decode_ksplit_chunk() == KSPLIT_CH_MAX && - moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, KSPLIT_CH_MAX) - ? KSPLIT_CH_MAX - : KSPLIT_CH_DEFAULT; - -#define ARK_MOE_W4A8_KSPLIT(ncols_v, ch_v) \ - launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, \ - expert_id_per_token, total_tokens, N, K, blocksize, blks); \ - return; - - if (ch == KSPLIT_CH_MAX) { - switch (ncols) { - case 4: - ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_MAX) - case 2: - ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_MAX) - default: - ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_MAX) - } - } - switch (ncols) { - case 4: - ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_DEFAULT) - case 2: - ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_DEFAULT) - default: - ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_DEFAULT) - } -#undef ARK_MOE_W4A8_KSPLIT - } - launch_w4a8_decode(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, total_tokens, N, K, - blocksize, blks); -} - -// --------------------------------------------------------------------------- -// Host-side helpers -// --------------------------------------------------------------------------- - -// Resolve the effective AUTO_S8 re-scale block size. -// -// `requested <= 0` (the `group=-1` spelling) or any value that is not a valid -// block size falls back to `K`, i.e. one scale per output channel -- the -// maximum-efficiency shape. `ARK_MOE_W4A8_AUTO_S8` overrides the argument so -// benchmarks can sweep the block size without touching the caller. -inline int moe_w4a8_rescale_block_size(int K, int group_size, int requested) { - int v = requested; - const char* env = std::getenv("ARK_MOE_W4A8_AUTO_S8"); - if (env != nullptr) { - char* end = nullptr; - const long parsed = std::strtol(env, &end, 10); - if (end != env) v = static_cast(parsed); - } - if (K <= 0) return K; - if (v <= 0 || v >= K) return K; - if (group_size > 0 && (v < group_size || v % group_size != 0)) return K; - if (K % v != 0) return K; - // The mainloop slices each block into 64-wide DPAS K tiles. - if (v % 64 != 0) return K; - return v; -} - -// Shape preconditions shared by the prepack, prefill and decode paths. -inline bool moe_w4a8_shape_ok(int N, int K, int group_size) { - if (N <= 0 || K <= 0 || group_size <= 0) return false; - if (N % N_TILE != 0) return false; - if (K % 64 != 0) return false; - if (group_size % kPrepackOctet != 0) return false; - if (K % group_size != 0) return false; - return true; -} + int32_t* atomic_buffer = moe_dpas_fp8::get_atomic_scratch_buffer(p.q); -// Token count at or below which the auto phase selection picks the decode -// GEMV. Mirrors `ARK_MOE_AUTO_DECODE_MAX_TOKENS` used by the Python `moe()` -// dispatcher; overridable with `ARK_MOE_W4A8_DECODE_MAX_TOKENS`. -inline int moe_w4a8_decode_max_tokens() { - const char* env = std::getenv("ARK_MOE_W4A8_DECODE_MAX_TOKENS"); - if (env == nullptr) return 128; - char* end = nullptr; - const long parsed = std::strtol(env, &end, 10); - if (end == env || parsed < 0) return 128; - return static_cast(parsed); -} - -inline void moe_w4a8_release_scratch() { - auto& st = w4a8_scratch_state(); - - // Detach everything under the lock, then drop the lock before the device sync - // and the frees: `wait()` blocks for an unbounded time and must not be held - // across. Because the slabs are already out of the pool's tables, an acquire - // that races in behind us allocates fresh ones instead of handing back a - // pointer we are about to free. - std::vector> pending; - { - std::lock_guard lock(st.mu); - auto* pool = DeviceMemoryPool::Instance(); - for (auto& kv : st.queues) { - sycl::queue q = kv.second; - for (size_t loc : {kW4A8QactScratchLoc, kW4A8ExpertMapScratchLoc}) { - void* ptr = pool->detach_scratch_mem(loc, &q); - if (ptr != nullptr) pending.emplace_back(q, ptr); - } - } - st.queues.clear(); - } - - for (auto& item : pending) { - item.first.wait(); - sycl::free(item.second, item.first); - } + MoEGEMMLauncher_w4a8(*p.q, p.qact, p.weights, p.ascale, p.wscale, outputs, p.N, p.K, + p.num_tokens_per_expert, p.num_experts, p.blocksize, p.blks, + allow_full_tile, allow_block_2d_store, prefetch_dist, reduce, + atomic_buffer); } } // namespace moe_w4a8 -// The public `ark::` entry points below are thin wrappers emitted by the -// generated `sycl_tla_moe_w4a8.cpp` translation unit (MOE_SOURCE_MODE -// 21); they live in their own namespace here so the header stays free of -// external definitions and only that one TU pays the kernel compile cost. -namespace moe_w4a8_detail { - -// --------------------------------------------------------------------------- -// Public entry point 1 -- one-shot AUTO_S8 prepack. -// -// Converts auto-round's packed int4-sym weights + per-group scales into the -// int8 weights + FP32 block scales the W4A8 kernels consume. Callers are -// expected to run this once per checkpoint and keep the results resident. -// --------------------------------------------------------------------------- -inline void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, - BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, - int rescale_group_size) { - if (num_experts <= 0) return; - if (!moe_w4a8::moe_w4a8_shape_ok(N, K, group_size)) { - throw std::invalid_argument( - "moe_w4a8_prepack: unsupported shape (need N % 16 == 0, K % 64 == 0, " - "group_size % 8 == 0 and K % group_size == 0)"); - } - if (weights_s4 == nullptr || scales == nullptr || weights_s8 == nullptr || wscales == nullptr) { - throw std::invalid_argument("moe_w4a8_prepack: null buffer"); - } - - const int blocksize = moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); - const int blks = K / blocksize; - - if (act_dtype == BTLA_DTYPE::F16) { - using ScalarT = sycl::half; - moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), - static_cast(wscales), num_experts, N, K, group_size, - blocksize, blks); - moe_w4a8::launch_weight_rescale_s4_to_s8( - q, static_cast(weights_s4), static_cast(scales), - static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, - blocksize, blks); - } else if (act_dtype == BTLA_DTYPE::BF16) { - using ScalarT = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), - static_cast(wscales), num_experts, N, K, group_size, - blocksize, blks); - moe_w4a8::launch_weight_rescale_s4_to_s8( - q, static_cast(weights_s4), static_cast(scales), - static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, - blocksize, blks); - } else { - throw std::invalid_argument("moe_w4a8_prepack: act_dtype must be F16 or BF16"); - } -} - -// --------------------------------------------------------------------------- -// Public entry point 2 -- W4A8 MoE GEMM (prefill + decode). -// -// `phase`: 0 = auto (decode when `total_tokens <= -// ARK_MOE_W4A8_DECODE_MAX_TOKENS`), 1 = force decode GEMV, 2 = force prefill -// grouped GEMM. -// -// Two optional call contracts trade interface work for DRAM traffic. Both are -// opt-in and the defaults are unchanged. -// -// Pre-quantized activations (`qact_in` + `ascale_in`) -// --------------------------------------------------- -// By default the call quantizes `[T, K]` itself: it reads the 16-bit -// activations, writes an int8 copy and the GEMM reads that copy back, i.e. -// `4 * T * K` bytes on top of the GEMM's own operands. On the down-projection -// that is 27% of everything the call moves -- and it is redundant, because the -// producer of those activations (the SiLU/gate elementwise kernel) already -// writes `[T, K]` once and could write int8 plus a per-row scale instead: the -// absmax it needs is a reduction over the row it is already holding. When both -// pointers are supplied all three streams disappear, along with a kernel -// launch. `ascale_in` is `[T]` fp32, `scale = absmax / 127`, matching what -// `launch_act_dynamic_quant` writes. -// -// Fused top-k reduction (`row_to_token` + `routing_weights` + `fused_out`) -// ----------------------------------------------------------------------- -// See `MoEFusedReduce`. Prefill only, and the accumulator must be zeroed by -// the caller; `outputs` is then unused and may be null. -// --------------------------------------------------------------------------- -inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, - BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, - int* num_tokens_per_expert, int num_experts, int total_tokens, int phase, - const void* qact_in = nullptr, const float* ascale_in = nullptr, - const int* row_to_token = nullptr, const float* routing_weights = nullptr, - float* fused_out = nullptr, int fused_batch = 0) { - if (total_tokens == 0 || num_experts <= 0) return; - if (N % moe_w4a8::N_TILE != 0) { - throw std::invalid_argument("moe_gemm_w4a8: N must be a multiple of 16"); - } - if (K % 64 != 0) { - throw std::invalid_argument("moe_gemm_w4a8: K must be a multiple of 64"); - } - if (rescale_block_size <= 0 || rescale_block_size > K || K % rescale_block_size != 0 || - rescale_block_size % 64 != 0) { - throw std::invalid_argument( - "moe_gemm_w4a8: rescale_block_size must be a multiple of 64 that divides K " - "(use moe_w4a8_rescale_block_size to resolve it)"); - } - if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { - throw std::invalid_argument("moe_gemm_w4a8: act_dtype must be F16 or BF16"); - } - - const bool prequantized = qact_in != nullptr || ascale_in != nullptr; - if (prequantized && (qact_in == nullptr || ascale_in == nullptr)) { - throw std::invalid_argument("moe_gemm_w4a8: pre-quantized activations need both qact and ascale"); - } - if (!prequantized && activations == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: null activations"); - } - - const bool fused_reduce = row_to_token != nullptr || routing_weights != nullptr || fused_out != nullptr; - if (fused_reduce && (row_to_token == nullptr || routing_weights == nullptr || fused_out == nullptr || - fused_batch <= 0)) { - throw std::invalid_argument( - "moe_gemm_w4a8: the fused top-k reduction needs row_to_token, routing_weights, a zeroed [batch, N] " - "fp32 output and batch > 0"); - } - if (!fused_reduce && outputs == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: null outputs"); - } - - const int blocksize = rescale_block_size; - const int blks = K / blocksize; - - const bool use_decode = - phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); - - if (fused_reduce && use_decode) { - throw std::invalid_argument("moe_gemm_w4a8: the fused top-k reduction is prefill-only"); - } - - const int8_t* qact = static_cast(qact_in); - const float* ascale = ascale_in; - int8_t* qact_scratch = nullptr; - float* ascale_scratch = nullptr; - - if (!prequantized) { - // Quantized activations + per-token scales share one slab: `[T, K]` int8 - // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because - // K is a multiple of 64). - const size_t qact_bytes = static_cast(total_tokens) * static_cast(K); - const size_t scale_offset = (qact_bytes + sizeof(float) - 1) / sizeof(float) * sizeof(float); - const size_t slab_bytes = scale_offset + static_cast(total_tokens) * sizeof(float); - uint8_t* slab = moe_w4a8::acquire_qact_scratch(q, slab_bytes); - qact_scratch = reinterpret_cast(slab); - ascale_scratch = reinterpret_cast(slab + scale_offset); - qact = qact_scratch; - ascale = ascale_scratch; - } - - // Decode consumes `expert_id_per_token`; the activation-quant kernel already - // runs one sub-group per token, so it derives the map as well instead of - // paying for a second launch (`fill_expert_id_per_token`) on a timeline where - // one call is issued per generated token. Prefill passes nullptr and the scan - // is not compiled into the work. With pre-quantized activations that kernel - // does not run at all, so decode falls back to the standalone scan. - int* expert_map = nullptr; - if (use_decode) { - expert_map = moe_w4a8::acquire_expert_map_scratch(q, static_cast(total_tokens) * sizeof(int)); - } - - if (prequantized) { - if (use_decode) { - moe_decode_detail::fill_expert_id_per_token(q, expert_map, num_tokens_per_expert, num_experts, - total_tokens); - } - } else if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, - ascale_scratch, total_tokens, K, expert_map, - num_tokens_per_expert, num_experts); - } else { - using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, ascale_scratch, - total_tokens, K, expert_map, num_tokens_per_expert, num_experts); - } - - const auto* weights = static_cast(weights_s8); - const auto* wscale = static_cast(wscales); - - if (use_decode) { - if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, - static_cast(outputs), expert_map, - total_tokens, N, K, blocksize, blks); - } else { - using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), - expert_map, total_tokens, N, K, blocksize, blks); - } - return; - } - - moe_w4a8::MoEFusedReduce reduce{}; - if (fused_reduce) { - reduce.row_to_token = row_to_token; - reduce.row_weight = routing_weights; - reduce.out = fused_out; - reduce.batch = fused_batch; - } - - if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, - static_cast(outputs), num_tokens_per_expert, - num_experts, N, K, blocksize, blks, total_tokens, reduce); - } else { - using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), - num_tokens_per_expert, num_experts, N, K, blocksize, blks, - total_tokens, reduce); - } -} - -// Resolve the effective AUTO_S8 block size (host helper, also exported to -// Python so callers can size the `wscales` tensor consistently). -inline int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size) { - return moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); -} - -// Free the W4A8 activation-quantization / expert-map scratch slabs. -inline void moe_w4a8_release_scratch() { moe_w4a8::moe_w4a8_release_scratch(); } - -} // namespace moe_w4a8_detail - } // namespace ark #endif // ARK_XPU && ARK_SYCL_TLA diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp new file mode 100644 index 0000000000..9af1f0aba9 --- /dev/null +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp @@ -0,0 +1,664 @@ +// SYCL MoE W4A8 -- cutlass-free front end (declarations + host helpers) +// +// Companion to `sycl_tla_moe_w4a8.hpp`. That header pulls in the whole +// cutlass-sycl / CuTe include set and defines every W4A8 kernel template, so a +// translation unit that includes it pays the full parse *and* instantiates +// whichever kernels it names. Concentrating all of them in one TU is what made +// the generated `sycl_tla_moe_w4a8.cpp` peak at ~4.2 GB of compiler RSS. +// +// This header carries the part the public entry points need and no kernels: +// the scratch pools, the host-side shape/environment helpers, POD parameter +// structs, and declarations of the per-variant `dispatch` entry points. It +// includes `sycl_tla_moe_decode.hpp` only, which is cutlass-free, so the +// dispatcher TU that includes *this* header compiles for almost nothing. +// +// Each declared entry point is defined in its own generated TU (see +// `sycl_tla_generation.cmake`), mirroring how `sycl_tla_moe_prefill_s4_- +// helpers.hpp` fans the S4 prefill tiles out across TUs. The split axes are +// documented next to the declarations below. +// +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef ARK_XPU +#include +#endif + +#if defined(ARK_XPU) && defined(ARK_SYCL_TLA) + +// `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE` and the +// `DeviceMemoryPool` declaration. Cutlass-free, unlike the DPAS headers. +#include "sycl_tla_moe_decode.hpp" + +namespace ark { +namespace moe_w4a8 { + +using moe_decode_detail::N_TILE; +using moe_decode_detail::SG_SIZE; + +// Symmetric int4 full range: 2^(bits-1). Matches `fullrange` in +// `xpu_wrapper.hpp`'s `packscale` rescale kernel. +constexpr float kInt4FullRange = 8.0f; +constexpr float kInt8Max = 127.0f; + +// K elements decoded per work-item in the prepack kernel (one 32-bit word of +// packed nibbles). Requires `K % 8 == 0`, which the shape gate enforces. +constexpr int kPrepackOctet = 8; + +// --------------------------------------------------------------------------- +// Scratch pools. +// +// The activation-quantization buffers (`[total_tokens, K]` int8 + +// `[total_tokens]` fp32) and the decode expert map (`[total_tokens]` int32) +// are recomputed on every call, so they come from persistent slabs instead of +// a hot-path `malloc_device`. +// +// The slabs are served from the extension-wide `DeviceMemoryPool`, which keys +// on the device UUID rather than on a `sycl::queue*`: a slab therefore follows +// the device and is immune to the caller destroying its queue and to a later +// queue landing on the same address. This mirrors how the int4 decode scratch +// is managed in `sycl_tla_moe_decode_scratch.{hpp,cpp}`. +// +// Slabs are intentionally never freed from a static destructor -- the SYCL +// context may already be torn down by then. `moe_w4a8_release_scratch` +// provides the explicit teardown (exposed to Python under the same name). +// +// Sharing one slab per device means these entry points must not be driven +// concurrently from two queues on one device, which matches every other +// `DeviceMemoryPool` slot. +// --------------------------------------------------------------------------- + +// `DeviceMemoryPool` slots owned by the W4A8 path. Slots 0-7 belong to the +// dnnl / xpu / sycl-s8 / cpu wrappers and the SDPA kernels, slot 8 to the DPAS +// work-group counter, and slots 9-10 to the int4 decode scratch. +inline constexpr size_t kW4A8QactScratchLoc = 11; +inline constexpr size_t kW4A8ExpertMapScratchLoc = 12; + +struct W4A8ScratchState { + std::mutex mu; + // Device key (`DeviceMemoryPool::get_device_key`) -> a queue handle on that + // device, held *by value*: a `sycl::queue` is a reference-counted handle, so + // keeping a copy guarantees the queue outlives the memory allocated against + // it. + std::map queues; +}; + +// Intentionally leaked, see above. +inline W4A8ScratchState& w4a8_scratch_state() { + static W4A8ScratchState* s = new W4A8ScratchState(); + return *s; +} + +// Acquire a slab from the shared pool, synchronizing first when the request +// grows it: `DeviceMemoryPool` frees the old pointer in place when it grows a +// slot, and in-flight kernels may still be reading the old slab, so the wait +// has to happen before the call rather than after. +// +// The caller must hold `W4A8ScratchState::mu`. +inline void* acquire_w4a8_slab(sycl::queue* q, size_t bytes, size_t buf_loc) { + auto* pool = DeviceMemoryPool::Instance(); + const size_t held = pool->get_scratch_size(buf_loc, q); + if (held != 0 && held < bytes) { + q->wait(); + } + void* ptr = pool->get_scratch_mem(bytes, buf_loc, q); + if (ptr == nullptr) { + // The pool records the slot before checking the result, so a failed + // allocation leaves a {bytes, nullptr} entry behind that would satisfy + // every later request of this size or smaller without ever retrying. + // Drop it so the next call allocates again. + pool->detach_scratch_mem(buf_loc, q); + throw std::runtime_error("moe_gemm_w4a8: failed to allocate device scratch buffer"); + } + auto& st = w4a8_scratch_state(); + const size_t key = pool->get_device_key(q); + if (st.queues.find(key) == st.queues.end()) { + st.queues.emplace(key, *q); + } + return ptr; +} + +// Quantized activations + per-token scales. +inline uint8_t* acquire_qact_scratch(sycl::queue* q, size_t bytes) { + if (q == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); + } + if (bytes == 0) return nullptr; + auto& st = w4a8_scratch_state(); + std::lock_guard lock(st.mu); + return static_cast(acquire_w4a8_slab(q, bytes, kW4A8QactScratchLoc)); +} + +// Decode expert map (`[total_tokens]` int32). +inline int* acquire_expert_map_scratch(sycl::queue* q, size_t bytes) { + if (q == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); + } + if (bytes == 0) return nullptr; + auto& st = w4a8_scratch_state(); + std::lock_guard lock(st.mu); + return static_cast(acquire_w4a8_slab(q, bytes, kW4A8ExpertMapScratchLoc)); +} + + +// Host-side helpers +// --------------------------------------------------------------------------- + +// Resolve the effective AUTO_S8 re-scale block size. +// +// `requested <= 0` (the `group=-1` spelling) or any value that is not a valid +// block size falls back to `K`, i.e. one scale per output channel -- the +// maximum-efficiency shape. `ARK_MOE_W4A8_AUTO_S8` overrides the argument so +// benchmarks can sweep the block size without touching the caller. +inline int moe_w4a8_rescale_block_size(int K, int group_size, int requested) { + int v = requested; + const char* env = std::getenv("ARK_MOE_W4A8_AUTO_S8"); + if (env != nullptr) { + char* end = nullptr; + const long parsed = std::strtol(env, &end, 10); + if (end != env) v = static_cast(parsed); + } + if (K <= 0) return K; + if (v <= 0 || v >= K) return K; + if (group_size > 0 && (v < group_size || v % group_size != 0)) return K; + if (K % v != 0) return K; + // The mainloop slices each block into 64-wide DPAS K tiles. + if (v % 64 != 0) return K; + return v; +} + +// Shape preconditions shared by the prepack, prefill and decode paths. +inline bool moe_w4a8_shape_ok(int N, int K, int group_size) { + if (N <= 0 || K <= 0 || group_size <= 0) return false; + if (N % N_TILE != 0) return false; + if (K % 64 != 0) return false; + if (group_size % kPrepackOctet != 0) return false; + if (K % group_size != 0) return false; + return true; +} + +// Token count at or below which the auto phase selection picks the decode +// GEMV. Mirrors `ARK_MOE_AUTO_DECODE_MAX_TOKENS` used by the Python `moe()` +// dispatcher; overridable with `ARK_MOE_W4A8_DECODE_MAX_TOKENS`. +inline int moe_w4a8_decode_max_tokens() { + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_MAX_TOKENS"); + if (env == nullptr) return 128; + char* end = nullptr; + const long parsed = std::strtol(env, &end, 10); + if (end == env || parsed < 0) return 128; + return static_cast(parsed); +} + +// Prefill tile selection. +// +// The ladder used to live inside `moe_w4a8_prefill_dispatch`, which +// meant the TU holding it instantiated all six policies. It is pure host +// arithmetic, so it moves here and the caller launches only the tile it picks +// -- that is what lets each policy live in its own translation unit. +// +// Rungs (measured on Arc Pro B60, see the sweep notes in +// `sycl_tla_moe_w4a8.hpp`): tiny M takes the 8-row tile, the 64-row tile +// covers M up to 128, and above that the choice is between the 256-wide and +// the 128-wide N tile depending on whether N splits evenly. +enum class W4A8PrefillTile { M8, M64, M128, M128N256, M256N128, Large }; + +// `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile +// so it can be swept on hardware without a rebuild; anything unrecognised -- +// including the default `auto` -- keeps the ladder. +inline W4A8PrefillTile moe_w4a8_prefill_select_tile(int A_avg_M, int N) { + const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); + if (tile_env != nullptr) { + if (std::strcmp(tile_env, "8x128") == 0) return W4A8PrefillTile::M8; + if (std::strcmp(tile_env, "64x128") == 0) return W4A8PrefillTile::M64; + if (std::strcmp(tile_env, "128x128") == 0) return W4A8PrefillTile::M128; + if (std::strcmp(tile_env, "128x256") == 0) return W4A8PrefillTile::M128N256; + if (std::strcmp(tile_env, "256x128") == 0) return W4A8PrefillTile::M256N128; + if (std::strcmp(tile_env, "256x256") == 0) return W4A8PrefillTile::Large; + } + + if (A_avg_M < 16) return W4A8PrefillTile::M8; + if (A_avg_M < 128) return W4A8PrefillTile::M64; + if ((N % 256) == 0) return W4A8PrefillTile::M128N256; + return W4A8PrefillTile::M128; +} + +inline void moe_w4a8_release_scratch() { + auto& st = w4a8_scratch_state(); + + // Detach everything under the lock, then drop the lock before the device sync + // and the frees: `wait()` blocks for an unbounded time and must not be held + // across. Because the slabs are already out of the pool's tables, an acquire + // that races in behind us allocates fresh ones instead of handing back a + // pointer we are about to free. + std::vector> pending; + { + std::lock_guard lock(st.mu); + auto* pool = DeviceMemoryPool::Instance(); + for (auto& kv : st.queues) { + sycl::queue q = kv.second; + for (size_t loc : {kW4A8QactScratchLoc, kW4A8ExpertMapScratchLoc}) { + void* ptr = pool->detach_scratch_mem(loc, &q); + if (ptr != nullptr) pending.emplace_back(q, ptr); + } + } + st.queues.clear(); + } + + for (auto& item : pending) { + item.first.wait(); + sycl::free(item.second, item.first); + } +} + +} // namespace moe_w4a8 + +// --------------------------------------------------------------------------- +// Per-variant entry points. +// +// Every function below is defined in its own generated translation unit; this +// header only declares them, so including it never instantiates a kernel. The +// parameter structs are plain PODs of scalars and pointers -- deliberately no +// cutlass types -- so the declarations stay usable from a TU that has not seen +// CuTe. +// +// The split follows `sycl_tla_moe_prefill_s4_helpers.hpp`: one TU per +// (dtype x tile) for the DPAS prefill, which is where the compile cost is, and +// coarser grouping for the plain-SYCL kernels. Kernel counts per TU: +// +// prefill_{f16,bf16}_* : 1 DPAS kernel (12 TUs) +// decode_{f16,bf16} : 7 GEMV kernels (2 TUs) +// quant_{f16,bf16} : 11 quant kernels (2 TUs) +// prepack_{f16,bf16} : 2 rescale kernels (2 TUs) +// +// versus 52 kernels -- 12 of them DPAS -- in the single TU this replaced. +// --------------------------------------------------------------------------- +namespace moe_w4a8_detail { + +// One-shot AUTO_S8 prepack: int4 + per-group scales -> int8 + block scales. +struct W4A8PrepackParams { + sycl::queue* q = nullptr; + const void* weights_s4 = nullptr; // [E, N, K/2] packed nibbles + const void* scales = nullptr; // [E, N, K/group_size] act dtype + int8_t* weights_s8 = nullptr; // [E, N, K] out + float* wscales = nullptr; // [E, N, blks] out + int num_experts = 0; + int N = 0; + int K = 0; + int group_size = 0; + int blocksize = 0; + int blks = 0; +}; + +// Per-token activation quantization, optionally emitting the decode expert map +// in the same pass (`expert_map != nullptr`). +struct W4A8QuantParams { + sycl::queue* q = nullptr; + const void* activations = nullptr; // [T, K] act dtype + int8_t* qact = nullptr; // [T, K] out + float* ascale = nullptr; // [T] out + int total_tokens = 0; + int K = 0; + int* expert_map = nullptr; + const int* num_tokens_per_expert = nullptr; + int num_experts = 0; +}; + +// Decode GEMV (K-split or legacy, chosen inside the TU). +struct W4A8DecodeParams { + sycl::queue* q = nullptr; + const int8_t* qact = nullptr; + const float* ascale = nullptr; + const int8_t* weights = nullptr; + const float* wscale = nullptr; + void* outputs = nullptr; // act dtype + const int* expert_id_per_token = nullptr; + int total_tokens = 0; + int N = 0; + int K = 0; + int blocksize = 0; + int blks = 0; +}; + +// Grouped prefill GEMM. The tile is chosen by `moe_w4a8_prefill_select_tile` +// before the call, so each TU below instantiates exactly one policy. +struct W4A8PrefillParams { + sycl::queue* q = nullptr; + const int8_t* qact = nullptr; + const float* ascale = nullptr; + const int8_t* weights = nullptr; + const float* wscale = nullptr; + void* outputs = nullptr; // act dtype + const int* num_tokens_per_expert = nullptr; + int num_experts = 0; + int N = 0; + int K = 0; + int blocksize = 0; + int blks = 0; + int total_tokens = 0; + // Optional fused top-k reduction; `fused_out != nullptr` turns it on. + const int* row_to_token = nullptr; + const float* row_weight = nullptr; + float* fused_out = nullptr; + int fused_batch = 0; +}; + +void prepack_f16(const W4A8PrepackParams& params); +void prepack_bf16(const W4A8PrepackParams& params); + +void quant_f16(const W4A8QuantParams& params); +void quant_bf16(const W4A8QuantParams& params); + +void decode_f16(const W4A8DecodeParams& params); +void decode_bf16(const W4A8DecodeParams& params); + +void prefill_f16_m8(const W4A8PrefillParams& params); +void prefill_f16_m64(const W4A8PrefillParams& params); +void prefill_f16_m128(const W4A8PrefillParams& params); +void prefill_f16_m128n256(const W4A8PrefillParams& params); +void prefill_f16_m256n128(const W4A8PrefillParams& params); +void prefill_f16_large(const W4A8PrefillParams& params); + +void prefill_bf16_m8(const W4A8PrefillParams& params); +void prefill_bf16_m64(const W4A8PrefillParams& params); +void prefill_bf16_m128(const W4A8PrefillParams& params); +void prefill_bf16_m128n256(const W4A8PrefillParams& params); +void prefill_bf16_m256n128(const W4A8PrefillParams& params); +void prefill_bf16_large(const W4A8PrefillParams& params); + +// --------------------------------------------------------------------------- +// Public entry point 1 -- one-shot AUTO_S8 prepack. +// +// Converts auto-round's packed int4-sym weights + per-group scales into the +// int8 weights + FP32 block scales the W4A8 kernels consume. Callers are +// expected to run this once per checkpoint and keep the results resident. +// --------------------------------------------------------------------------- +inline void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, + BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, + int rescale_group_size) { + if (num_experts <= 0) return; + if (!moe_w4a8::moe_w4a8_shape_ok(N, K, group_size)) { + throw std::invalid_argument( + "moe_w4a8_prepack: unsupported shape (need N % 16 == 0, K % 64 == 0, " + "group_size % 8 == 0 and K % group_size == 0)"); + } + if (weights_s4 == nullptr || scales == nullptr || weights_s8 == nullptr || wscales == nullptr) { + throw std::invalid_argument("moe_w4a8_prepack: null buffer"); + } + if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { + throw std::invalid_argument("moe_w4a8_prepack: act_dtype must be F16 or BF16"); + } + + W4A8PrepackParams params; + params.q = q; + params.weights_s4 = weights_s4; + params.scales = scales; + params.weights_s8 = static_cast(weights_s8); + params.wscales = static_cast(wscales); + params.num_experts = num_experts; + params.N = N; + params.K = K; + params.group_size = group_size; + params.blocksize = moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); + params.blks = K / params.blocksize; + + if (act_dtype == BTLA_DTYPE::F16) { + prepack_f16(params); + } else { + prepack_bf16(params); + } +} + +// --------------------------------------------------------------------------- +// Public entry point 2 -- W4A8 MoE GEMM (prefill + decode). +// +// `phase`: 0 = auto (decode when `total_tokens <= +// ARK_MOE_W4A8_DECODE_MAX_TOKENS`), 1 = force decode GEMV, 2 = force prefill +// grouped GEMM. +// +// Two optional call contracts trade interface work for DRAM traffic. Both are +// opt-in and the defaults are unchanged. +// +// Pre-quantized activations (`qact_in` + `ascale_in`) +// --------------------------------------------------- +// By default the call quantizes `[T, K]` itself: it reads the 16-bit +// activations, writes an int8 copy and the GEMM reads that copy back, i.e. +// `4 * T * K` bytes on top of the GEMM's own operands. On the down-projection +// that is 27% of everything the call moves -- and it is redundant, because the +// producer of those activations (the SiLU/gate elementwise kernel) already +// writes `[T, K]` once and could write int8 plus a per-row scale instead: the +// absmax it needs is a reduction over the row it is already holding. When both +// pointers are supplied all three streams disappear, along with a kernel +// launch. `ascale_in` is `[T]` fp32, `scale = absmax / 127`, matching what +// `launch_act_dynamic_quant` writes. +// +// Fused top-k reduction (`row_to_token` + `routing_weights` + `fused_out`) +// ----------------------------------------------------------------------- +// See `MoEFusedReduce`. Prefill only, and the accumulator must be zeroed by +// the caller; `outputs` is then unused and may be null. +// --------------------------------------------------------------------------- +inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, + BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, + int* num_tokens_per_expert, int num_experts, int total_tokens, int phase, + const void* qact_in = nullptr, const float* ascale_in = nullptr, + const int* row_to_token = nullptr, const float* routing_weights = nullptr, + float* fused_out = nullptr, int fused_batch = 0) { + if (total_tokens == 0 || num_experts <= 0) return; + if (N % moe_w4a8::N_TILE != 0) { + throw std::invalid_argument("moe_gemm_w4a8: N must be a multiple of 16"); + } + if (K % 64 != 0) { + throw std::invalid_argument("moe_gemm_w4a8: K must be a multiple of 64"); + } + if (rescale_block_size <= 0 || rescale_block_size > K || K % rescale_block_size != 0 || + rescale_block_size % 64 != 0) { + throw std::invalid_argument( + "moe_gemm_w4a8: rescale_block_size must be a multiple of 64 that divides K " + "(use moe_w4a8_rescale_block_size to resolve it)"); + } + if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { + throw std::invalid_argument("moe_gemm_w4a8: act_dtype must be F16 or BF16"); + } + + const bool prequantized = qact_in != nullptr || ascale_in != nullptr; + if (prequantized && (qact_in == nullptr || ascale_in == nullptr)) { + throw std::invalid_argument("moe_gemm_w4a8: pre-quantized activations need both qact and ascale"); + } + if (!prequantized && activations == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: null activations"); + } + + const bool fused_reduce = row_to_token != nullptr || routing_weights != nullptr || fused_out != nullptr; + if (fused_reduce && (row_to_token == nullptr || routing_weights == nullptr || fused_out == nullptr || + fused_batch <= 0)) { + throw std::invalid_argument( + "moe_gemm_w4a8: the fused top-k reduction needs row_to_token, routing_weights, a zeroed [batch, N] " + "fp32 output and batch > 0"); + } + if (!fused_reduce && outputs == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: null outputs"); + } + + const bool is_f16 = act_dtype == BTLA_DTYPE::F16; + const int blocksize = rescale_block_size; + const int blks = K / blocksize; + + const bool use_decode = + phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); + + if (fused_reduce && use_decode) { + throw std::invalid_argument("moe_gemm_w4a8: the fused top-k reduction is prefill-only"); + } + + const int8_t* qact = static_cast(qact_in); + const float* ascale = ascale_in; + int8_t* qact_scratch = nullptr; + float* ascale_scratch = nullptr; + + if (!prequantized) { + // Quantized activations + per-token scales share one slab: `[T, K]` int8 + // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because + // K is a multiple of 64). + const size_t qact_bytes = static_cast(total_tokens) * static_cast(K); + const size_t scale_offset = (qact_bytes + sizeof(float) - 1) / sizeof(float) * sizeof(float); + const size_t slab_bytes = scale_offset + static_cast(total_tokens) * sizeof(float); + uint8_t* slab = moe_w4a8::acquire_qact_scratch(q, slab_bytes); + qact_scratch = reinterpret_cast(slab); + ascale_scratch = reinterpret_cast(slab + scale_offset); + qact = qact_scratch; + ascale = ascale_scratch; + } + + // Decode consumes `expert_id_per_token`; the activation-quant kernel already + // runs one sub-group per token, so it derives the map as well instead of + // paying for a second launch (`fill_expert_id_per_token`) on a timeline where + // one call is issued per generated token. Prefill passes nullptr and the scan + // is not compiled into the work. With pre-quantized activations that kernel + // does not run at all, so decode falls back to the standalone scan. + int* expert_map = nullptr; + if (use_decode) { + expert_map = moe_w4a8::acquire_expert_map_scratch(q, static_cast(total_tokens) * sizeof(int)); + } + + if (prequantized) { + if (use_decode) { + moe_decode_detail::fill_expert_id_per_token(q, expert_map, num_tokens_per_expert, num_experts, + total_tokens); + } + } else { + W4A8QuantParams qp; + qp.q = q; + qp.activations = activations; + qp.qact = qact_scratch; + qp.ascale = ascale_scratch; + qp.total_tokens = total_tokens; + qp.K = K; + qp.expert_map = expert_map; + qp.num_tokens_per_expert = num_tokens_per_expert; + qp.num_experts = num_experts; + if (is_f16) { + quant_f16(qp); + } else { + quant_bf16(qp); + } + } + + const auto* weights = static_cast(weights_s8); + const auto* wscale = static_cast(wscales); + + if (use_decode) { + W4A8DecodeParams dp; + dp.q = q; + dp.qact = qact; + dp.ascale = ascale; + dp.weights = weights; + dp.wscale = wscale; + dp.outputs = outputs; + dp.expert_id_per_token = expert_map; + dp.total_tokens = total_tokens; + dp.N = N; + dp.K = K; + dp.blocksize = blocksize; + dp.blks = blks; + if (is_f16) { + decode_f16(dp); + } else { + decode_bf16(dp); + } + return; + } + + W4A8PrefillParams pp; + pp.q = q; + pp.qact = qact; + pp.ascale = ascale; + pp.weights = weights; + pp.wscale = wscale; + pp.outputs = outputs; + pp.num_tokens_per_expert = num_tokens_per_expert; + pp.num_experts = num_experts; + pp.N = N; + pp.K = K; + pp.blocksize = blocksize; + pp.blks = blks; + pp.total_tokens = total_tokens; + if (fused_reduce) { + pp.row_to_token = row_to_token; + pp.row_weight = routing_weights; + pp.fused_out = fused_out; + pp.fused_batch = fused_batch; + } + + // One `dispatch` symbol per (dtype, tile); only the selected one is linked + // against a kernel-bearing TU, and none of them is instantiated here. + switch (moe_w4a8::moe_w4a8_prefill_select_tile(total_tokens / num_experts, N)) { + case moe_w4a8::W4A8PrefillTile::M8: + if (is_f16) { + prefill_f16_m8(pp); + } else { + prefill_bf16_m8(pp); + } + break; + case moe_w4a8::W4A8PrefillTile::M64: + if (is_f16) { + prefill_f16_m64(pp); + } else { + prefill_bf16_m64(pp); + } + break; + case moe_w4a8::W4A8PrefillTile::M128N256: + if (is_f16) { + prefill_f16_m128n256(pp); + } else { + prefill_bf16_m128n256(pp); + } + break; + case moe_w4a8::W4A8PrefillTile::M256N128: + if (is_f16) { + prefill_f16_m256n128(pp); + } else { + prefill_bf16_m256n128(pp); + } + break; + case moe_w4a8::W4A8PrefillTile::Large: + if (is_f16) { + prefill_f16_large(pp); + } else { + prefill_bf16_large(pp); + } + break; + case moe_w4a8::W4A8PrefillTile::M128: + default: + if (is_f16) { + prefill_f16_m128(pp); + } else { + prefill_bf16_m128(pp); + } + break; + } +} + +// Resolve the effective AUTO_S8 block size (host helper, also exported to +// Python so callers can size the `wscales` tensor consistently). +inline int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size) { + return moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); +} + +// Free the W4A8 activation-quantization / expert-map scratch slabs. +inline void moe_w4a8_release_scratch() { moe_w4a8::moe_w4a8_release_scratch(); } + +} // namespace moe_w4a8_detail + +} // namespace ark + +#endif // ARK_XPU && ARK_SYCL_TLA diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp new file mode 100644 index 0000000000..fda352b08f --- /dev/null +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp @@ -0,0 +1,1036 @@ +// SYCL MoE W4A8 -- cutlass-free kernels (activation quant, prepack, decode) +// +// Split out of `sycl_tla_moe_w4a8.hpp` so the translation units that only need +// these kernels never parse the cutlass-sycl / CuTe include set. Nothing here +// touches CuTe: the activation-quantization, AUTO_S8 prepack and decode-GEMV +// kernels are plain SYCL, and only the grouped prefill GEMM needs DPAS. +// +// The narrative for each kernel -- the message-width / unroll / single-pass +// choices for the quant pass, the AUTO_S8 re-scale, and the decode K-split +// lane mapping -- stays inline with the code below. See +// `sycl_tla_moe_w4a8.hpp` for the overall W4A8 design. +// +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include + +#ifdef ARK_XPU +#include +#endif + +#if defined(ARK_XPU) && defined(ARK_SYCL_TLA) + +// `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE`, and +// the shared nibble decoders. Cutlass-free. +#include "sycl_tla_moe_decode.hpp" +// Scratch pools, host-side helpers and the parameter structs the launch +// wrappers below are expressed in terms of. +#include "sycl_tla_moe_w4a8_helpers.hpp" + +namespace ark { +namespace moe_w4a8 { + +using moe_dequant::decode_int4_octet; + +// --------------------------------------------------------------------------- +// Kernel name tags (one per specialization, required for SYCL kernel naming) +// --------------------------------------------------------------------------- +template +class MoEW4A8ActQuant; + +template +class MoEW4A8ActQuantVec; + +template +class MoEW4A8ActQuantSingle; + +template +class MoEW4A8ScaleReduce; + +template +class MoEW4A8Repack; + +template +class MoEW4A8DecodeGemv; + +template +class MoEW4A8DecodeKSplit; + +// --------------------------------------------------------------------------- +// Per-token dynamic activation quantization: act dtype -> int8 + fp32 scale. +// +// One sub-group per token: lanes stride the K axis (coalesced), reduce the +// absmax with `reduce_over_group`, then write back the quantized row. A row +// that is entirely zero gets `scale = 0` and quantizes to all zeros (the +// reciprocal is forced to 0 instead of inf). +// +// The decode path also needs `expert_id_per_token`, which +// `moe_decode_detail::fill_expert_id_per_token` produces in a kernel of its +// own. That kernel does one tiny scan per token, so at decode sizes it is pure +// launch overhead on a timeline where the GEMV itself is only tens of +// microseconds and one call is issued per generated token. This kernel already +// runs one sub-group per token, so when `expert_id_per_token != nullptr` lane 0 +// folds the same scan in and the separate launch disappears -- the same "one +// fewer kernel launch on the decode timeline" the FP8 DPAS decode dispatch +// gets by consuming `num_tokens_per_expert` directly. The scan is the verbatim +// body of `fill_expert_id_per_token`, including its clamp to +// `num_experts - 1` for a routing table that sums to less than `total_tokens`. +// +// Message width (the prefill cost that matters) +// --------------------------------------------- +// This kernel is a pure streaming pass -- it reads `[T, K]` activations twice +// (absmax, then quantize) and writes `[T, K]` int8 -- so at prefill sizes it is +// a sizeable fraction of the whole `moe_gemm_w4a8` call, not a preamble. At +// 32768 routed rows and K = 2048 it touches ~200 MB, next to the ~400 MB the +// grouped GEMM streams for the qwen3 up-proj weights. +// +// The scalar mapping below (`k = lane; k < K; k += SG_SIZE`) moves that traffic +// in the *narrowest* messages the sub-group can issue: 16 lanes x one 16-bit +// element is a 32-byte load and 16 lanes x one int8 is a **16-byte** store, i.e. +// a quarter of a cache line per store message. That is the same defect the +// decode GEMV had before the K-split rewrite, and it was worth 1.09-1.93x there. +// +// `launch_act_dynamic_quant_vec` fixes it the same way: each lane owns `VEC` +// *consecutive* elements, so one message covers `SG_SIZE * VEC` contiguous +// elements -- 256 bytes of activations and 128 bytes of int8 at `VEC = 8`. +// Both passes read the same `sycl::vec`, and the second pass re-reads a row the +// first pass just touched, so it is served by the cache rather than DRAM. +// +// `VEC` is chosen from K: 8 when `K % 128 == 0` (every shipped MoE shape -- +// 768 / 1536 / 2048 / 3072), otherwise 4, which the `K % 64 == 0` shape gate +// always satisfies. A misaligned base pointer (never the case for torch +// allocations or the scratch pool) falls back to the scalar kernel, and +// `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. +// +// `test_perf_prefill_act_quant_sweep` on BMG (384 rows/expert, bf16 act) puts +// the widened messages at 1.12x (qwen3 up), 1.10x (qwen3 down), 1.15x (minimax +// up) and 1.07x (minimax down) of the scalar mapping on the *whole* +// `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of +// prefill than that, since the GEMM around it is unchanged. (Earlier runs of +// the same sweep read 1.05 / 1.13 / 1.11 / 1.04, 1.13 / 1.14 / 1.12 / 1.04 and +// 1.14 / 1.07 / 1.10 / 1.07: the ranking is stable, the individual ratios move +// by a few percent between runs.) +// +// `sycl::vec` is used rather than `sycl::vec` +// because `sycl::vec` of `bfloat16` is not universally available; the elements +// are `bit_cast` back one at a time, exactly like the decode kernels' +// `ActVec` loads in `sycl_tla_moe_decode.hpp`. +// +// Requests in flight (the cost widening the messages did not address) +// ------------------------------------------------------------------- +// Wide messages fix how many bytes each *request* moves; they do not change +// how many requests a work-item has outstanding. This kernel walks K with a +// runtime trip count (`steps = K / (SG_SIZE * VEC)`) and folds every vector +// into the same `local_max` accumulator, so the loop reads as: issue one load, +// stall until it returns, `fmax` it, repeat. Xe cores execute in order and +// `fmax` is not reassociated without fast-math, so each thread keeps roughly +// *one* 256-byte load in flight. +// +// That is a Little's-law problem, not a bandwidth one: 1280 concurrent +// sub-groups (the B60's occupancy ceiling -- 160 XVEs x 8 thread slots) x 256 +// bytes is ~320 KB of in-flight reads, under the ~456 KB a 456 GB/s device +// needs to stay busy across a ~1 us memory latency, and a real launch rarely +// fills every slot. The same argument is why the decode GEMV loads two chunks +// per iteration (`launch_w4a8_decode_ksplit`), and why the pair is spelled out +// there rather than left to the compiler. +// +// `UNROLL` gives the pass the same treatment: each iteration loads `UNROLL` +// *independent* vectors before consuming any of them, and reduces them into +// `UNROLL` separate partial maxima so the loads do not serialize behind the +// accumulator chain either. At the default `UNROLL = 4` a thread holds 1 KB, +// which clears the 456 KB well before every slot is occupied. The quantize +// pass batches its loads the same way, and its stores are already independent. +// `steps % UNROLL` vectors are left to a tail loop -- `K = 768` (qwen3 down) +// gives `steps = 6`, so the tail is real code, not a formality. +// +// Nothing that rounds changes: the per-lane partial reduction is still `fmax` +// over the same values (exact and order-independent, so partial maxima merge +// to the same bits), and every element goes through the same `rint`/`clamp` +// expression. `UNROLL = 1` is the previous kernel instruction for instruction, +// so `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` is an exact A/B baseline; +// `test_act_quant_unroll_matches` asserts every depth is bit-identical and +// `test_perf_prefill_act_quant_unroll_sweep` times them. +// +// That sweep keeps the default, though not by much: only minimax up is a real +// A/B in it (the other three shapes take the single-pass kernel below, where +// `UNROLL` is dead code, so their rows are three sets of identical kernels -- +// a useful noise probe, spreading 3.3-7.3% in the latest run and 0.4-3.9% in +// the one before). On that shape the three depths read 6.982 ms at 1, +// 6.795 ms at 2 and 6.837 ms at 4: the batched loads are worth 1.02-1.03x over +// `UNROLL = 1`, and the 0.6% between 2 and 4 is an order of magnitude inside +// the noise the identical-kernel rows show, so the default stays at 4 (the +// earlier run had it 8.959 / 8.967 / 9.139 ms, i.e. 4 fastest). +// +// Reading the row once (the traffic the two passes duplicate) +// ----------------------------------------------------------- +// Batching the loads did not change how many there are. The absmax has to see +// the whole row before the first element can be quantized, so the kernel reads +// `[T, K]`, reduces, then reads `[T, K]` again -- and at 384 rows per expert +// the activation matrix is 1.5 MB for K = 2048, against 3.1 MB of weights for +// the whole GEMM. The re-read is L2-resident when the row is still there, but +// the rows a work-group quantizes second are evicted by the ones it quantized +// first well before the pass ends: at 8 MB of L2 and 4 KB per bf16 row of +// K = 2048, only ~2000 of 2048 tokens' rows fit *if nothing else is resident*, +// and the GEMM's weights are competing for the same cache immediately after. +// +// A row is small enough to keep in registers instead: a lane owns `K / 16` +// elements, so `K = 2048` is 256 bytes -- 64 of the 128 dwords per lane the +// quantizer gets (it launches without `grf_size<256>`, unlike the GEMM). Load +// the row once, reduce it, then quantize out of the registers. The second read +// disappears, and every load is issued before any of them is consumed, which +// subsumes what `UNROLL` was doing (`UNROLL = steps`, effectively) rather than +// competing with it. +// +// `MAX_STEPS` is the compile-time cap that makes the fragment a register array +// rather than scratch: the loop is `#pragma unroll` over `MAX_STEPS` with an +// `if (s < steps)` guard, so every index is a constant and SROA can promote it. +// Two rungs are instantiated -- 8 vectors (K <= 1024 at VEC = 8, 32 dwords) and +// 16 (K <= 2048, 64 dwords) -- and anything longer keeps the two-pass kernel, +// which is why minimax's K = 3072 up-projection still takes the old path. The +// partial maxima stay at four accumulators, as in the two-pass kernel, so the +// reduction chain is unchanged in both cost and value. +// +// This was a register-pressure gamble -- if 64 dwords of row plus addressing +// spilled, the pass would get slower, not faster -- and the sweep settled it in +// its favour: at 384 rows/expert the single-pass kernel is 1.06x (qwen3 down, +// K = 768), 1.04x (qwen3 up, K = 2048, the rung filled exactly) and 1.02x +// (minimax down, K = 1536) against the two-pass one. Nothing spills. minimax +// up (K = 3072) is past the last rung, so both of its rows run the *same* +// two-pass kernel and their 1.01x is this sweep's noise probe. +// `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` still restores the two-pass +// kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times the +// pair, and `test_act_quant_single_pass_matches` asserts they agree bit for bit +// (same `fmax` set, same `inv`, same `rint`/`clamp`). +// --------------------------------------------------------------------------- + +// Fold the per-token expert scan (decode only) into the quantization kernel. +// Verbatim body of `moe_decode_detail::fill_expert_id_per_token`. +inline void act_quant_write_scale(float* ascale, int token, float scale, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + ascale[token] = scale; + if (expert_id_per_token == nullptr) return; + int offset = 0; + int expert = num_experts - 1; + for (int e = 0; e < num_experts; ++e) { + const int n = num_tokens_per_expert[e]; + if (token < offset + n) { + expert = e; + break; + } + offset += n; + } + expert_id_per_token[token] = expert; +} + +template +void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); + static_assert(UNROLL >= 1 && (UNROLL & (UNROLL - 1)) == 0, "UNROLL must be a power of two"); + using ActVec = sycl::vec; + using QVec = sycl::vec; + + // Vectors a lane walks over. `K % (SG_SIZE * VEC) == 0` is checked by the + // caller, so the loop needs no tail -- but `steps` need not be a multiple of + // `UNROLL` (K = 768 gives 6 vectors at VEC = 8), hence the second loop. + const int steps = K / (SG_SIZE * VEC); + const int main_steps = steps - (steps % UNROLL); + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); + QVec* out = reinterpret_cast(qact + static_cast(token) * K); + + // One partial maximum per unrolled slot: `fmax` is exact, so merging + // them below gives the same absmax as a single chain, but the loads no + // longer wait on it. + float part_max[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) part_max[u] = 0.0f; + + for (int s = 0; s < main_steps; s += UNROLL) { + ActVec v[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + v[u] = row[static_cast(s + u) * SG_SIZE + lane]; + } +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); + part_max[u] = sycl::fmax(part_max[u], sycl::fabs(static_cast(a))); + } + } + } + for (int s = main_steps; s < steps; ++s) { + const ActVec v = row[static_cast(s) * SG_SIZE + lane]; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[e])); + part_max[0] = sycl::fmax(part_max[0], sycl::fabs(static_cast(a))); + } + } + + float local_max = part_max[0]; +#pragma unroll + for (int u = 1; u < UNROLL; ++u) local_max = sycl::fmax(local_max, part_max[u]); + + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + // Same batching on the way back: the re-read of a row the first pass + // just touched is served by the cache, but only if enough of it is + // requested at once. + for (int s = 0; s < main_steps; s += UNROLL) { + ActVec v[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + v[u] = row[static_cast(s + u) * SG_SIZE + lane]; + } +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s + u) * SG_SIZE + lane] = qv; + } + } + for (int s = main_steps; s < steps; ++s) { + const ActVec v = row[static_cast(s) * SG_SIZE + lane]; + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s) * SG_SIZE + lane] = qv; + } + }); +} + +// Vectors a lane loads before it consumes any of them. `4` covers every +// shipped shape's `steps` (6 / 12 / 16 / 24 at VEC = 8) with at most a +// two-vector tail. `ARK_MOE_W4A8_ACT_QUANT_UNROLL` selects 1 (the previous +// kernel), 2 or 4; anything else falls back to the default. +constexpr int kActQuantUnrollDefault = 4; + +// Partial maxima the single-pass kernel reduces into, matching the two-pass +// kernel's default `UNROLL` so the two produce the same value bit for bit. +constexpr int kActQuantSinglePartials = 4; + +// Longest row a lane keeps in registers, in vectors. 16 vectors is 64 dwords +// per lane at VEC = 8 (K = 2048), half the default 128-dword budget; longer +// rows take the two-pass kernel rather than risk a spill. +constexpr int kActQuantSingleMaxSteps = 16; + +// Single-pass variant: the row is loaded once into registers, reduced, then +// quantized out of them. `MAX_STEPS` bounds the register array at compile time +// (see the design note above); `steps <= MAX_STEPS` is the caller's contract. +template +void launch_act_dynamic_quant_vec_single(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); + static_assert(MAX_STEPS >= kActQuantSinglePartials, "MAX_STEPS must cover the partial accumulators"); + using ActVec = sycl::vec; + using QVec = sycl::vec; + + const int steps = K / (SG_SIZE * VEC); + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); + QVec* out = reinterpret_cast(qact + static_cast(token) * K); + + // The whole row, one load per slot, every one issued before the first + // is consumed. Constant indices under the unroll keep it in registers. + ActVec v[MAX_STEPS]; +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { + v[s] = row[static_cast(s) * SG_SIZE + lane]; + } + } + + float part_max[kActQuantSinglePartials]; +#pragma unroll + for (int u = 0; u < kActQuantSinglePartials; ++u) part_max[u] = 0.0f; + +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); + part_max[s % kActQuantSinglePartials] = + sycl::fmax(part_max[s % kActQuantSinglePartials], sycl::fabs(static_cast(a))); + } + } + } + + float local_max = part_max[0]; +#pragma unroll + for (int u = 1; u < kActQuantSinglePartials; ++u) local_max = sycl::fmax(local_max, part_max[u]); + + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + // No second read of the row: it is already here. +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s) * SG_SIZE + lane] = qv; + } + } + }); +} + +inline int moe_w4a8_act_quant_unroll() { + const char* env = std::getenv("ARK_MOE_W4A8_ACT_QUANT_UNROLL"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && (v == 1 || v == 2 || v == 4)) return static_cast(v); + } + return kActQuantUnrollDefault; +} + +// How many k-tiles of A and B the prefill mainloop keeps prefetched ahead of +// the tile it is computing. The prologue issues `prefetch_dist` pairs before +// the first DPAS and the loop then issues one pair per tile, so this is the +// depth of the memory pipeline the mainloop runs against -- too shallow and +// the DPAS waits on L2, too deep and the prefetched lines are evicted before +// use (and the prologue itself becomes a serial stall on short K). +// +// 3 is the value the mainloop was written with and the sibling prefill kernels +// use. The shapes here are short in K (12 k-tiles at K = 768), which is exactly +// where the depth is worth re-measuring, so it is a runtime knob rather than a +// constant; `test_perf_prefill_prefetch_sweep` walks it. +inline constexpr int kPrefillPrefetchDefault = 3; + +inline int moe_w4a8_prefill_prefetch_dist() { + const char* env = std::getenv("ARK_MOE_W4A8_PREFILL_PREFETCH"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 1 && v <= 8) return static_cast(v); + } + return kPrefillPrefetchDefault; +} + +// Runtime unroll depth -> compile-time bridge. +template +void launch_act_dynamic_quant_vec_unroll(int unroll, sycl::queue* q, const ScalarT* activations, int8_t* qact, + float* ascale, int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + // Register-resident single pass when the row fits, the two-pass kernel + // otherwise. The smallest rung that covers `steps` is chosen so a short row + // does not reserve registers for slots it never loads. + const int steps = K / (SG_SIZE * VEC); + if (steps <= kActQuantSingleMaxSteps && + moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS", true)) { + if (steps <= kActQuantSingleMaxSteps / 2) { + launch_act_dynamic_quant_vec_single( + q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); + } else { + launch_act_dynamic_quant_vec_single( + q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); + } + return; + } + + if (unroll == 1) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } else if (unroll == 2) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } else { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } +} + +template +void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token = nullptr, + const int* num_tokens_per_expert = nullptr, int num_experts = 0) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + if (total_tokens == 0) return; + + // Widest lane chunk this shape and these buffers support. The alignment + // checks never fire for torch allocations or the scratch pool (both are at + // least 256-byte aligned), but a caller-supplied activation view could be + // offset, and an unaligned `sycl::vec` access would be undefined. + if (moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_VEC", true)) { + const auto act_addr = reinterpret_cast(activations); + const auto q_addr = reinterpret_cast(qact); + const int unroll = moe_w4a8_act_quant_unroll(); + if (K % (SG_SIZE * 8) == 0 && act_addr % (8 * sizeof(ScalarT)) == 0 && q_addr % 8 == 0) { + launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + return; + } + if (K % (SG_SIZE * 4) == 0 && act_addr % (4 * sizeof(ScalarT)) == 0 && q_addr % 4 == 0) { + launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + return; + } + } + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ScalarT* row = activations + static_cast(token) * K; + int8_t* out = qact + static_cast(token) * K; + + float local_max = 0.0f; + for (int k = lane; k < K; k += SG_SIZE) { + local_max = sycl::fmax(local_max, sycl::fabs(static_cast(row[k]))); + } + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + for (int k = lane; k < K; k += SG_SIZE) { + const float v = sycl::rint(static_cast(row[k]) * inv); + out[k] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); + } + }); +} + +// --------------------------------------------------------------------------- +// AUTO_S8 stage 1: per-(expert, output channel, block) re-scale factor. +// +// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * (8 / 127) +// +// Verbatim port of the rescale branch of `packscale` in `xpu_wrapper.hpp` +// (with `fullrange = 8` for int4). An all-zero block yields `sxt = 0`; stage 2 +// turns that into all-zero int8 weights, so the (equally zero) product is +// still exact. +// --------------------------------------------------------------------------- +template +void launch_weight_scale_reduce(sycl::queue* q, const ScalarT* scales, float* wscale_out, int E, int N, int K, + int group_size, int rescale_block, int nblk) { + const int groups_k = K / group_size; + const int groups_per_block = rescale_block / group_size; + + q->parallel_for>( + sycl::range<1>(static_cast(E) * static_cast(N) * static_cast(nblk)), + [=](sycl::id<1> id) { + const size_t idx = id[0]; + const int blk = static_cast(idx % static_cast(nblk)); + const size_t row = idx / static_cast(nblk); // e * N + n + const ScalarT* s_row = + scales + row * static_cast(groups_k) + static_cast(blk) * groups_per_block; + + float absmax = 0.0f; + for (int g = 0; g < groups_per_block; ++g) { + absmax = sycl::fmax(absmax, sycl::fabs(static_cast(s_row[g]))); + } + wscale_out[idx] = absmax * (kInt4FullRange / kInt8Max); + }); +} + +// --------------------------------------------------------------------------- +// AUTO_S8 stage 2: int4 -> int8 re-scale. +// +// w8[k] = round(w4[k] * s[k / group_size] / sxt[k / rescale_block]) +// +// Verbatim port of the `CfgDequantS8Rescale` branch of `unpackq` in +// `xpu_wrapper.hpp`. One work-item decodes one 32-bit word (8 nibbles); the +// shape gate guarantees `group_size % 8 == 0` and `rescale_block % 8 == 0`, so +// all 8 K indices of a word share the same group scale and the same block +// scale and both loads hoist out of the inner loop. +// --------------------------------------------------------------------------- +template +void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, const ScalarT* scales, + const float* wscale, int8_t* w8_out, int E, int N, int K, int group_size, + int rescale_block, int nblk) { + const int groups_k = K / group_size; + const int octets = K / kPrepackOctet; + + q->parallel_for>( + sycl::range<2>(static_cast(E) * static_cast(N), static_cast(octets)), + [=](sycl::id<2> id) { + const size_t row = id[0]; // e * N + n + const int oct = static_cast(id[1]); + const int k_base = oct * kPrepackOctet; + + const uint8_t* w_ptr = weights + row * static_cast(K / 2) + static_cast(oct) * 4; + const uint32_t word = *reinterpret_cast(w_ptr); + int q4[kPrepackOctet]; + decode_int4_octet(word, q4); + + const float s = static_cast(scales[row * static_cast(groups_k) + k_base / group_size]); + const float sx = wscale[row * static_cast(nblk) + k_base / rescale_block]; + const float f = sx > 0.0f ? s / sx : 0.0f; + + int8_t* out = w8_out + row * static_cast(K) + k_base; +#pragma unroll + for (int j = 0; j < kPrepackOctet; ++j) { + const float v = sycl::rint(static_cast(q4[j]) * f); + out[j] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); + } + }); +} + +// Both prepack kernels for one dtype, behind the shared parameter struct so a +// generated TU can define `prepack_f16` / `prepack_bf16` in three lines. +template +void moe_w4a8_prepack_launch(const moe_w4a8_detail::W4A8PrepackParams& p) { + launch_weight_scale_reduce(p.q, static_cast(p.scales), p.wscales, p.num_experts, p.N, + p.K, p.group_size, p.blocksize, p.blks); + launch_weight_rescale_s4_to_s8(p.q, static_cast(p.weights_s4), + static_cast(p.scales), p.wscales, p.weights_s8, + p.num_experts, p.N, p.K, p.group_size, p.blocksize, p.blks); +} + + +// --------------------------------------------------------------------------- +// Decode GEMV: int8 x int8 -> int32, one output column per sub-group lane. +// +// Same work decomposition as `moe_decode_detail::launch_int8` (work-group = +// one sub-group covering 16 consecutive N columns of one token), with the +// per-K-group float dequantization replaced by a per-block int32 dot product. +// Two accumulators hide the multiply-add latency; int32 cannot overflow here +// (|a|,|w| <= 127 gives < 2^14 per product, so K would have to exceed 130k). +// --------------------------------------------------------------------------- +template +void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, int total_tokens, + int N, int K, int blocksize, int blks) { + if (N % N_TILE != 0) { + throw std::invalid_argument("moe_gemm_w4a8(decode): N must be a multiple of 16"); + } + if (total_tokens == 0) return; + + const int n_tiles = N / N_TILE; + sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int n_tile = static_cast(it.get_group(1)); + const int lane = static_cast(it.get_local_id(1)); + const int n_global = n_tile * N_TILE + lane; + + const int expert = expert_id_per_token[token]; + const int8_t* act_row = qact + static_cast(token) * K; + const int8_t* w_row = weights + (static_cast(expert) * N + static_cast(n_global)) * K; + const float* s_row = + wscale + (static_cast(expert) * N + static_cast(n_global)) * blks; + + constexpr int SUB = 16; + using QVec = sycl::vec; + + float accf = 0.0f; + for (int ib = 0; ib < blks; ++ib) { + const int k_base = ib * blocksize; + int acc0 = 0; + int acc1 = 0; + int kk = 0; + const int end = (blocksize / SUB) * SUB; + for (; kk < end; kk += SUB) { + const QVec av = *reinterpret_cast(act_row + k_base + kk); + const QVec wv = *reinterpret_cast(w_row + k_base + kk); +#pragma unroll + for (int u = 0; u < SUB; u += 2) { + acc0 += static_cast(av[u]) * static_cast(wv[u]); + acc1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); + } + } + for (; kk < blocksize; ++kk) { + acc0 += static_cast(act_row[k_base + kk]) * static_cast(w_row[k_base + kk]); + } + accf += static_cast(acc0 + acc1) * s_row[ib]; + } + + outputs[static_cast(token) * N + n_global] = static_cast(accf * ascale[token]); + }); +} + +// --------------------------------------------------------------------------- +// Decode GEMV, K-split lane mapping (default) -- one sub-group per output +// element, lanes splitting K, plus N-blocking over `NCOLS` columns. +// +// `launch_w4a8_decode` above maps one *work-item* to one output element, so a +// lane walks a whole `[n_global, K]` int8 weight row on its own. That is the +// same mapping the FP8 decode GEMV started from, and it costs the same two +// things on a kernel that does exactly one multiply-add per weight byte: +// +// 1. Weight loads are not coalesced. Lanes `l` and `l+1` read bytes `K` +// apart, so each 16-byte load turns into 16 scattered cache-line +// requests. No DRAM byte is wasted (each lane consumes its lines as it +// walks the row), but the memory controller sees 16 independent streams +// per sub-group -- the pattern DRAM row buffers handle worst. +// 2. The grid is small: `total_tokens * N / 16` sub-groups, i.e. 768 SIMD16 +// threads for a Qwen3-MoE batch-1 step (8 routed rows, N = 1536). That is +// below the thread slots of a BMG-class GPU (1280 on a B60), so there are +// never enough loads in flight to cover DRAM latency. +// +// This kernel transposes the mapping exactly like `launch_fp8_ksplit`: a whole +// sub-group cooperates on one output element and lane `l` owns the `CH` +// consecutive K elements at `l * CH` inside each `SG_SIZE * CH`-wide K tile. +// One instruction then covers `SG_SIZE * CH` *contiguous* weight bytes (256 B = +// four full cache lines at the default `CH = 16`, 512 B at `CH = 32`) and the +// same span of int8 activations, every thread walks a single sequential stream, +// and the sub-group count grows by `SG_SIZE` (12288 for that batch-1 step). The +// price is one `reduce_over_group` per output element -- a handful of shuffles +// against `K` multiply-adds. +// +// On top of that the sub-group blocks N: it owns `NCOLS` consecutive columns +// and reuses one activation load for all of them, which cuts activation +// messages per weight chunk to `1 / NCOLS` and multiplies the number of +// independent weight loads in flight by `NCOLS` (see +// `moe_w4a8_decode_ksplit_ncols`). +// +// Numerics are equivalent, not bit-identical: the int32 partial sums are still +// folded to float once per AUTO_S8 block with that block's scale, but they are +// split across the 16 lanes and summed at the end. Integer addition is exact +// and associative, so the *integer* partition is lossless; only the float +// accumulation is reordered (per lane, then across lanes, instead of one lane +// folding every block in sequence), which can differ from the legacy result by +// a rounding step. A lane's chunk is `CH` consecutive K elements starting at a +// multiple of `CH`, and the shape gate requires the block to be a multiple of +// `CH`, so a chunk never straddles two blocks. +// --------------------------------------------------------------------------- + +// K elements a lane owns per step: `KSPLIT_CH_DEFAULT` is one 16-byte int8 +// weight load and one 16-byte int8 activation load, the same transactions the +// legacy GEMV issues. `ARK_MOE_W4A8_DECODE_KSPLIT_CH=32` doubles them to +// 32-byte loads, which halves the number of memory messages per byte and +// doubles the bytes a thread keeps in flight; it costs GRF (2 x NCOLS chunks +// live at once) and needs `blocksize >= SG_SIZE * CH`. +// +// Measured (`test_perf_decode_config_sweep`, BMG, 8 routed rows, bf16 act), at +// the default NCOLS: 284.0 -> 278.9 GB/s (qwen3 up), 280.1 -> 244.4 (qwen3 +// down), 268.1 -> 259.9 (minimax up), 315.5 -> 308.7 (minimax down). The wider +// chunk never wins at any NCOLS and costs up to 13%, so 16 stays the default +// and 32 stays an opt-in sweep point. +constexpr int KSPLIT_CH_DEFAULT = 16; +constexpr int KSPLIT_CH_MAX = 32; +// Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group +// covers `KSPLIT_WG_SGS * NCOLS` consecutive columns. +constexpr int KSPLIT_WG_SGS = N_TILE; +// `NCOLS = 2` is the measured default: it is the fastest configuration on two +// of the four swept shapes and within 2% of the best (`4` on qwen3 down, `1` on +// minimax up) on the other two, while `1` costs 47% on qwen3 up and `4` costs +// 14% on minimax up. +constexpr int KSPLIT_NCOLS_DEFAULT = 2; +constexpr int KSPLIT_NCOLS_MAX = 4; + +// A lane's `CH`-byte chunk, as a register type. +// +// `sycl::vec` only exists for 1, 2, 3, 4, 8 and 16 elements, so a `CH = 32` +// chunk cannot be spelled `sycl::vec`: that instantiation is a hard +// static_assert in the SYCL headers ("Invalid number of elements for +// sycl::vec"). A chunk is therefore an aggregate of `CH / 16` 16-byte +// sub-vectors covering *consecutive* bytes. The lane still reads one contiguous +// `CH`-byte span at a `CH`-aligned address (`moe_w4a8_decode_ksplit_shape_ok` +// makes every chunk offset a multiple of `CH` off a row base that is a multiple +// of `K`, itself a multiple of `CH`), the sub-vectors are adjacent both in +// memory and in the GRF, and the declared alignment lets IGC fold the pair back +// into a single wider message. At the default `CH = 16` the aggregate holds a +// single `sycl::vec`, i.e. exactly the load this kernel issued +// before. +// +// `operator[]` is only ever called from the fully unrolled inner loops, so the +// sub-vector selection folds away at compile time and no dynamic indexing +// (which would push the chunk out to scratch) is generated. +template +struct alignas(CH) QChunk { + static constexpr int kSub = KSPLIT_CH_DEFAULT; + static_assert(CH % kSub == 0, "chunk width must be a whole number of 16-byte sub-vectors"); + sycl::vec v[CH / kSub]; + + int8_t operator[](int i) const { return v[i / kSub][i % kSub]; } +}; + +// `ARK_MOE_W4A8_DECODE_KSPLIT` (default ON). Setting it to "0" / "false" / +// "off" / "no" forces the legacy per-lane-strided GEMV, for A/B comparison and +// as a regression escape. Re-read on every call so benchmarks can toggle the +// path in-process. +inline bool moe_w4a8_decode_ksplit_enabled() { + return moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_DECODE_KSPLIT", true); +} + +// Per-lane chunk width in K elements (= bytes). 16 or 32; anything else falls +// back to the default. +inline int moe_w4a8_decode_ksplit_chunk() { + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_CH"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && (v == 16 || v == 32)) return static_cast(v); + } + return KSPLIT_CH_DEFAULT; +} + +// Shape gate. `blocksize >= SG_SIZE * ch` keeps every lane of the sub-group +// busy: below that some lanes own no chunk in a block and only pay the +// reduction, which is the one regime where splitting K cannot pay for itself. +// `blocksize % ch == 0` combined with `K % blocksize == 0` also makes every +// chunk offset a multiple of `ch` off a row base that is a multiple of `K`, so +// the vector loads stay naturally aligned. The resolved AUTO_S8 block is always +// a multiple of 64 that divides K, so the conditions hold for every shipped +// configuration and only very fine re-scale blocks fall back to the legacy +// GEMV. +inline bool moe_w4a8_decode_ksplit_shape_ok(int N, int K, int blocksize, int ch = KSPLIT_CH_DEFAULT) { + if (N % N_TILE != 0) return false; + if (blocksize < SG_SIZE * ch) return false; + if (blocksize % ch != 0) return false; + if (K % blocksize != 0) return false; + return true; +} + +// N-blocking factor. A work-group covers `KSPLIT_WG_SGS * ncols` columns, so +// `ncols` shrinks until it tiles N. `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` +// overrides the default (1, 2 or 4); `NCOLS == 1` reproduces the plain K-split +// mapping instruction for instruction. +inline int moe_w4a8_decode_ksplit_ncols(int N) { + int ncols = KSPLIT_NCOLS_DEFAULT; + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 1 && v <= KSPLIT_NCOLS_MAX && (v & (v - 1)) == 0) { + ncols = static_cast(v); + } + } + while (ncols > 1 && (N % (KSPLIT_WG_SGS * ncols)) != 0) ncols /= 2; + return ncols; +} + +template +void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int blocksize, int blks) { + static_assert(NCOLS >= 1 && (NCOLS & (NCOLS - 1)) == 0, "NCOLS must be a power of two"); + static_assert(CH == 16 || CH == KSPLIT_CH_MAX, "CH must be 16 or 32"); + // K elements a sub-group covers per step -- the contiguous span its 16 lanes + // read in one instruction. + constexpr int STEP = SG_SIZE * CH; + if (!moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, CH) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { + throw std::invalid_argument("moe_gemm_w4a8(decode): K-split GEMV called on an unsupported shape"); + } + if (total_tokens == 0) return; + + // One sub-group per (token, NCOLS columns); `KSPLIT_WG_SGS` of them per + // work-group. + sycl::range<2> global{static_cast(total_tokens), static_cast(N / NCOLS) * SG_SIZE}; + sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const auto sg = it.get_sub_group(); + const int token = static_cast(it.get_global_id(0)); + const int local_id = static_cast(it.get_local_id(1)); + // The work-group is one row of `KSPLIT_WG_SGS * SG_SIZE` work-items, so + // sub-group index and lane index are the halves of the local id. + const int lane = local_id % SG_SIZE; + const int n_base = (static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE) * NCOLS; + + const int expert = expert_id_per_token[token]; + const int8_t* act_row = qact + static_cast(token) * K; + const size_t row0 = static_cast(expert) * N + static_cast(n_base); + const int8_t* w_rows[NCOLS]; + const float* s_rows[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + w_rows[c] = weights + (row0 + static_cast(c)) * K; + s_rows[c] = wscale + (row0 + static_cast(c)) * blks; + } + + using QVec = QChunk; + + float acc[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) acc[c] = 0.0f; + + for (int ib = 0; ib < blks; ++ib) { + const int block_begin = ib * blocksize; + const int block_end = block_begin + blocksize; + int32_t iacc[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) iacc[c] = 0; + + int k0 = block_begin + lane * CH; + // Two chunks per iteration: their loads are independent, so the pair + // doubles the weight requests a thread keeps in flight. All + // `2 * NCOLS` weight loads are issued before the first is consumed. + for (; k0 + STEP + CH <= block_end; k0 += 2 * STEP) { + const QVec av0 = *reinterpret_cast(act_row + k0); + const QVec av1 = *reinterpret_cast(act_row + k0 + STEP); + QVec wv0[NCOLS], wv1[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + wv0[c] = *reinterpret_cast(w_rows[c] + k0); + wv1[c] = *reinterpret_cast(w_rows[c] + k0 + STEP); + } +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + int p0 = 0; + int p1 = 0; +#pragma unroll + for (int u = 0; u < CH; u += 2) { + p0 += static_cast(av0[u]) * static_cast(wv0[c][u]); + p1 += static_cast(av0[u + 1]) * static_cast(wv0[c][u + 1]); + p0 += static_cast(av1[u]) * static_cast(wv1[c][u]); + p1 += static_cast(av1[u + 1]) * static_cast(wv1[c][u + 1]); + } + iacc[c] += p0 + p1; + } + } + // Tail: the chunk of a lane whose partner a full step away falls + // outside the block. At most one chunk per lane. + for (; k0 < block_end; k0 += STEP) { + const QVec av = *reinterpret_cast(act_row + k0); +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + const QVec wv = *reinterpret_cast(w_rows[c] + k0); + int p0 = 0; + int p1 = 0; +#pragma unroll + for (int u = 0; u < CH; u += 2) { + p0 += static_cast(av[u]) * static_cast(wv[u]); + p1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); + } + iacc[c] += p0 + p1; + } + } + +#pragma unroll + for (int c = 0; c < NCOLS; ++c) acc[c] += static_cast(iacc[c]) * s_rows[c][ib]; + } + + const float sa = ascale[token]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + const float total = sycl::reduce_over_group(sg, acc[c], sycl::plus{}); + if (lane == 0) { + outputs[static_cast(token) * N + n_base + c] = static_cast(total * sa); + } + } + }); +} + +// Runtime (NCOLS, CH) -> compile-time bridge, plus the K-split / legacy choice. +// `CH = 32` needs a block of at least 512 elements, so it silently falls back to +// 16 on shapes it cannot serve rather than dropping to the legacy GEMV. +template +void launch_w4a8_decode_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int blocksize, int blks) { + if (moe_w4a8_decode_ksplit_enabled() && moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize)) { + const int ncols = moe_w4a8_decode_ksplit_ncols(N); + const int ch = moe_w4a8_decode_ksplit_chunk() == KSPLIT_CH_MAX && + moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, KSPLIT_CH_MAX) + ? KSPLIT_CH_MAX + : KSPLIT_CH_DEFAULT; + +#define ARK_MOE_W4A8_KSPLIT(ncols_v, ch_v) \ + launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, \ + expert_id_per_token, total_tokens, N, K, blocksize, blks); \ + return; + + if (ch == KSPLIT_CH_MAX) { + switch (ncols) { + case 4: + ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_MAX) + case 2: + ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_MAX) + default: + ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_MAX) + } + } + switch (ncols) { + case 4: + ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_DEFAULT) + case 2: + ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_DEFAULT) + default: + ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_DEFAULT) + } +#undef ARK_MOE_W4A8_KSPLIT + } + launch_w4a8_decode(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, total_tokens, N, K, + blocksize, blks); +} + +// Activation quantization for one dtype, behind the shared parameter struct. +template +void moe_w4a8_quant_launch(const moe_w4a8_detail::W4A8QuantParams& p) { + launch_act_dynamic_quant(p.q, static_cast(p.activations), p.qact, p.ascale, + p.total_tokens, p.K, p.expert_map, p.num_tokens_per_expert, p.num_experts); +} + +// Decode GEMV for one dtype, behind the shared parameter struct. The K-split +// vs. legacy choice and the (NCOLS, CH) specialization stay inside the TU. +template +void moe_w4a8_decode_launch(const moe_w4a8_detail::W4A8DecodeParams& p) { + launch_w4a8_decode_dispatch(p.q, p.qact, p.ascale, p.weights, p.wscale, + static_cast(p.outputs), p.expert_id_per_token, p.total_tokens, + p.N, p.K, p.blocksize, p.blks); +} + + +} // namespace moe_w4a8 +} // namespace ark + +#endif // ARK_XPU && ARK_SYCL_TLA From 2ac5d4faf3362b853d7ebf0ff6db3d8f41a16996 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:04:37 +0000 Subject: [PATCH 086/112] docs: describe the W4A8 three-header / 19-TU source layout Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .orig_moe_w4a8.hpp | 2353 +++++++++++++++++ .../ark/test/README_MOE_W4A8.md | 19 + .../ark/test/README_MOE_W4A8_CN.md | 18 + 3 files changed, 2390 insertions(+) create mode 100644 .orig_moe_w4a8.hpp diff --git a/.orig_moe_w4a8.hpp b/.orig_moe_w4a8.hpp new file mode 100644 index 0000000000..3fb951fc58 --- /dev/null +++ b/.orig_moe_w4a8.hpp @@ -0,0 +1,2353 @@ +// SYCL MoE W4A8 -- INT4 weights / INT8 compute (prefill + decode) +// +// STATUS: PARTIALLY HARDWARE-VALIDATED -- every perf sweep in +// `test_moe_w4a8_perf.py` (`test_perf_prefill_tile_sweep`, +// `test_perf_prefill_tile_sweep_long_seq`, `test_perf_prefill_act_quant_- +// sweep`, `..._unroll_sweep`, `..._single_pass_sweep`, +// `test_perf_prefill_store_sweep`, `test_perf_prefill_epilogue_sweep`, +// `test_perf_decode_config_sweep`) and +// every cross-configuration equivalence test (`test_act_quant_vec_matches_- +// scalar`, `test_act_quant_unroll_matches`, `test_act_quant_single_pass_- +// matches`, `test_full_tile_epilogue_matches_predicated`, +// `test_prefill_2d_store_matches_scalar`, `test_decode_ksplit_matches_legacy`) +// has been run on an Intel Arc Pro B60 +// (Battlemage, BMG-G21 -- 20 Xe2 cores / 160 XVEs at ~2.4 GHz, ~197 int8 TOPS, +// 24 GB GDDR6 at 456 GB/s), so both phases compile and run and **every** +// dispatch default -- tile ladder, activation-quant message width / unroll / +// single-pass, interior-tile epilogue, 2D block store, decode CH / NCOLS -- +// comes from those measurements at the compute-bound batch (384 rows/expert), +// with the tile ladder measured at the 8K-prompt routing (512 / 341 +// rows/expert) as well, and each configuration checked numerically against the +// others before it was +// timed. The accuracy gates against the fp32 reference still need a device +// run. The authoring environment has no XPU and no SYCL compiler, so anything +// added *since* that run follows the porting conventions of its siblings +// `sycl_tla_moe_prefill_int_dpas.hpp` / +// `sycl_tla_moe_prefill_fp8_dpas.hpp`; nothing is currently in that state. +// --------------------------------------------------------------------------- +// +// What this file implements +// ------------------------- +// A W4A8 MoE path: **weights are stored as int4** (auto-round's packed +// `[E, N, K/2]` nibble layout with `[E, N, K/group_size]` per-group scales), +// **the DPAS compute dtype is int8**, and **activations are dynamically +// quantized to int8** (per-token absmax) on the fly. +// +// It covers both MoE phases: +// * prefill -- persistent grouped GEMM over experts, `XE_DPAS_TT<8, int32_t, +// int8_t, int8_t>` (`s8 x s8 -> s32`), modelled on the W4A8 weight-only +// GEMM in `sycl_tla_s8_gemm.hpp` (`sycl_tla_igemm_s8s8_dequant`) and the +// grouped scheduler in `sycl_tla_moe_prefill_int_dpas.hpp`. +// * decode -- int8 GEMV. The default mapping splits K across the sub-group +// lanes (coalesced 256-byte weight reads, `NCOLS` output columns per +// sub-group), mirroring `moe_decode_detail::launch_fp8_ksplit`; +// `ARK_MOE_W4A8_DECODE_KSPLIT=0` restores the original one-lane-per-output +// mapping modelled on `moe_decode_detail::launch_int8`. +// +// The AUTO_S8 re-scale trick +// -------------------------- +// ARK's weight-only GEMM has an `AUTO_S8` option (`ARK_AUTO_S8` / +// `env_params::auto_s8`, see `xpu_wrapper.hpp`): rather than feeding the int8 +// mainloop a per-K-group scale (which forces a partial-accumulator fold at +// every group boundary), it *re-scales* the int4 weights into int8 with a +// coarser block size -- typically `group=-1`, i.e. one scale per output +// channel spanning the whole K axis. The int8 GEMM then runs a single +// full-K int32 accumulation with one scalar multiply in the epilogue, which +// is the most efficient shape for DPAS. +// +// The conversion is exactly the one `packscale` + `unpackq(S8, ...)` perform +// in `xpu_wrapper.hpp`: +// +// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * fullrange / 127 +// w8[e][n][k] = round(w4[e][n][k] * s[e][n][k/group_size] / sxt[e][n][j]) +// +// with `fullrange = 2^(bits-1) = 8` for int4. Because `|w4| <= 8` and +// `s <= sxt * 127 / 8` inside the block, `|w8| <= 127`: the re-scaled weight +// always fits in int8 without clipping, and the dequantized value +// `w8 * sxt` reproduces `w4 * s` up to the int8 rounding step. +// +// The block size is `rescale_group_size` (`-1` / `K` == per output channel == +// the `group=-1` maximum-efficiency case). It can be overridden per-process +// with `ARK_MOE_W4A8_AUTO_S8` (`-1` or a multiple of both `group_size` and 64 +// that divides K). Any invalid value falls back to per-channel. +// +// Because the conversion only depends on the checkpoint it is exposed as a +// separate one-shot entry point (`moe_w4a8_prepack`) so callers can run it at +// load time and keep the int8 weights + FP32 block scales resident, instead of +// paying for it on every forward. +// +// Numerics +// -------- +// out[t][n] = (Σ_j sxt[e][n][j] * Σ_{k in block j} qa[t][k] * w8[e][n][k]) +// * sa[t] +// with `qa = round(a / sa)`, `sa = max_k |a[t][k]| / 127`. The activation +// scale is per token (row), the weight scale is per (output channel, block), +// mirroring `sycl_tla_igemm_s8s8_dequant`'s `scale_a[row] * scale_b[col]` +// epilogue. +// +// Layout convention (identical to `moe_gemm_decode` / `moe_gemm_prefill`) +// ---------------------------------------------------------------------- +// activations : [total_tokens, K] act dtype (tokens pre-sorted by expert) +// weights_s4 : [E, N, K/2] uint8, two nibbles per byte (sym) +// scales : [E, N, K/group_size] act dtype +// weights_s8 : [E, N, K] int8 (prepack output) +// wscales : [E, N, K/rescale_block] float (prepack output) +// outputs : [total_tokens, N] act dtype +// +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef ARK_XPU +#include +#endif + +#if defined(ARK_XPU) && defined(ARK_SYCL_TLA) + +// Pulls in the cutlass-sycl / CuTe include set, the `dpas_policy_base` policy +// root, `make_moe_tensor`, and `get_atomic_scratch_buffer` (via the FP8 +// header it includes). +#include "sycl_tla_moe_prefill_int_dpas.hpp" +// `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE`, and +// the shared nibble decoders. +#include "sycl_tla_moe_decode.hpp" + +namespace ark { +namespace moe_w4a8 { + +using namespace cute; + +using moe_decode_detail::N_TILE; +using moe_decode_detail::SG_SIZE; +using moe_dequant::decode_int4_octet; + +// Symmetric int4 full range: 2^(bits-1). Matches `fullrange` in +// `xpu_wrapper.hpp`'s `packscale` rescale kernel. +constexpr float kInt4FullRange = 8.0f; +constexpr float kInt8Max = 127.0f; + +// K elements decoded per work-item in the prepack kernel (one 32-bit word of +// packed nibbles). Requires `K % 8 == 0`, which the shape gate enforces. +constexpr int kPrepackOctet = 8; + +// --------------------------------------------------------------------------- +// Kernel name tags (one per specialization, required for SYCL kernel naming) +// --------------------------------------------------------------------------- +template +class MoEW4A8ActQuant; + +template +class MoEW4A8ActQuantVec; + +template +class MoEW4A8ActQuantSingle; + +template +class MoEW4A8ScaleReduce; + +template +class MoEW4A8Repack; + +template +class MoEW4A8DecodeGemv; + +template +class MoEW4A8DecodeKSplit; + +template +class MoEW4A8GemmName; + +// --------------------------------------------------------------------------- +// Scratch pools. +// +// The activation-quantization buffers (`[total_tokens, K]` int8 + +// `[total_tokens]` fp32) and the decode expert map (`[total_tokens]` int32) +// are recomputed on every call, so they come from persistent slabs instead of +// a hot-path `malloc_device`. +// +// The slabs are served from the extension-wide `DeviceMemoryPool`, which keys +// on the device UUID rather than on a `sycl::queue*`: a slab therefore follows +// the device and is immune to the caller destroying its queue and to a later +// queue landing on the same address. This mirrors how the int4 decode scratch +// is managed in `sycl_tla_moe_decode_scratch.{hpp,cpp}`. +// +// Slabs are intentionally never freed from a static destructor -- the SYCL +// context may already be torn down by then. `moe_w4a8_release_scratch` +// provides the explicit teardown (exposed to Python under the same name). +// +// Sharing one slab per device means these entry points must not be driven +// concurrently from two queues on one device, which matches every other +// `DeviceMemoryPool` slot. +// --------------------------------------------------------------------------- + +// `DeviceMemoryPool` slots owned by the W4A8 path. Slots 0-7 belong to the +// dnnl / xpu / sycl-s8 / cpu wrappers and the SDPA kernels, slot 8 to the DPAS +// work-group counter, and slots 9-10 to the int4 decode scratch. +inline constexpr size_t kW4A8QactScratchLoc = 11; +inline constexpr size_t kW4A8ExpertMapScratchLoc = 12; + +struct W4A8ScratchState { + std::mutex mu; + // Device key (`DeviceMemoryPool::get_device_key`) -> a queue handle on that + // device, held *by value*: a `sycl::queue` is a reference-counted handle, so + // keeping a copy guarantees the queue outlives the memory allocated against + // it. + std::map queues; +}; + +// Intentionally leaked, see above. +inline W4A8ScratchState& w4a8_scratch_state() { + static W4A8ScratchState* s = new W4A8ScratchState(); + return *s; +} + +// Acquire a slab from the shared pool, synchronizing first when the request +// grows it: `DeviceMemoryPool` frees the old pointer in place when it grows a +// slot, and in-flight kernels may still be reading the old slab, so the wait +// has to happen before the call rather than after. +// +// The caller must hold `W4A8ScratchState::mu`. +inline void* acquire_w4a8_slab(sycl::queue* q, size_t bytes, size_t buf_loc) { + auto* pool = DeviceMemoryPool::Instance(); + const size_t held = pool->get_scratch_size(buf_loc, q); + if (held != 0 && held < bytes) { + q->wait(); + } + void* ptr = pool->get_scratch_mem(bytes, buf_loc, q); + if (ptr == nullptr) { + // The pool records the slot before checking the result, so a failed + // allocation leaves a {bytes, nullptr} entry behind that would satisfy + // every later request of this size or smaller without ever retrying. + // Drop it so the next call allocates again. + pool->detach_scratch_mem(buf_loc, q); + throw std::runtime_error("moe_gemm_w4a8: failed to allocate device scratch buffer"); + } + auto& st = w4a8_scratch_state(); + const size_t key = pool->get_device_key(q); + if (st.queues.find(key) == st.queues.end()) { + st.queues.emplace(key, *q); + } + return ptr; +} + +// Quantized activations + per-token scales. +inline uint8_t* acquire_qact_scratch(sycl::queue* q, size_t bytes) { + if (q == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); + } + if (bytes == 0) return nullptr; + auto& st = w4a8_scratch_state(); + std::lock_guard lock(st.mu); + return static_cast(acquire_w4a8_slab(q, bytes, kW4A8QactScratchLoc)); +} + +// Decode expert map (`[total_tokens]` int32). +inline int* acquire_expert_map_scratch(sycl::queue* q, size_t bytes) { + if (q == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); + } + if (bytes == 0) return nullptr; + auto& st = w4a8_scratch_state(); + std::lock_guard lock(st.mu); + return static_cast(acquire_w4a8_slab(q, bytes, kW4A8ExpertMapScratchLoc)); +} + +// --------------------------------------------------------------------------- +// Per-token dynamic activation quantization: act dtype -> int8 + fp32 scale. +// +// One sub-group per token: lanes stride the K axis (coalesced), reduce the +// absmax with `reduce_over_group`, then write back the quantized row. A row +// that is entirely zero gets `scale = 0` and quantizes to all zeros (the +// reciprocal is forced to 0 instead of inf). +// +// The decode path also needs `expert_id_per_token`, which +// `moe_decode_detail::fill_expert_id_per_token` produces in a kernel of its +// own. That kernel does one tiny scan per token, so at decode sizes it is pure +// launch overhead on a timeline where the GEMV itself is only tens of +// microseconds and one call is issued per generated token. This kernel already +// runs one sub-group per token, so when `expert_id_per_token != nullptr` lane 0 +// folds the same scan in and the separate launch disappears -- the same "one +// fewer kernel launch on the decode timeline" the FP8 DPAS decode dispatch +// gets by consuming `num_tokens_per_expert` directly. The scan is the verbatim +// body of `fill_expert_id_per_token`, including its clamp to +// `num_experts - 1` for a routing table that sums to less than `total_tokens`. +// +// Message width (the prefill cost that matters) +// --------------------------------------------- +// This kernel is a pure streaming pass -- it reads `[T, K]` activations twice +// (absmax, then quantize) and writes `[T, K]` int8 -- so at prefill sizes it is +// a sizeable fraction of the whole `moe_gemm_w4a8` call, not a preamble. At +// 32768 routed rows and K = 2048 it touches ~200 MB, next to the ~400 MB the +// grouped GEMM streams for the qwen3 up-proj weights. +// +// The scalar mapping below (`k = lane; k < K; k += SG_SIZE`) moves that traffic +// in the *narrowest* messages the sub-group can issue: 16 lanes x one 16-bit +// element is a 32-byte load and 16 lanes x one int8 is a **16-byte** store, i.e. +// a quarter of a cache line per store message. That is the same defect the +// decode GEMV had before the K-split rewrite, and it was worth 1.09-1.93x there. +// +// `launch_act_dynamic_quant_vec` fixes it the same way: each lane owns `VEC` +// *consecutive* elements, so one message covers `SG_SIZE * VEC` contiguous +// elements -- 256 bytes of activations and 128 bytes of int8 at `VEC = 8`. +// Both passes read the same `sycl::vec`, and the second pass re-reads a row the +// first pass just touched, so it is served by the cache rather than DRAM. +// +// `VEC` is chosen from K: 8 when `K % 128 == 0` (every shipped MoE shape -- +// 768 / 1536 / 2048 / 3072), otherwise 4, which the `K % 64 == 0` shape gate +// always satisfies. A misaligned base pointer (never the case for torch +// allocations or the scratch pool) falls back to the scalar kernel, and +// `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. +// +// `test_perf_prefill_act_quant_sweep` on BMG (384 rows/expert, bf16 act) puts +// the widened messages at 1.12x (qwen3 up), 1.10x (qwen3 down), 1.15x (minimax +// up) and 1.07x (minimax down) of the scalar mapping on the *whole* +// `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of +// prefill than that, since the GEMM around it is unchanged. (Earlier runs of +// the same sweep read 1.05 / 1.13 / 1.11 / 1.04, 1.13 / 1.14 / 1.12 / 1.04 and +// 1.14 / 1.07 / 1.10 / 1.07: the ranking is stable, the individual ratios move +// by a few percent between runs.) +// +// `sycl::vec` is used rather than `sycl::vec` +// because `sycl::vec` of `bfloat16` is not universally available; the elements +// are `bit_cast` back one at a time, exactly like the decode kernels' +// `ActVec` loads in `sycl_tla_moe_decode.hpp`. +// +// Requests in flight (the cost widening the messages did not address) +// ------------------------------------------------------------------- +// Wide messages fix how many bytes each *request* moves; they do not change +// how many requests a work-item has outstanding. This kernel walks K with a +// runtime trip count (`steps = K / (SG_SIZE * VEC)`) and folds every vector +// into the same `local_max` accumulator, so the loop reads as: issue one load, +// stall until it returns, `fmax` it, repeat. Xe cores execute in order and +// `fmax` is not reassociated without fast-math, so each thread keeps roughly +// *one* 256-byte load in flight. +// +// That is a Little's-law problem, not a bandwidth one: 1280 concurrent +// sub-groups (the B60's occupancy ceiling -- 160 XVEs x 8 thread slots) x 256 +// bytes is ~320 KB of in-flight reads, under the ~456 KB a 456 GB/s device +// needs to stay busy across a ~1 us memory latency, and a real launch rarely +// fills every slot. The same argument is why the decode GEMV loads two chunks +// per iteration (`launch_w4a8_decode_ksplit`), and why the pair is spelled out +// there rather than left to the compiler. +// +// `UNROLL` gives the pass the same treatment: each iteration loads `UNROLL` +// *independent* vectors before consuming any of them, and reduces them into +// `UNROLL` separate partial maxima so the loads do not serialize behind the +// accumulator chain either. At the default `UNROLL = 4` a thread holds 1 KB, +// which clears the 456 KB well before every slot is occupied. The quantize +// pass batches its loads the same way, and its stores are already independent. +// `steps % UNROLL` vectors are left to a tail loop -- `K = 768` (qwen3 down) +// gives `steps = 6`, so the tail is real code, not a formality. +// +// Nothing that rounds changes: the per-lane partial reduction is still `fmax` +// over the same values (exact and order-independent, so partial maxima merge +// to the same bits), and every element goes through the same `rint`/`clamp` +// expression. `UNROLL = 1` is the previous kernel instruction for instruction, +// so `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` is an exact A/B baseline; +// `test_act_quant_unroll_matches` asserts every depth is bit-identical and +// `test_perf_prefill_act_quant_unroll_sweep` times them. +// +// That sweep keeps the default, though not by much: only minimax up is a real +// A/B in it (the other three shapes take the single-pass kernel below, where +// `UNROLL` is dead code, so their rows are three sets of identical kernels -- +// a useful noise probe, spreading 3.3-7.3% in the latest run and 0.4-3.9% in +// the one before). On that shape the three depths read 6.982 ms at 1, +// 6.795 ms at 2 and 6.837 ms at 4: the batched loads are worth 1.02-1.03x over +// `UNROLL = 1`, and the 0.6% between 2 and 4 is an order of magnitude inside +// the noise the identical-kernel rows show, so the default stays at 4 (the +// earlier run had it 8.959 / 8.967 / 9.139 ms, i.e. 4 fastest). +// +// Reading the row once (the traffic the two passes duplicate) +// ----------------------------------------------------------- +// Batching the loads did not change how many there are. The absmax has to see +// the whole row before the first element can be quantized, so the kernel reads +// `[T, K]`, reduces, then reads `[T, K]` again -- and at 384 rows per expert +// the activation matrix is 1.5 MB for K = 2048, against 3.1 MB of weights for +// the whole GEMM. The re-read is L2-resident when the row is still there, but +// the rows a work-group quantizes second are evicted by the ones it quantized +// first well before the pass ends: at 8 MB of L2 and 4 KB per bf16 row of +// K = 2048, only ~2000 of 2048 tokens' rows fit *if nothing else is resident*, +// and the GEMM's weights are competing for the same cache immediately after. +// +// A row is small enough to keep in registers instead: a lane owns `K / 16` +// elements, so `K = 2048` is 256 bytes -- 64 of the 128 dwords per lane the +// quantizer gets (it launches without `grf_size<256>`, unlike the GEMM). Load +// the row once, reduce it, then quantize out of the registers. The second read +// disappears, and every load is issued before any of them is consumed, which +// subsumes what `UNROLL` was doing (`UNROLL = steps`, effectively) rather than +// competing with it. +// +// `MAX_STEPS` is the compile-time cap that makes the fragment a register array +// rather than scratch: the loop is `#pragma unroll` over `MAX_STEPS` with an +// `if (s < steps)` guard, so every index is a constant and SROA can promote it. +// Two rungs are instantiated -- 8 vectors (K <= 1024 at VEC = 8, 32 dwords) and +// 16 (K <= 2048, 64 dwords) -- and anything longer keeps the two-pass kernel, +// which is why minimax's K = 3072 up-projection still takes the old path. The +// partial maxima stay at four accumulators, as in the two-pass kernel, so the +// reduction chain is unchanged in both cost and value. +// +// This was a register-pressure gamble -- if 64 dwords of row plus addressing +// spilled, the pass would get slower, not faster -- and the sweep settled it in +// its favour: at 384 rows/expert the single-pass kernel is 1.06x (qwen3 down, +// K = 768), 1.04x (qwen3 up, K = 2048, the rung filled exactly) and 1.02x +// (minimax down, K = 1536) against the two-pass one. Nothing spills. minimax +// up (K = 3072) is past the last rung, so both of its rows run the *same* +// two-pass kernel and their 1.01x is this sweep's noise probe. +// `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` still restores the two-pass +// kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times the +// pair, and `test_act_quant_single_pass_matches` asserts they agree bit for bit +// (same `fmax` set, same `inv`, same `rint`/`clamp`). +// --------------------------------------------------------------------------- + +// Fold the per-token expert scan (decode only) into the quantization kernel. +// Verbatim body of `moe_decode_detail::fill_expert_id_per_token`. +inline void act_quant_write_scale(float* ascale, int token, float scale, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + ascale[token] = scale; + if (expert_id_per_token == nullptr) return; + int offset = 0; + int expert = num_experts - 1; + for (int e = 0; e < num_experts; ++e) { + const int n = num_tokens_per_expert[e]; + if (token < offset + n) { + expert = e; + break; + } + offset += n; + } + expert_id_per_token[token] = expert; +} + +template +void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); + static_assert(UNROLL >= 1 && (UNROLL & (UNROLL - 1)) == 0, "UNROLL must be a power of two"); + using ActVec = sycl::vec; + using QVec = sycl::vec; + + // Vectors a lane walks over. `K % (SG_SIZE * VEC) == 0` is checked by the + // caller, so the loop needs no tail -- but `steps` need not be a multiple of + // `UNROLL` (K = 768 gives 6 vectors at VEC = 8), hence the second loop. + const int steps = K / (SG_SIZE * VEC); + const int main_steps = steps - (steps % UNROLL); + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); + QVec* out = reinterpret_cast(qact + static_cast(token) * K); + + // One partial maximum per unrolled slot: `fmax` is exact, so merging + // them below gives the same absmax as a single chain, but the loads no + // longer wait on it. + float part_max[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) part_max[u] = 0.0f; + + for (int s = 0; s < main_steps; s += UNROLL) { + ActVec v[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + v[u] = row[static_cast(s + u) * SG_SIZE + lane]; + } +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); + part_max[u] = sycl::fmax(part_max[u], sycl::fabs(static_cast(a))); + } + } + } + for (int s = main_steps; s < steps; ++s) { + const ActVec v = row[static_cast(s) * SG_SIZE + lane]; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[e])); + part_max[0] = sycl::fmax(part_max[0], sycl::fabs(static_cast(a))); + } + } + + float local_max = part_max[0]; +#pragma unroll + for (int u = 1; u < UNROLL; ++u) local_max = sycl::fmax(local_max, part_max[u]); + + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + // Same batching on the way back: the re-read of a row the first pass + // just touched is served by the cache, but only if enough of it is + // requested at once. + for (int s = 0; s < main_steps; s += UNROLL) { + ActVec v[UNROLL]; +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + v[u] = row[static_cast(s + u) * SG_SIZE + lane]; + } +#pragma unroll + for (int u = 0; u < UNROLL; ++u) { + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s + u) * SG_SIZE + lane] = qv; + } + } + for (int s = main_steps; s < steps; ++s) { + const ActVec v = row[static_cast(s) * SG_SIZE + lane]; + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s) * SG_SIZE + lane] = qv; + } + }); +} + +// Vectors a lane loads before it consumes any of them. `4` covers every +// shipped shape's `steps` (6 / 12 / 16 / 24 at VEC = 8) with at most a +// two-vector tail. `ARK_MOE_W4A8_ACT_QUANT_UNROLL` selects 1 (the previous +// kernel), 2 or 4; anything else falls back to the default. +constexpr int kActQuantUnrollDefault = 4; + +// Partial maxima the single-pass kernel reduces into, matching the two-pass +// kernel's default `UNROLL` so the two produce the same value bit for bit. +constexpr int kActQuantSinglePartials = 4; + +// Longest row a lane keeps in registers, in vectors. 16 vectors is 64 dwords +// per lane at VEC = 8 (K = 2048), half the default 128-dword budget; longer +// rows take the two-pass kernel rather than risk a spill. +constexpr int kActQuantSingleMaxSteps = 16; + +// Single-pass variant: the row is loaded once into registers, reduced, then +// quantized out of them. `MAX_STEPS` bounds the register array at compile time +// (see the design note above); `steps <= MAX_STEPS` is the caller's contract. +template +void launch_act_dynamic_quant_vec_single(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); + static_assert(MAX_STEPS >= kActQuantSinglePartials, "MAX_STEPS must cover the partial accumulators"); + using ActVec = sycl::vec; + using QVec = sycl::vec; + + const int steps = K / (SG_SIZE * VEC); + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); + QVec* out = reinterpret_cast(qact + static_cast(token) * K); + + // The whole row, one load per slot, every one issued before the first + // is consumed. Constant indices under the unroll keep it in registers. + ActVec v[MAX_STEPS]; +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { + v[s] = row[static_cast(s) * SG_SIZE + lane]; + } + } + + float part_max[kActQuantSinglePartials]; +#pragma unroll + for (int u = 0; u < kActQuantSinglePartials; ++u) part_max[u] = 0.0f; + +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); + part_max[s % kActQuantSinglePartials] = + sycl::fmax(part_max[s % kActQuantSinglePartials], sycl::fabs(static_cast(a))); + } + } + } + + float local_max = part_max[0]; +#pragma unroll + for (int u = 1; u < kActQuantSinglePartials; ++u) local_max = sycl::fmax(local_max, part_max[u]); + + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + // No second read of the row: it is already here. +#pragma unroll + for (int s = 0; s < MAX_STEPS; ++s) { + if (s < steps) { + QVec qv; +#pragma unroll + for (int e = 0; e < VEC; ++e) { + const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); + const float x = sycl::rint(static_cast(a) * inv); + qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); + } + out[static_cast(s) * SG_SIZE + lane] = qv; + } + } + }); +} + +inline int moe_w4a8_act_quant_unroll() { + const char* env = std::getenv("ARK_MOE_W4A8_ACT_QUANT_UNROLL"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && (v == 1 || v == 2 || v == 4)) return static_cast(v); + } + return kActQuantUnrollDefault; +} + +// How many k-tiles of A and B the prefill mainloop keeps prefetched ahead of +// the tile it is computing. The prologue issues `prefetch_dist` pairs before +// the first DPAS and the loop then issues one pair per tile, so this is the +// depth of the memory pipeline the mainloop runs against -- too shallow and +// the DPAS waits on L2, too deep and the prefetched lines are evicted before +// use (and the prologue itself becomes a serial stall on short K). +// +// 3 is the value the mainloop was written with and the sibling prefill kernels +// use. The shapes here are short in K (12 k-tiles at K = 768), which is exactly +// where the depth is worth re-measuring, so it is a runtime knob rather than a +// constant; `test_perf_prefill_prefetch_sweep` walks it. +inline constexpr int kPrefillPrefetchDefault = 3; + +inline int moe_w4a8_prefill_prefetch_dist() { + const char* env = std::getenv("ARK_MOE_W4A8_PREFILL_PREFETCH"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 1 && v <= 8) return static_cast(v); + } + return kPrefillPrefetchDefault; +} + +// Runtime unroll depth -> compile-time bridge. +template +void launch_act_dynamic_quant_vec_unroll(int unroll, sycl::queue* q, const ScalarT* activations, int8_t* qact, + float* ascale, int total_tokens, int K, int* expert_id_per_token, + const int* num_tokens_per_expert, int num_experts) { + // Register-resident single pass when the row fits, the two-pass kernel + // otherwise. The smallest rung that covers `steps` is chosen so a short row + // does not reserve registers for slots it never loads. + const int steps = K / (SG_SIZE * VEC); + if (steps <= kActQuantSingleMaxSteps && + moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS", true)) { + if (steps <= kActQuantSingleMaxSteps / 2) { + launch_act_dynamic_quant_vec_single( + q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); + } else { + launch_act_dynamic_quant_vec_single( + q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); + } + return; + } + + if (unroll == 1) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } else if (unroll == 2) { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } else { + launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + } +} + +template +void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, + int total_tokens, int K, int* expert_id_per_token = nullptr, + const int* num_tokens_per_expert = nullptr, int num_experts = 0) { + static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); + if (total_tokens == 0) return; + + // Widest lane chunk this shape and these buffers support. The alignment + // checks never fire for torch allocations or the scratch pool (both are at + // least 256-byte aligned), but a caller-supplied activation view could be + // offset, and an unaligned `sycl::vec` access would be undefined. + if (moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_VEC", true)) { + const auto act_addr = reinterpret_cast(activations); + const auto q_addr = reinterpret_cast(qact); + const int unroll = moe_w4a8_act_quant_unroll(); + if (K % (SG_SIZE * 8) == 0 && act_addr % (8 * sizeof(ScalarT)) == 0 && q_addr % 8 == 0) { + launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + return; + } + if (K % (SG_SIZE * 4) == 0 && act_addr % (4 * sizeof(ScalarT)) == 0 && q_addr % 4 == 0) { + launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, + expert_id_per_token, num_tokens_per_expert, num_experts); + return; + } + } + + sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int lane = static_cast(it.get_local_id(1)); + const ScalarT* row = activations + static_cast(token) * K; + int8_t* out = qact + static_cast(token) * K; + + float local_max = 0.0f; + for (int k = lane; k < K; k += SG_SIZE) { + local_max = sycl::fmax(local_max, sycl::fabs(static_cast(row[k]))); + } + auto sg = it.get_sub_group(); + const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); + + const float scale = absmax / kInt8Max; + const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; + if (lane == 0) { + act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); + } + + for (int k = lane; k < K; k += SG_SIZE) { + const float v = sycl::rint(static_cast(row[k]) * inv); + out[k] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); + } + }); +} + +// --------------------------------------------------------------------------- +// AUTO_S8 stage 1: per-(expert, output channel, block) re-scale factor. +// +// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * (8 / 127) +// +// Verbatim port of the rescale branch of `packscale` in `xpu_wrapper.hpp` +// (with `fullrange = 8` for int4). An all-zero block yields `sxt = 0`; stage 2 +// turns that into all-zero int8 weights, so the (equally zero) product is +// still exact. +// --------------------------------------------------------------------------- +template +void launch_weight_scale_reduce(sycl::queue* q, const ScalarT* scales, float* wscale_out, int E, int N, int K, + int group_size, int rescale_block, int nblk) { + const int groups_k = K / group_size; + const int groups_per_block = rescale_block / group_size; + + q->parallel_for>( + sycl::range<1>(static_cast(E) * static_cast(N) * static_cast(nblk)), + [=](sycl::id<1> id) { + const size_t idx = id[0]; + const int blk = static_cast(idx % static_cast(nblk)); + const size_t row = idx / static_cast(nblk); // e * N + n + const ScalarT* s_row = + scales + row * static_cast(groups_k) + static_cast(blk) * groups_per_block; + + float absmax = 0.0f; + for (int g = 0; g < groups_per_block; ++g) { + absmax = sycl::fmax(absmax, sycl::fabs(static_cast(s_row[g]))); + } + wscale_out[idx] = absmax * (kInt4FullRange / kInt8Max); + }); +} + +// --------------------------------------------------------------------------- +// AUTO_S8 stage 2: int4 -> int8 re-scale. +// +// w8[k] = round(w4[k] * s[k / group_size] / sxt[k / rescale_block]) +// +// Verbatim port of the `CfgDequantS8Rescale` branch of `unpackq` in +// `xpu_wrapper.hpp`. One work-item decodes one 32-bit word (8 nibbles); the +// shape gate guarantees `group_size % 8 == 0` and `rescale_block % 8 == 0`, so +// all 8 K indices of a word share the same group scale and the same block +// scale and both loads hoist out of the inner loop. +// --------------------------------------------------------------------------- +template +void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, const ScalarT* scales, + const float* wscale, int8_t* w8_out, int E, int N, int K, int group_size, + int rescale_block, int nblk) { + const int groups_k = K / group_size; + const int octets = K / kPrepackOctet; + + q->parallel_for>( + sycl::range<2>(static_cast(E) * static_cast(N), static_cast(octets)), + [=](sycl::id<2> id) { + const size_t row = id[0]; // e * N + n + const int oct = static_cast(id[1]); + const int k_base = oct * kPrepackOctet; + + const uint8_t* w_ptr = weights + row * static_cast(K / 2) + static_cast(oct) * 4; + const uint32_t word = *reinterpret_cast(w_ptr); + int q4[kPrepackOctet]; + decode_int4_octet(word, q4); + + const float s = static_cast(scales[row * static_cast(groups_k) + k_base / group_size]); + const float sx = wscale[row * static_cast(nblk) + k_base / rescale_block]; + const float f = sx > 0.0f ? s / sx : 0.0f; + + int8_t* out = w8_out + row * static_cast(K) + k_base; +#pragma unroll + for (int j = 0; j < kPrepackOctet; ++j) { + const float v = sycl::rint(static_cast(q4[j]) * f); + out[j] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); + } + }); +} + +// --------------------------------------------------------------------------- +// Tile policies. +// +// `WGTile`'s K extent is 64 -- the int8 DPAS atom's K granularity, matching +// `sycl_tla_s8_gemm.hpp`'s `Shape, Int, _64>`. The M/N +// shapes and sub-group layouts are the reference GEMM's tile ladder +// (`SmallTileSG` / `SmallMidTileSG` / `MediumTileSG` / `LargeTileSG`), which +// keeps `size(mma)` at 64 / 128 / 256 / 512 threads -- all divisors of the 512 +// threads-per-SM budget the persistent scheduler assumes. +// +// Tile shape *is* the prefill bandwidth knob. A `TileM x TileN` tile reads its +// own A and B slabs, so the bytes a whole expert pulls through L2/DRAM are +// +// M*K * ceil(N/TileN) + N*K * ceil(M/TileM) ~= M*N*K * (1/TileN + 1/TileM) +// +// i.e. A is re-read once per N tile and B once per M tile. Both halvings are +// real, and both are cancelled by *padding*: an expert launches +// `ceil(M/TileM) * ceil(N/TileN)` full tiles, so a `TileM` that does not +// divide the rows/expert pays for rows that do not exist. +// +// `test_perf_prefill_tile_sweep` on BMG at the compute-bound batch the suite +// runs (384 rows/expert, bf16 act), with the 2D block store and the +// single-pass activation quantizer in: +// +// shape auto 128x128 128x256 256x128 256x256 +// qwen3 up 3.540 ms 3.518 ms 3.585 ms 4.404 ms 3.970 ms +// qwen3 down 2.472 ms 2.473 ms 2.432 ms 2.696 ms 2.547 ms +// minimax up 6.976 ms 6.823 ms 6.878 ms 8.899 ms 8.019 ms +// minimax down 6.749 ms 7.227 ms 6.874 ms 9.096 ms 7.823 ms +// +// and `test_perf_prefill_tile_sweep_long_seq`, the same sweep at one 8K prompt +// -- 512 rows/expert on qwen3 (128 experts), 341 on minimax (192): +// +// shape auto 128x128 128x256 256x128 256x256 +// qwen3 up 4.371 ms 4.382 ms 4.393 ms 4.468 ms 4.394 ms +// qwen3 down 3.075 ms 3.030 ms 2.903 ms 3.025 ms 3.059 ms +// minimax up 6.564 ms 6.673 ms 6.449 ms 9.057 ms 7.744 ms +// minimax down 6.466 ms 6.725 ms 6.450 ms 9.373 ms 7.307 ms +// +// M: `TileM = 256` never pays. At 384 and 341 rows/expert it is 1.05-1.45x +// *behind*, and that part is arithmetic rather than a register effect: 384 +// rows take `ceil(384/256) = 2` 256-row tiles -- 512 rows scheduled for 384 +// rows of data, a third of the MACs spent on padding -- against exactly 3 full +// 128-row tiles, and the like-for-like ratio on the long-K shapes, where the +// mainloop dominates, is that padding ratio (512/384 = 1.33) to within noise +// (1.25x qwen3 up, 1.30x minimax up, both at `TileN = 128`). +// The 8K prompt is the case where that argument does *not* apply: 512 rows per +// expert is an exact multiple of 256, so both tiles schedule the same rows. +// The 256-row tile is still not ahead there. Like for like on `TileN` it reads +// -2.0% / 0.0% (qwen3 up at `TileN` 128 / 256) and +0.2% / -5.4% (qwen3 down), +// i.e. never better than a tie and 5.4% behind on the shape with the shortest +// mainloop -- so halving how often B is pulled per M tile buys nothing that the +// larger work-group (512 threads, one per Xe core) does not give back in +// scheduling granularity. The only reading ever in its favour is an older run +// at 256 rows/expert, 1.3-3.9% ahead, inside the noise floor. The ladder +// therefore stops taking it (see `moe_w4a8_prefill_dispatch`): it has no +// measured upside, and a routing skewed around the average the ladder sees +// puts individual experts back on the padding cliff. +// +// N: the 256-wide tile is ahead or level everywhere the tables can compare it +// -- at 384 rows/expert it takes minimax down by 1.05x and qwen3 down by 1.02x +// and is 0.8-1.9% behind on the other two, and at the 8K prompt it takes three +// of four by 3.5-4.4% and ties qwen3 up (0.3%). The 35-50% cliff the first +// sweep saw on every 256-wide N tile is gone -- it was the float C shadow the +// mainloop used to keep live (see `xe_gemm_w4a8`), which doubled the per-lane +// C footprint and made `TileN = 256` ask for the entire 256-register large-GRF +// file -- and what remained of it in the second sweep (0-8% behind on three +// shapes, measured with the *scalar* epilogue store) is gone too, now that a +// 32x64 fragment goes out in a handful of block messages instead of 128 scalar +// ones. So the ladder is 256 wide in N wherever N divides into it. +// +// Noise floor for reading all of this: the `auto` column is not an independent +// measurement -- it launches whichever explicit tile the ladder picks, so each +// row above contains one *duplicate* pair (`128x256` at 384 rows/expert and at +// the minimax 8K point, `256x256` at the qwen3 8K point). Across the eight +// pairs the two readings of the same kernel differ by 0.2-1.9%, which is the +// run-to-run floor for these tables; the padding effect reaches 45%. +// +// Every policy stays reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a +// re-sweep on a device with a different register budget. +// --------------------------------------------------------------------------- +class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_8, _128, _64>; + using SGLayout = Layout, Stride<_0, _1, _0>>; +}; + +class w4a8_policy_m_64 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_64, _128, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +class w4a8_policy_m_128 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_128, _128, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +class w4a8_policy_m_128_n256 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_128, _256, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +class w4a8_policy_m_256_n128 : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_256, _128, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { + public: + using WGTile = Shape<_256, _256, _64>; + using SGLayout = Layout, Stride<_4, _1, _0>>; +}; + +// --------------------------------------------------------------------------- +// Optional fused top-k reduction (prefill only). +// +// The grouped GEMM's natural output is `[T, N]`, one row per *routed* row, and +// every caller immediately reduces it: a token's `top_k` rows are scaled by +// their routing weights and summed into one `[batch, N]` row. That reduction +// reads `T*N` and writes `batch*N`, and the GEMM wrote `T*N` for it to read -- +// so the unfused contract moves `2*T*N + batch*N` elements where the fused one +// moves `2*batch*N` (a read-modify-write of the accumulator). +// +// It is the largest lever on the down-projection shapes, where D is a third of +// the call's traffic: at qwen3's routing (`top_k = 8`) it takes D from +// `T*N*sizeof(ElementD)` to `batch*N*4*2`, i.e. 192 MB -> 48 MB at 384 +// rows/expert, and deletes the caller's reduction kernel outright. +// +// The accumulator is fp32 and the caller must zero it: rows of the same token +// land on different experts, hence on different work-groups, so the only +// portable combiner is a device-scope atomic add. That makes the result +// **order-dependent** and therefore not bit-identical to the unfused path -- +// the equivalence test for this contract is an SNR/cosine gate, not +// `torch.equal`. Scaling is applied before the atomic (one multiply per +// element), so the atomic itself stays a plain `fetch_add`. +// +// `out == nullptr` selects the unfused path and compiles to the same code as +// before; the branch is uniform across the work-group (it is a kernel +// argument). +// --------------------------------------------------------------------------- +struct MoEFusedReduce { + const int* row_to_token = nullptr; // routed row -> model token (expert-local base) + const float* row_weight = nullptr; // routed row -> routing weight (expert-local base) + float* out = nullptr; // [batch, N] fp32 accumulator, zeroed by the caller + int batch = 0; // rows of `out`; bounds the scatter + + CUTE_HOST_DEVICE bool enabled() const { return out != nullptr; } +}; + +CUTE_DEVICE inline void atomic_add_f32(float* addr, float value) { + sycl::atomic_ref + ref(*addr); + ref.fetch_add(value); +} + +// --------------------------------------------------------------------------- +// Single-tile int8 x int8 -> int32 mainloop with a per-block weight scale and +// a per-row activation scale. +// +// Structure is `igemm_kblock_device_impl` from `sycl_tla_s8_gemm.hpp` (the +// W4A8 weight-only GEMM), with two changes for the grouped/MoE case: +// * the tile coordinate is passed in by the persistent scheduler instead of +// being derived from the work-item's group id, and +// * A/B/D base pointers are the per-expert slices. +// +// `blks == 1` (the AUTO_S8 `group=-1` default) collapses the outer loop to a +// single full-K int32 accumulation -- the maximum-efficiency shape -- and gets +// its own instantiation, because on this architecture the *register file*, not +// the tile traffic, is what the prefill GEMM runs out of. +// +// Why `blks == 1` is specialized (the register-file argument) +// ----------------------------------------------------------- +// The blocked path needs two C fragments: the int32 DPAS accumulator `tCrC`, +// cleared once per re-scale block, and a float `tFrC` that survives across +// blocks because the per-block weight scale has to be applied before the next +// block overwrites `tCrC`. Both are the size of the work-group tile divided by +// the sub-group count, and a lane holds them in GRF for the *entire* mainloop: +// +// tile SG C fragment int32 regs/lane + float regs/lane +// 128x128 32 x 32 64 64 +// 128x256 32 x 64 128 128 +// +// With `grf_size<256>` a lane has 256 registers in total, so at 128x128 the +// float shadow alone reserves a quarter of the register file for the whole +// mainloop, and at 128x256 the two fragments together *are* the register file +// -- leaving nothing for the staged A/B tiles. That is the measured cliff +// documented in the tile-policy comment above (256-wide N tiles 35-50% slower +// than 128-wide ones, split by `TileN` and not by `TileM`), and it is pure +// overhead when `blks == 1`: with one block there is nothing to carry across +// blocks, so the scale can be folded on the way out and no float fragment +// needs to exist while the mainloop runs. +// +// The single-block epilogue therefore keeps only `tCrC` live and applies +// `scale_b[col] * scale_a[row]` in one pass, exactly like the `AccumBlock == +// false` branch of the reference `igemm_device_impl`. +// +// The epilogue writes through the raw `[m, n]` row-major output pointer using +// the coordinates of `thr_mma.partition_C(...)`, exactly like the reference, +// because the int32 accumulator has to be converted and scaled per element +// anyway. A grouped GEMM's per-expert M is arbitrary, so tiles at the M edge +// are partial and their *store* has to be predicated -- but the scale *loads* +// are not: their indices are clamped into range instead. Both scale reads are +// then unconditional loads at a compile-time offset from a uniform base, which +// is what lets the compiler collapse the `size(tCrC)` per-element reads into +// the handful of distinct addresses a sub-group's fragment actually covers +// (all lanes of a row group share `scale_a[row]`, and a lane repeats the same +// `scale_b[col]` for every row it owns). Under the previous `continue` guard +// each read sat in its own basic block and none of that could be hoisted. +// +// Interior tiles skip the guard entirely (the cost that shows up at small K) +// ------------------------------------------------------------------------- +// `m`, `n`, `m_coord` and `n_coord` are all uniform across the work-group, so +// "does this tile touch the M or N edge" is one uniform compare, not a +// per-element one. Off the edge the clamps and the store predicate are dead +// weight: per fragment element they add two compares plus two selects for the +// scale indices and another compare pair for the store, roughly doubling the +// instruction count of an epilogue whose real work is one int32->float +// convert, two multiplies and one store. +// +// That matters because the epilogue is *not* amortized over a long mainloop at +// these shapes. A 128x128 tile runs `K / 64` k-tiles -- 12 of them for the +// qwen3 down-projection (K = 768) -- while it always writes `TileM * TileN` +// elements, and qwen3 down is exactly the shape the sweep reports furthest +// from the compute target (63 TFLOPS against 87-103 for the other three). The +// fast path emits the same expression in the same order for every element it +// stores, so it is bit-identical to the guarded one +// (`test_full_tile_epilogue_matches_predicated`), and +// `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` forces the guarded path for A/B +// measurement. `test_perf_prefill_epilogue_sweep` at 384 rows/expert has it at +// 1.04x (qwen3 down), 1.03x (qwen3 up) and 1.00-1.01x on the two minimax +// shapes -- the shape ordering the instruction-count argument predicted, with +// the gain concentrated where the mainloop is shortest. +// +// The store itself: one 2D block message instead of `size(tCrC)` scalar ones +// ----------------------------------------------------------------------- +// Removing instructions from around the store left the store. The Xe DPAS C +// fragment gives a lane one *column* of each 8x16 atom, so the 16 lanes of a +// sub-group hold 16 *consecutive columns of one row*: a scalar +// `c[row * n + col] = ...` is a 32-byte message for 16-bit `ElementD`, half a +// cache line, and a 32x32 sub-group fragment issues **64** of them. The same +// bytes go out in 4 messages through the hardware 2D block store, which is +// what every sibling prefill kernel already uses for D +// (`sycl_tla_moe_prefill_{fp8,int,s4}_dpas.hpp`) and what the dense GEMM in +// `sycl_tla_dense_gemm.hpp` uses on this exact accumulator shape. +// +// D is the reason this is worth doing at prefill sizes rather than a tidy-up: +// at 384 rows per expert the qwen3 down-projection writes `M*N` fp16 (1.5 MB +// per expert) -- exactly as many bytes as the `N*K` int8 weights it reads, +// because N (2048) is larger than K (768) there, and over a third of the +// expert's traffic. It is the same shape whose mainloop is shortest, so it +// pays the epilogue twice. +// +// The port follows `dense_gemm_detail::gemm_device_impl` rather than the +// sibling MoE kernels, because those `reorder(tCrC, tCrC_out)` from the MMA +// fragment into an explicitly chosen `XE_STORE_2D` atom's fragment, and +// `reorder` moves *registers*: with a `float` accumulator that is free, but +// this kernel's accumulator is `int32` (`FrgTypeC` of +// `XE_DPAS_TT<8, int32_t, int8_t, int8_t>`) and has to be scaled and +// numerically converted first, which `reorder` does not do. `dense_gemm`'s +// shape is the one that fits: `make_block_2d_copy_D(mma, D)` derives its +// layout from the MMA's own C partition, so the scaled `ElementD` fragment +// (`make_tensor_like(tCrC)`, filled through the same `tCgC(i)` +// coordinates the scalar path uses) can be handed straight to +// `copy(copy_d, tCrD, tCgC)` with no `reorder` in between. +// +// It also *removes* the store predicate rather than skipping it: the 2D block +// message clips to the surface (`m` rows x `n` columns) described by the D +// tensor, so a partial tile at the M edge drops its out-of-range rows in +// hardware -- exactly how the sibling grouped GEMMs handle their ragged +// experts. Only the scale *loads* still need their index clamps, and only on +// edge tiles. The value written is computed by the same expression in the same +// order as the scalar path, so the two are bit-identical +// (`test_prefill_2d_store_matches_scalar`); `ARK_MOE_W4A8_PREFILL_STORE_2D=0` +// restores the scalar store for A/B measurement. `test_perf_prefill_store_- +// sweep` at 384 rows/expert makes it the largest single prefill win of the +// set: 1.14x (qwen3 up), 1.21x (qwen3 down -- the shape that pays the epilogue +// twice), 1.09x (minimax up) and 1.16x (minimax down); the run before read +// 1.16 / 1.35 / 1.12 / 1.20, same ordering. +// +// The block 2D descriptor wants a 64-byte aligned base and a row pitch that is +// a multiple of 16 bytes. The base here is the expert's slice +// `Outputs + pre_rows * N`, with `pre_rows` a runtime routing value, so the +// dispatcher gates on `N * sizeof(ElementD) % 64 == 0` (which makes *every* +// expert's base 64-byte aligned given an aligned tensor) and on the base +// pointer itself; anything else keeps the scalar store. +// --------------------------------------------------------------------------- +template +CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, const float* scale_a, + const float* scale_b, int m, int n, int k, int blocksize, int blks, int m_coord, + int n_coord, bool allow_full_tile, bool allow_block_2d_store, int prefetch_dist, + MoEFusedReduce const& reduce, TiledMMA const& mma) { + auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); + const int local_id = static_cast(item.get_local_linear_id()); + + auto wg_tile = mma.tile_mnk(); + auto wg_coord = make_coord(m_coord, n_coord, 0); + + // The fused path never writes through `c` (it scatters into `reduce.out` + // instead) and its caller has no `[T, N]` buffer to hand over, so `c` is + // null there. D and its 2D copy atom are still built -- they are ordinary + // objects, not lazily constructed -- so give them a valid base to describe. + ElementD* d_base = c != nullptr ? c : reinterpret_cast(reduce.out); + + auto A = make_tensor(make_gmem_ptr(const_cast(a)), make_shape(m, k), make_stride(k, _1{})); + auto B = make_tensor(make_gmem_ptr(const_cast(b)), make_shape(n, k), make_stride(k, _1{})); + auto D = make_tensor(make_gmem_ptr(d_base), make_shape(m, n), make_stride(n, _1{})); + + Tensor cA = make_identity_tensor(A.shape()); + Tensor cB = make_identity_tensor(B.shape()); + Tensor cC = make_identity_tensor(D.shape()); + + Tensor gA = local_tile(cA, select<0, 2>(wg_tile), make_coord(m_coord, _)); + Tensor gB = local_tile(cB, select<1, 2>(wg_tile), make_coord(n_coord, _)); + Tensor gC = local_tile(cC, wg_tile, wg_coord, Step<_1, _1, X>{}); + + auto copy_a = get_block_2d_copy_A(mma, A); + auto copy_b = get_block_2d_copy_B(mma, B); + auto copy_d = make_block_2d_copy_D(mma, D); + + auto thr_mma = mma.get_slice(local_id); + auto thr_copy_a = copy_a.get_slice(local_id); + auto thr_copy_b = copy_b.get_slice(local_id); + + auto tCrA = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); + auto tCrB = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); + + auto tArA = thr_copy_a.partition_sg_fragment_D(gA(_, _, 0)); + auto tBrB = thr_copy_b.partition_sg_fragment_D(gB(_, _, 0)); + + Tensor tAgA = thr_copy_a.partition_S(gA); + Tensor tBgB = thr_copy_b.partition_S(gB); + + Tensor tCrC = partition_fragment_C(mma, select<0, 1>(wg_tile)); + Tensor tCgC = thr_mma.partition_C(gC); + + auto prefetch_a = make_block_2d_prefetch(copy_a); + auto prefetch_b = make_block_2d_prefetch(copy_b); + + auto pAgA = prefetch_a.get_slice(local_id).partition_S(gA); + auto pBgB = prefetch_b.get_slice(local_id).partition_S(gB); + + constexpr auto barrier_scope = ScopeWorkgroup; + + const int k_tile_size = static_cast(get<2>(wg_tile)); + const int k_tiles_per_block = blocksize / k_tile_size; + const int k_tile_count = blks * k_tiles_per_block; + int k_tile_prefetch = 0; + + // One k-tile of the DPAS pipeline. Shared by both paths so the two + // instantiations differ only in what they keep live around it. + auto run_k_tile = [&](int k_tile) { + barrier_arrive(barrier_scope); + + copy(copy_a, tAgA(_, _, _, k_tile), tArA); + copy(copy_b, tBgB(_, _, _, k_tile), tBrB); + + if (k_tile_prefetch < k_tile_count) { + prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); + prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); + } + ++k_tile_prefetch; + + reorder(tArA, tCrA); + reorder(tBrB, tCrB); + cute::gemm(mma, tCrA, tCrB, tCrC); + + barrier_wait(barrier_scope); + }; + + // Runtime bound (`ARK_MOE_W4A8_PREFILL_PREFETCH`), so no unroll pragma: the + // prologue runs once per tile, ahead of a mainloop of `k_tile_count` + // iterations, and its trip count is uniform across the work-group. + for (; k_tile_prefetch < prefetch_dist && k_tile_prefetch < k_tile_count; ++k_tile_prefetch) { + prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); + prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); + } + + // Does this tile touch the M or N edge? Uniform across the work-group (`m`, + // `n` and both coordinates are), so the epilogues below branch once instead + // of testing every fragment element. + const bool full_tile = allow_full_tile && (m_coord + 1) * static_cast(get<0>(wg_tile)) <= m && + (n_coord + 1) * static_cast(get<1>(wg_tile)) <= n; + + // `blks` is a kernel argument, so it is uniform across the work-group and + // this branch never splits the split-barrier pairing below. + if (blks == 1) { + clear(tCrC); + + for (int k_tile = 0; k_tile < k_tile_count; ++k_tile) { + run_k_tile(k_tile); + } + + // Single expression, instantiated once guarded and once not. `FullTile` + // only removes work: the value stored is computed by the same operations + // in the same order, so the two paths are bit-identical. + auto store_scaled = [&](auto full) { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + if constexpr (decltype(full)::value) { + c[static_cast(row) * n + col] = static_cast( + static_cast(tCrC(i)) * scale_b[static_cast(col)] * scale_a[row]); + } else { + // Clamp rather than branch: an out-of-range element's value is + // dropped by the guarded store, and unconditional loads let the + // redundant reads across the fragment collapse. `m` and `n` are both + // >= 1 here (an expert with no rows contributes no tiles). + const int row_in = row < m ? row : m - 1; + const int col_in = col < n ? col : n - 1; + const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * scale_a[row_in]; + if (row < m && col < n) { + c[static_cast(row) * n + col] = static_cast(value); + } + } + } + }; + + // Same values in the same order, through the hardware 2D block store. The + // element predicate is gone because the message clips to the `m x n` + // surface; only the scale loads still clamp their indices. + auto store_scaled_2d = [&](auto full) { + Tensor tCrD = make_tensor_like(tCrC); + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + if constexpr (decltype(full)::value) { + tCrD(i) = static_cast(static_cast(tCrC(i)) * scale_b[static_cast(col)] * + scale_a[row]); + } else { + const int row_in = row < m ? row : m - 1; + const int col_in = col < n ? col : n - 1; + tCrD(i) = static_cast(static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * + scale_a[row_in]); + } + } + copy(copy_d, tCrD, tCgC); + }; + + // Fused top-k reduction: scale the row by its routing weight and + // accumulate it into the token's row of the `[batch, n]` fp32 output. + // Out-of-range rows are dropped rather than clamped -- a clamped scatter + // would corrupt a *valid* token's accumulator, which the guarded store + // above cannot do -- but the loads stay unconditional so they still + // collapse across the fragment. `row_to_token` is caller data, so its + // value is range-checked as well: a bad index drops the contribution + // instead of writing outside the accumulator. + auto store_fused = [&](auto full) { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + const int row_in = decltype(full)::value ? row : (row < m ? row : m - 1); + const int col_in = decltype(full)::value ? col : (col < n ? col : n - 1); + const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * + scale_a[row_in] * reduce.row_weight[row_in]; + const int token = reduce.row_to_token[row_in]; + const bool in_tile = decltype(full)::value || (row < m && col < n); + if (in_tile && token >= 0 && token < reduce.batch) { + atomic_add_f32(&reduce.out[static_cast(token) * n + col], value); + } + } + }; + + if (reduce.enabled()) { + if (full_tile) { + store_fused(std::true_type{}); + } else { + store_fused(std::false_type{}); + } + } else if (allow_block_2d_store) { + if (full_tile) { + store_scaled_2d(std::true_type{}); + } else { + store_scaled_2d(std::false_type{}); + } + } else if (full_tile) { + store_scaled(std::true_type{}); + } else { + store_scaled(std::false_type{}); + } + return; + } + + Tensor tFrC = make_tensor_like(tCrC); + + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + tFrC(i) = 0.0f; + } + + for (int ib = 0; ib < blks; ++ib) { + clear(tCrC); + + for (int bk = 0; bk < k_tiles_per_block; ++bk) { + run_k_tile(ib * k_tiles_per_block + bk); + } + + if (full_tile) { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + const int col = static_cast(get<1>(tCgC(i))); + tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col) * blks + ib]; + } + } else { + CUTE_UNROLL + for (int i = 0; i < size(tCrC); ++i) { + const int col = static_cast(get<1>(tCgC(i))); + const int col_in = col < n ? col : n - 1; + tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col_in) * blks + ib]; + } + } + } + + if (reduce.enabled()) { + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + const int row_in = full_tile ? row : (row < m ? row : m - 1); + const float value = tFrC(i) * scale_a[row_in] * reduce.row_weight[row_in]; + const int token = reduce.row_to_token[row_in]; + const bool in_tile = full_tile || (row < m && col < n); + if (in_tile && token >= 0 && token < reduce.batch) { + atomic_add_f32(&reduce.out[static_cast(token) * n + col], value); + } + } + return; + } + + if (allow_block_2d_store) { + Tensor tCrD = make_tensor_like(tFrC); + if (full_tile) { + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + const int row = static_cast(get<0>(tCgC(i))); + tCrD(i) = static_cast(tFrC(i) * scale_a[row]); + } + } else { + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + const int row = static_cast(get<0>(tCgC(i))); + const int row_in = row < m ? row : m - 1; + tCrD(i) = static_cast(tFrC(i) * scale_a[row_in]); + } + } + copy(copy_d, tCrD, tCgC); + return; + } + + if (full_tile) { + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + c[static_cast(row) * n + col] = static_cast(tFrC(i) * scale_a[row]); + } + return; + } + + CUTE_UNROLL + for (int i = 0; i < size(tFrC); ++i) { + auto coord = tCgC(i); + const int row = static_cast(get<0>(coord)); + const int col = static_cast(get<1>(coord)); + const int row_in = row < m ? row : m - 1; + const float value = tFrC(i) * scale_a[row_in]; + if (row < m && col < n) { + c[static_cast(row) * n + col] = static_cast(value); + } + } +} + +// --------------------------------------------------------------------------- +// Persistent atomic scheduler over `rows_per_expert`. +// +// Structurally identical to `moe_dpas_int::MoEGEMM_int` (which is itself the +// vllm-xpu-kernels grouped-GEMM scheduler); only the per-expert pointer +// arithmetic and the mainloop call differ: +// * A / D advance by the expert's token offset (`pre_rows`), and so does the +// per-token activation scale. +// * B advances by `expert * N * K` int8 elements, the block scales by +// `expert * N * blks` floats. +// --------------------------------------------------------------------------- +template +CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const float* ScaleA, + const float* ScaleB, ElementD* Outputs, TiledMMA const& mma, + const int* rows_per_expert, const int32_t num_experts, const int32_t gemm_n, + const int32_t gemm_k, const int32_t blocksize, const int32_t blks, + const bool allow_full_tile, const bool allow_block_2d_store, + const int32_t prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer, + const sycl::local_accessor& slm_mem_const) { + auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); + auto wg_tile = mma.tile_mnk(); + auto wg_tile_m = get<0>(wg_tile); + auto wg_tile_n = get<1>(wg_tile); + + int group_id = item.get_group_linear_id(); + int gemm_n_pad = (gemm_n + wg_tile_n - 1) / wg_tile_n * wg_tile_n; + int group_m_id = (group_id * wg_tile_n) / gemm_n_pad; + int group_range = item.get_group_range(1); + int local_id = item.get_local_linear_id(); + + if (group_id == 0 && local_id == 0) { + auto atm = sycl::atomic_ref(atomic_buffer[0]); + atm.store(0); + } + + int pre_rows = 0; + int pre_tiles = 0; + + int32_t* slm_mem = + static_cast(slm_mem_const.template get_multi_ptr().get()); + + for (int i = 0; i < num_experts; ++i) { + int gemm_m = rows_per_expert[i]; + int cumsum_rows_for_experts = pre_rows + gemm_m; + int cumsum_tiles_for_experts = (gemm_m + wg_tile_m - 1) / wg_tile_m + pre_tiles; + + if (group_m_id >= cumsum_tiles_for_experts) { + pre_rows = cumsum_rows_for_experts; + pre_tiles = cumsum_tiles_for_experts; + continue; + } + + const int expert_id = i; + const int64_t B_offset = + static_cast(expert_id) * static_cast(gemm_n) * static_cast(gemm_k); + const int64_t SB_offset = + static_cast(expert_id) * static_cast(gemm_n) * static_cast(blks); + + const int8_t* ptr_A_curr_batch = Activations + static_cast(pre_rows) * gemm_k; + const int8_t* ptr_B_curr_batch = Weights + B_offset; + const float* ptr_SA_curr_batch = ScaleA + pre_rows; + const float* ptr_SB_curr_batch = ScaleB + SB_offset; + ElementD* ptr_D_curr_batch = Outputs == nullptr ? nullptr : Outputs + static_cast(pre_rows) * gemm_n; + + // The scatter targets a `[batch, N]` accumulator shared by every expert, + // so only the per-row side tables advance with the expert; `reduce.out` + // stays put. + MoEFusedReduce expert_reduce = reduce; + if (reduce.enabled()) { + expert_reduce.row_to_token = reduce.row_to_token + pre_rows; + expert_reduce.row_weight = reduce.row_weight + pre_rows; + } + + while (group_m_id < cumsum_tiles_for_experts) { + const int n_coord = (group_id * wg_tile_n) % gemm_n_pad / wg_tile_n; + const int m_coord = (group_m_id - pre_tiles); + + xe_gemm_w4a8(ptr_A_curr_batch, ptr_B_curr_batch, ptr_D_curr_batch, + ptr_SA_curr_batch, ptr_SB_curr_batch, gemm_m, gemm_n, gemm_k, + blocksize, blks, m_coord, n_coord, allow_full_tile, + allow_block_2d_store, prefetch_dist, expert_reduce, mma); + + if (local_id == 0) { + slm_mem[0] = cutlass::atomicAdd(atomic_buffer, 1); + } + item.barrier(sycl::access::fence_space::local_space); + group_id = group_range + slm_mem[0]; + group_m_id = (group_id * wg_tile_n) / gemm_n_pad; + } + pre_rows = cumsum_rows_for_experts; + pre_tiles = cumsum_tiles_for_experts; + } +} + +// --------------------------------------------------------------------------- +// Grouped-GEMM launcher (fork of `moe_dpas_int::MoEGEMMLauncher_int`, with the +// int8 DPAS atom of `sycl_tla_s8_gemm.hpp`). +// --------------------------------------------------------------------------- +template +void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const int8_t* weights, + const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, + const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, + const int blks, const bool allow_full_tile, const bool allow_block_2d_store, + const int prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer) { + using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; + using WGTile = typename Policy::WGTile; + using SGLayout = typename Policy::SGLayout; + using MMA = typename TiledMMAHelper, Layout, SGLayout>::TiledMMA; + auto mma = MMA{}; + + int sm_count = cutlass::KernelHardwareInfo::query_device_multiprocessor_count(0); + auto MaxThreadsPerWorkgroup = size(mma); + + static constexpr int MaxThreadsPerSM = 512; + if (MaxThreadsPerSM % MaxThreadsPerWorkgroup != 0) { + throw std::runtime_error("moe_gemm_w4a8: MaxThreadsPerSM must be divisible by MaxThreadsPerWorkgroup"); + } + + sycl::range<3> local(1, 1, MaxThreadsPerWorkgroup); + sycl::range<3> global(1, sm_count * MaxThreadsPerSM / MaxThreadsPerWorkgroup, 1); + + namespace syclex = sycl::ext::oneapi::experimental; + namespace intelex = sycl::ext::intel::experimental; + + syclex::properties kernel_props{syclex::sub_group_size<16>, intelex::grf_size<256>}; + + using GmemTiledCopyA = typename Policy::GmemTiledCopyA; + using GmemTiledCopyB = typename Policy::GmemTiledCopyB; + + auto event = stream.submit([&](sycl::handler& cgh) { + sycl::local_accessor local_mem(sycl::range<1>(1), cgh); + cgh.parallel_for>( + sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { + MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, + rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, + blks, allow_full_tile, allow_block_2d_store, prefetch_dist, + reduce, atomic_buffer, local_mem); + }); + }); + + EventManager::getInstance().addEvent(event); + event.wait(); +} + +// --------------------------------------------------------------------------- +// Prefill driver: policy selection on the average per-expert M. +// +// The rungs match the tile ladder of `launch_igemm_kblock` in +// `sycl_tla_s8_gemm.hpp`: a grouped GEMM's M is *per expert*, so the ladder +// walks the average rows/expert rather than the total token count. +// +// The M rung is a row threshold and the N rung a divisibility test, and both +// are about not scheduling work the shape does not have: +// +// * `TileM` stops at 128. The 256-row tile halves how often each expert's B +// panel is pulled through L2/DRAM (B is read once per M tile), but an +// expert launches `ceil(M / TileM)` *full* tiles, so it only breaks even +// where `ceil(M/256)*256 == ceil(M/128)*128` -- false at the 384 and 341 +// rows/expert the perf suite measures, where it computes 512 rows for 384 +// rows of data and reads 1.05-1.45x slower. The 8K prompt puts Qwen3-MoE +// at exactly 512 rows/expert, where the padding argument does not apply, +// and `test_perf_prefill_tile_sweep_long_seq` measures it there: still not +// ahead (a tie on qwen3 up, 5.4% behind on qwen3 down). So the rung is +// gone rather than gated -- there is no routing at which it has been +// measured to win, and the ladder only sees the *average* rows/expert, so +// a skewed routing would put individual experts back on the padding cliff +// even when the average divides. Both 256-row policies stay compiled and +// reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a re-sweep. +// +// * `TileN = 256` halves how often A is re-read (once per N tile) and is +// ahead or level everywhere the sweeps can compare it, so it is taken +// whenever N divides into it exactly. `N % 256 != 0` would pad the last +// tile the same way a ragged M does, and no shipped shape needs it: every +// N here (1536 / 2048 / 3072) is a multiple of 256. +// +// The rung used to be `A_avg_M >= 256 -> 256x128` with no padding test and a +// 128-wide N at every rung, which is what made the 384 rows/expert batch -- +// the compute-bound batch the perf suite now runs -- land on the slowest +// column of its own sweep; it then became a padding-gated 256-row tile, which +// the 8K-prompt sweep has now retired. +// +// `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile +// (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything +// else -- including the default `auto` -- keeps the ladder. It exists so the +// tile can be swept on hardware without a rebuild. +// +// `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` makes every tile take the guarded +// epilogue (see `xe_gemm_w4a8`), which is the A/B baseline for the interior- +// tile fast path; it is read here, once per call, rather than on the device. +// +// `ARK_MOE_W4A8_PREFILL_STORE_2D=0` puts the epilogue back on the scalar +// predicated store instead of the hardware 2D block store, the A/B baseline +// for that change. The block message needs a 64-byte aligned surface base and +// a 16-byte multiple row pitch; D's per-expert base is `outputs + pre_rows * N` +// for a routing-dependent `pre_rows`, so the gate is on the row stride itself +// (`N * sizeof(ElementD) % 64 == 0`, which covers the pitch as well) plus the +// tensor base. Every shipped N (1536 / 2048 / 3072 with 16-bit D) clears it; +// anything that does not keeps the scalar store rather than risking a +// misaligned descriptor. +// --------------------------------------------------------------------------- +template +void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* num_tokens_per_expert, int E, + int N, int K, int blocksize, int blks, int total_tokens, + MoEFusedReduce reduce = MoEFusedReduce{}) { + if (E == 0 || N == 0 || K == 0 || total_tokens == 0) return; + + compat::set_default_queue(*q); + + const int A_avg_M = total_tokens / E; + const bool tile_n_256 = (N % 256) == 0; + const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); + const bool store_2d_aligned = !reduce.enabled() && (static_cast(N) * sizeof(ElementD)) % 64 == 0 && + reinterpret_cast(outputs) % 64 == 0; + const bool allow_block_2d_store = + store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); + const int prefetch_dist = moe_w4a8_prefill_prefetch_dist(); + int32_t* atomic_buffer = moe_dpas_fp8::get_atomic_scratch_buffer(q); + +#define ARK_MOE_W4A8_LAUNCH(policy) \ + MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ + num_tokens_per_expert, E, blocksize, blks, allow_full_tile, \ + allow_block_2d_store, prefetch_dist, reduce, atomic_buffer); + + const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); + if (tile_env != nullptr) { + if (std::strcmp(tile_env, "8x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) + return; + } else if (std::strcmp(tile_env, "64x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) + return; + } else if (std::strcmp(tile_env, "128x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) + return; + } else if (std::strcmp(tile_env, "128x256") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) + return; + } else if (std::strcmp(tile_env, "256x128") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) + return; + } else if (std::strcmp(tile_env, "256x256") == 0) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) + return; + } + } + + if (A_avg_M < 16) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) + } else if (A_avg_M < 128) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) + } else if (tile_n_256) { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) + } else { + ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) + } +#undef ARK_MOE_W4A8_LAUNCH +} + +// --------------------------------------------------------------------------- +// Decode GEMV: int8 x int8 -> int32, one output column per sub-group lane. +// +// Same work decomposition as `moe_decode_detail::launch_int8` (work-group = +// one sub-group covering 16 consecutive N columns of one token), with the +// per-K-group float dequantization replaced by a per-block int32 dot product. +// Two accumulators hide the multiply-add latency; int32 cannot overflow here +// (|a|,|w| <= 127 gives < 2^14 per product, so K would have to exceed 130k). +// --------------------------------------------------------------------------- +template +void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, int total_tokens, + int N, int K, int blocksize, int blks) { + if (N % N_TILE != 0) { + throw std::invalid_argument("moe_gemm_w4a8(decode): N must be a multiple of 16"); + } + if (total_tokens == 0) return; + + const int n_tiles = N / N_TILE; + sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; + sycl::range<2> local{1, static_cast(SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const int token = static_cast(it.get_global_id(0)); + const int n_tile = static_cast(it.get_group(1)); + const int lane = static_cast(it.get_local_id(1)); + const int n_global = n_tile * N_TILE + lane; + + const int expert = expert_id_per_token[token]; + const int8_t* act_row = qact + static_cast(token) * K; + const int8_t* w_row = weights + (static_cast(expert) * N + static_cast(n_global)) * K; + const float* s_row = + wscale + (static_cast(expert) * N + static_cast(n_global)) * blks; + + constexpr int SUB = 16; + using QVec = sycl::vec; + + float accf = 0.0f; + for (int ib = 0; ib < blks; ++ib) { + const int k_base = ib * blocksize; + int acc0 = 0; + int acc1 = 0; + int kk = 0; + const int end = (blocksize / SUB) * SUB; + for (; kk < end; kk += SUB) { + const QVec av = *reinterpret_cast(act_row + k_base + kk); + const QVec wv = *reinterpret_cast(w_row + k_base + kk); +#pragma unroll + for (int u = 0; u < SUB; u += 2) { + acc0 += static_cast(av[u]) * static_cast(wv[u]); + acc1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); + } + } + for (; kk < blocksize; ++kk) { + acc0 += static_cast(act_row[k_base + kk]) * static_cast(w_row[k_base + kk]); + } + accf += static_cast(acc0 + acc1) * s_row[ib]; + } + + outputs[static_cast(token) * N + n_global] = static_cast(accf * ascale[token]); + }); +} + +// --------------------------------------------------------------------------- +// Decode GEMV, K-split lane mapping (default) -- one sub-group per output +// element, lanes splitting K, plus N-blocking over `NCOLS` columns. +// +// `launch_w4a8_decode` above maps one *work-item* to one output element, so a +// lane walks a whole `[n_global, K]` int8 weight row on its own. That is the +// same mapping the FP8 decode GEMV started from, and it costs the same two +// things on a kernel that does exactly one multiply-add per weight byte: +// +// 1. Weight loads are not coalesced. Lanes `l` and `l+1` read bytes `K` +// apart, so each 16-byte load turns into 16 scattered cache-line +// requests. No DRAM byte is wasted (each lane consumes its lines as it +// walks the row), but the memory controller sees 16 independent streams +// per sub-group -- the pattern DRAM row buffers handle worst. +// 2. The grid is small: `total_tokens * N / 16` sub-groups, i.e. 768 SIMD16 +// threads for a Qwen3-MoE batch-1 step (8 routed rows, N = 1536). That is +// below the thread slots of a BMG-class GPU (1280 on a B60), so there are +// never enough loads in flight to cover DRAM latency. +// +// This kernel transposes the mapping exactly like `launch_fp8_ksplit`: a whole +// sub-group cooperates on one output element and lane `l` owns the `CH` +// consecutive K elements at `l * CH` inside each `SG_SIZE * CH`-wide K tile. +// One instruction then covers `SG_SIZE * CH` *contiguous* weight bytes (256 B = +// four full cache lines at the default `CH = 16`, 512 B at `CH = 32`) and the +// same span of int8 activations, every thread walks a single sequential stream, +// and the sub-group count grows by `SG_SIZE` (12288 for that batch-1 step). The +// price is one `reduce_over_group` per output element -- a handful of shuffles +// against `K` multiply-adds. +// +// On top of that the sub-group blocks N: it owns `NCOLS` consecutive columns +// and reuses one activation load for all of them, which cuts activation +// messages per weight chunk to `1 / NCOLS` and multiplies the number of +// independent weight loads in flight by `NCOLS` (see +// `moe_w4a8_decode_ksplit_ncols`). +// +// Numerics are equivalent, not bit-identical: the int32 partial sums are still +// folded to float once per AUTO_S8 block with that block's scale, but they are +// split across the 16 lanes and summed at the end. Integer addition is exact +// and associative, so the *integer* partition is lossless; only the float +// accumulation is reordered (per lane, then across lanes, instead of one lane +// folding every block in sequence), which can differ from the legacy result by +// a rounding step. A lane's chunk is `CH` consecutive K elements starting at a +// multiple of `CH`, and the shape gate requires the block to be a multiple of +// `CH`, so a chunk never straddles two blocks. +// --------------------------------------------------------------------------- + +// K elements a lane owns per step: `KSPLIT_CH_DEFAULT` is one 16-byte int8 +// weight load and one 16-byte int8 activation load, the same transactions the +// legacy GEMV issues. `ARK_MOE_W4A8_DECODE_KSPLIT_CH=32` doubles them to +// 32-byte loads, which halves the number of memory messages per byte and +// doubles the bytes a thread keeps in flight; it costs GRF (2 x NCOLS chunks +// live at once) and needs `blocksize >= SG_SIZE * CH`. +// +// Measured (`test_perf_decode_config_sweep`, BMG, 8 routed rows, bf16 act), at +// the default NCOLS: 284.0 -> 278.9 GB/s (qwen3 up), 280.1 -> 244.4 (qwen3 +// down), 268.1 -> 259.9 (minimax up), 315.5 -> 308.7 (minimax down). The wider +// chunk never wins at any NCOLS and costs up to 13%, so 16 stays the default +// and 32 stays an opt-in sweep point. +constexpr int KSPLIT_CH_DEFAULT = 16; +constexpr int KSPLIT_CH_MAX = 32; +// Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group +// covers `KSPLIT_WG_SGS * NCOLS` consecutive columns. +constexpr int KSPLIT_WG_SGS = N_TILE; +// `NCOLS = 2` is the measured default: it is the fastest configuration on two +// of the four swept shapes and within 2% of the best (`4` on qwen3 down, `1` on +// minimax up) on the other two, while `1` costs 47% on qwen3 up and `4` costs +// 14% on minimax up. +constexpr int KSPLIT_NCOLS_DEFAULT = 2; +constexpr int KSPLIT_NCOLS_MAX = 4; + +// A lane's `CH`-byte chunk, as a register type. +// +// `sycl::vec` only exists for 1, 2, 3, 4, 8 and 16 elements, so a `CH = 32` +// chunk cannot be spelled `sycl::vec`: that instantiation is a hard +// static_assert in the SYCL headers ("Invalid number of elements for +// sycl::vec"). A chunk is therefore an aggregate of `CH / 16` 16-byte +// sub-vectors covering *consecutive* bytes. The lane still reads one contiguous +// `CH`-byte span at a `CH`-aligned address (`moe_w4a8_decode_ksplit_shape_ok` +// makes every chunk offset a multiple of `CH` off a row base that is a multiple +// of `K`, itself a multiple of `CH`), the sub-vectors are adjacent both in +// memory and in the GRF, and the declared alignment lets IGC fold the pair back +// into a single wider message. At the default `CH = 16` the aggregate holds a +// single `sycl::vec`, i.e. exactly the load this kernel issued +// before. +// +// `operator[]` is only ever called from the fully unrolled inner loops, so the +// sub-vector selection folds away at compile time and no dynamic indexing +// (which would push the chunk out to scratch) is generated. +template +struct alignas(CH) QChunk { + static constexpr int kSub = KSPLIT_CH_DEFAULT; + static_assert(CH % kSub == 0, "chunk width must be a whole number of 16-byte sub-vectors"); + sycl::vec v[CH / kSub]; + + int8_t operator[](int i) const { return v[i / kSub][i % kSub]; } +}; + +// `ARK_MOE_W4A8_DECODE_KSPLIT` (default ON). Setting it to "0" / "false" / +// "off" / "no" forces the legacy per-lane-strided GEMV, for A/B comparison and +// as a regression escape. Re-read on every call so benchmarks can toggle the +// path in-process. +inline bool moe_w4a8_decode_ksplit_enabled() { + return moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_DECODE_KSPLIT", true); +} + +// Per-lane chunk width in K elements (= bytes). 16 or 32; anything else falls +// back to the default. +inline int moe_w4a8_decode_ksplit_chunk() { + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_CH"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && (v == 16 || v == 32)) return static_cast(v); + } + return KSPLIT_CH_DEFAULT; +} + +// Shape gate. `blocksize >= SG_SIZE * ch` keeps every lane of the sub-group +// busy: below that some lanes own no chunk in a block and only pay the +// reduction, which is the one regime where splitting K cannot pay for itself. +// `blocksize % ch == 0` combined with `K % blocksize == 0` also makes every +// chunk offset a multiple of `ch` off a row base that is a multiple of `K`, so +// the vector loads stay naturally aligned. The resolved AUTO_S8 block is always +// a multiple of 64 that divides K, so the conditions hold for every shipped +// configuration and only very fine re-scale blocks fall back to the legacy +// GEMV. +inline bool moe_w4a8_decode_ksplit_shape_ok(int N, int K, int blocksize, int ch = KSPLIT_CH_DEFAULT) { + if (N % N_TILE != 0) return false; + if (blocksize < SG_SIZE * ch) return false; + if (blocksize % ch != 0) return false; + if (K % blocksize != 0) return false; + return true; +} + +// N-blocking factor. A work-group covers `KSPLIT_WG_SGS * ncols` columns, so +// `ncols` shrinks until it tiles N. `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` +// overrides the default (1, 2 or 4); `NCOLS == 1` reproduces the plain K-split +// mapping instruction for instruction. +inline int moe_w4a8_decode_ksplit_ncols(int N) { + int ncols = KSPLIT_NCOLS_DEFAULT; + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + if (end != env && v >= 1 && v <= KSPLIT_NCOLS_MAX && (v & (v - 1)) == 0) { + ncols = static_cast(v); + } + } + while (ncols > 1 && (N % (KSPLIT_WG_SGS * ncols)) != 0) ncols /= 2; + return ncols; +} + +template +void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int blocksize, int blks) { + static_assert(NCOLS >= 1 && (NCOLS & (NCOLS - 1)) == 0, "NCOLS must be a power of two"); + static_assert(CH == 16 || CH == KSPLIT_CH_MAX, "CH must be 16 or 32"); + // K elements a sub-group covers per step -- the contiguous span its 16 lanes + // read in one instruction. + constexpr int STEP = SG_SIZE * CH; + if (!moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, CH) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { + throw std::invalid_argument("moe_gemm_w4a8(decode): K-split GEMV called on an unsupported shape"); + } + if (total_tokens == 0) return; + + // One sub-group per (token, NCOLS columns); `KSPLIT_WG_SGS` of them per + // work-group. + sycl::range<2> global{static_cast(total_tokens), static_cast(N / NCOLS) * SG_SIZE}; + sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; + + q->parallel_for>( + sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + const auto sg = it.get_sub_group(); + const int token = static_cast(it.get_global_id(0)); + const int local_id = static_cast(it.get_local_id(1)); + // The work-group is one row of `KSPLIT_WG_SGS * SG_SIZE` work-items, so + // sub-group index and lane index are the halves of the local id. + const int lane = local_id % SG_SIZE; + const int n_base = (static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE) * NCOLS; + + const int expert = expert_id_per_token[token]; + const int8_t* act_row = qact + static_cast(token) * K; + const size_t row0 = static_cast(expert) * N + static_cast(n_base); + const int8_t* w_rows[NCOLS]; + const float* s_rows[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + w_rows[c] = weights + (row0 + static_cast(c)) * K; + s_rows[c] = wscale + (row0 + static_cast(c)) * blks; + } + + using QVec = QChunk; + + float acc[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) acc[c] = 0.0f; + + for (int ib = 0; ib < blks; ++ib) { + const int block_begin = ib * blocksize; + const int block_end = block_begin + blocksize; + int32_t iacc[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) iacc[c] = 0; + + int k0 = block_begin + lane * CH; + // Two chunks per iteration: their loads are independent, so the pair + // doubles the weight requests a thread keeps in flight. All + // `2 * NCOLS` weight loads are issued before the first is consumed. + for (; k0 + STEP + CH <= block_end; k0 += 2 * STEP) { + const QVec av0 = *reinterpret_cast(act_row + k0); + const QVec av1 = *reinterpret_cast(act_row + k0 + STEP); + QVec wv0[NCOLS], wv1[NCOLS]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + wv0[c] = *reinterpret_cast(w_rows[c] + k0); + wv1[c] = *reinterpret_cast(w_rows[c] + k0 + STEP); + } +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + int p0 = 0; + int p1 = 0; +#pragma unroll + for (int u = 0; u < CH; u += 2) { + p0 += static_cast(av0[u]) * static_cast(wv0[c][u]); + p1 += static_cast(av0[u + 1]) * static_cast(wv0[c][u + 1]); + p0 += static_cast(av1[u]) * static_cast(wv1[c][u]); + p1 += static_cast(av1[u + 1]) * static_cast(wv1[c][u + 1]); + } + iacc[c] += p0 + p1; + } + } + // Tail: the chunk of a lane whose partner a full step away falls + // outside the block. At most one chunk per lane. + for (; k0 < block_end; k0 += STEP) { + const QVec av = *reinterpret_cast(act_row + k0); +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + const QVec wv = *reinterpret_cast(w_rows[c] + k0); + int p0 = 0; + int p1 = 0; +#pragma unroll + for (int u = 0; u < CH; u += 2) { + p0 += static_cast(av[u]) * static_cast(wv[u]); + p1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); + } + iacc[c] += p0 + p1; + } + } + +#pragma unroll + for (int c = 0; c < NCOLS; ++c) acc[c] += static_cast(iacc[c]) * s_rows[c][ib]; + } + + const float sa = ascale[token]; +#pragma unroll + for (int c = 0; c < NCOLS; ++c) { + const float total = sycl::reduce_over_group(sg, acc[c], sycl::plus{}); + if (lane == 0) { + outputs[static_cast(token) * N + n_base + c] = static_cast(total * sa); + } + } + }); +} + +// Runtime (NCOLS, CH) -> compile-time bridge, plus the K-split / legacy choice. +// `CH = 32` needs a block of at least 512 elements, so it silently falls back to +// 16 on shapes it cannot serve rather than dropping to the legacy GEMV. +template +void launch_w4a8_decode_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, + const float* wscale, ElementD* outputs, const int* expert_id_per_token, + int total_tokens, int N, int K, int blocksize, int blks) { + if (moe_w4a8_decode_ksplit_enabled() && moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize)) { + const int ncols = moe_w4a8_decode_ksplit_ncols(N); + const int ch = moe_w4a8_decode_ksplit_chunk() == KSPLIT_CH_MAX && + moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, KSPLIT_CH_MAX) + ? KSPLIT_CH_MAX + : KSPLIT_CH_DEFAULT; + +#define ARK_MOE_W4A8_KSPLIT(ncols_v, ch_v) \ + launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, \ + expert_id_per_token, total_tokens, N, K, blocksize, blks); \ + return; + + if (ch == KSPLIT_CH_MAX) { + switch (ncols) { + case 4: + ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_MAX) + case 2: + ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_MAX) + default: + ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_MAX) + } + } + switch (ncols) { + case 4: + ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_DEFAULT) + case 2: + ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_DEFAULT) + default: + ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_DEFAULT) + } +#undef ARK_MOE_W4A8_KSPLIT + } + launch_w4a8_decode(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, total_tokens, N, K, + blocksize, blks); +} + +// --------------------------------------------------------------------------- +// Host-side helpers +// --------------------------------------------------------------------------- + +// Resolve the effective AUTO_S8 re-scale block size. +// +// `requested <= 0` (the `group=-1` spelling) or any value that is not a valid +// block size falls back to `K`, i.e. one scale per output channel -- the +// maximum-efficiency shape. `ARK_MOE_W4A8_AUTO_S8` overrides the argument so +// benchmarks can sweep the block size without touching the caller. +inline int moe_w4a8_rescale_block_size(int K, int group_size, int requested) { + int v = requested; + const char* env = std::getenv("ARK_MOE_W4A8_AUTO_S8"); + if (env != nullptr) { + char* end = nullptr; + const long parsed = std::strtol(env, &end, 10); + if (end != env) v = static_cast(parsed); + } + if (K <= 0) return K; + if (v <= 0 || v >= K) return K; + if (group_size > 0 && (v < group_size || v % group_size != 0)) return K; + if (K % v != 0) return K; + // The mainloop slices each block into 64-wide DPAS K tiles. + if (v % 64 != 0) return K; + return v; +} + +// Shape preconditions shared by the prepack, prefill and decode paths. +inline bool moe_w4a8_shape_ok(int N, int K, int group_size) { + if (N <= 0 || K <= 0 || group_size <= 0) return false; + if (N % N_TILE != 0) return false; + if (K % 64 != 0) return false; + if (group_size % kPrepackOctet != 0) return false; + if (K % group_size != 0) return false; + return true; +} + +// Token count at or below which the auto phase selection picks the decode +// GEMV. Mirrors `ARK_MOE_AUTO_DECODE_MAX_TOKENS` used by the Python `moe()` +// dispatcher; overridable with `ARK_MOE_W4A8_DECODE_MAX_TOKENS`. +inline int moe_w4a8_decode_max_tokens() { + const char* env = std::getenv("ARK_MOE_W4A8_DECODE_MAX_TOKENS"); + if (env == nullptr) return 128; + char* end = nullptr; + const long parsed = std::strtol(env, &end, 10); + if (end == env || parsed < 0) return 128; + return static_cast(parsed); +} + +inline void moe_w4a8_release_scratch() { + auto& st = w4a8_scratch_state(); + + // Detach everything under the lock, then drop the lock before the device sync + // and the frees: `wait()` blocks for an unbounded time and must not be held + // across. Because the slabs are already out of the pool's tables, an acquire + // that races in behind us allocates fresh ones instead of handing back a + // pointer we are about to free. + std::vector> pending; + { + std::lock_guard lock(st.mu); + auto* pool = DeviceMemoryPool::Instance(); + for (auto& kv : st.queues) { + sycl::queue q = kv.second; + for (size_t loc : {kW4A8QactScratchLoc, kW4A8ExpertMapScratchLoc}) { + void* ptr = pool->detach_scratch_mem(loc, &q); + if (ptr != nullptr) pending.emplace_back(q, ptr); + } + } + st.queues.clear(); + } + + for (auto& item : pending) { + item.first.wait(); + sycl::free(item.second, item.first); + } +} + +} // namespace moe_w4a8 + +// The public `ark::` entry points below are thin wrappers emitted by the +// generated `sycl_tla_moe_w4a8.cpp` translation unit (MOE_SOURCE_MODE +// 21); they live in their own namespace here so the header stays free of +// external definitions and only that one TU pays the kernel compile cost. +namespace moe_w4a8_detail { + +// --------------------------------------------------------------------------- +// Public entry point 1 -- one-shot AUTO_S8 prepack. +// +// Converts auto-round's packed int4-sym weights + per-group scales into the +// int8 weights + FP32 block scales the W4A8 kernels consume. Callers are +// expected to run this once per checkpoint and keep the results resident. +// --------------------------------------------------------------------------- +inline void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, + BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, + int rescale_group_size) { + if (num_experts <= 0) return; + if (!moe_w4a8::moe_w4a8_shape_ok(N, K, group_size)) { + throw std::invalid_argument( + "moe_w4a8_prepack: unsupported shape (need N % 16 == 0, K % 64 == 0, " + "group_size % 8 == 0 and K % group_size == 0)"); + } + if (weights_s4 == nullptr || scales == nullptr || weights_s8 == nullptr || wscales == nullptr) { + throw std::invalid_argument("moe_w4a8_prepack: null buffer"); + } + + const int blocksize = moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); + const int blks = K / blocksize; + + if (act_dtype == BTLA_DTYPE::F16) { + using ScalarT = sycl::half; + moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), + static_cast(wscales), num_experts, N, K, group_size, + blocksize, blks); + moe_w4a8::launch_weight_rescale_s4_to_s8( + q, static_cast(weights_s4), static_cast(scales), + static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, + blocksize, blks); + } else if (act_dtype == BTLA_DTYPE::BF16) { + using ScalarT = sycl::ext::oneapi::bfloat16; + moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), + static_cast(wscales), num_experts, N, K, group_size, + blocksize, blks); + moe_w4a8::launch_weight_rescale_s4_to_s8( + q, static_cast(weights_s4), static_cast(scales), + static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, + blocksize, blks); + } else { + throw std::invalid_argument("moe_w4a8_prepack: act_dtype must be F16 or BF16"); + } +} + +// --------------------------------------------------------------------------- +// Public entry point 2 -- W4A8 MoE GEMM (prefill + decode). +// +// `phase`: 0 = auto (decode when `total_tokens <= +// ARK_MOE_W4A8_DECODE_MAX_TOKENS`), 1 = force decode GEMV, 2 = force prefill +// grouped GEMM. +// +// Two optional call contracts trade interface work for DRAM traffic. Both are +// opt-in and the defaults are unchanged. +// +// Pre-quantized activations (`qact_in` + `ascale_in`) +// --------------------------------------------------- +// By default the call quantizes `[T, K]` itself: it reads the 16-bit +// activations, writes an int8 copy and the GEMM reads that copy back, i.e. +// `4 * T * K` bytes on top of the GEMM's own operands. On the down-projection +// that is 27% of everything the call moves -- and it is redundant, because the +// producer of those activations (the SiLU/gate elementwise kernel) already +// writes `[T, K]` once and could write int8 plus a per-row scale instead: the +// absmax it needs is a reduction over the row it is already holding. When both +// pointers are supplied all three streams disappear, along with a kernel +// launch. `ascale_in` is `[T]` fp32, `scale = absmax / 127`, matching what +// `launch_act_dynamic_quant` writes. +// +// Fused top-k reduction (`row_to_token` + `routing_weights` + `fused_out`) +// ----------------------------------------------------------------------- +// See `MoEFusedReduce`. Prefill only, and the accumulator must be zeroed by +// the caller; `outputs` is then unused and may be null. +// --------------------------------------------------------------------------- +inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, + BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, + int* num_tokens_per_expert, int num_experts, int total_tokens, int phase, + const void* qact_in = nullptr, const float* ascale_in = nullptr, + const int* row_to_token = nullptr, const float* routing_weights = nullptr, + float* fused_out = nullptr, int fused_batch = 0) { + if (total_tokens == 0 || num_experts <= 0) return; + if (N % moe_w4a8::N_TILE != 0) { + throw std::invalid_argument("moe_gemm_w4a8: N must be a multiple of 16"); + } + if (K % 64 != 0) { + throw std::invalid_argument("moe_gemm_w4a8: K must be a multiple of 64"); + } + if (rescale_block_size <= 0 || rescale_block_size > K || K % rescale_block_size != 0 || + rescale_block_size % 64 != 0) { + throw std::invalid_argument( + "moe_gemm_w4a8: rescale_block_size must be a multiple of 64 that divides K " + "(use moe_w4a8_rescale_block_size to resolve it)"); + } + if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { + throw std::invalid_argument("moe_gemm_w4a8: act_dtype must be F16 or BF16"); + } + + const bool prequantized = qact_in != nullptr || ascale_in != nullptr; + if (prequantized && (qact_in == nullptr || ascale_in == nullptr)) { + throw std::invalid_argument("moe_gemm_w4a8: pre-quantized activations need both qact and ascale"); + } + if (!prequantized && activations == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: null activations"); + } + + const bool fused_reduce = row_to_token != nullptr || routing_weights != nullptr || fused_out != nullptr; + if (fused_reduce && (row_to_token == nullptr || routing_weights == nullptr || fused_out == nullptr || + fused_batch <= 0)) { + throw std::invalid_argument( + "moe_gemm_w4a8: the fused top-k reduction needs row_to_token, routing_weights, a zeroed [batch, N] " + "fp32 output and batch > 0"); + } + if (!fused_reduce && outputs == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: null outputs"); + } + + const int blocksize = rescale_block_size; + const int blks = K / blocksize; + + const bool use_decode = + phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); + + if (fused_reduce && use_decode) { + throw std::invalid_argument("moe_gemm_w4a8: the fused top-k reduction is prefill-only"); + } + + const int8_t* qact = static_cast(qact_in); + const float* ascale = ascale_in; + int8_t* qact_scratch = nullptr; + float* ascale_scratch = nullptr; + + if (!prequantized) { + // Quantized activations + per-token scales share one slab: `[T, K]` int8 + // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because + // K is a multiple of 64). + const size_t qact_bytes = static_cast(total_tokens) * static_cast(K); + const size_t scale_offset = (qact_bytes + sizeof(float) - 1) / sizeof(float) * sizeof(float); + const size_t slab_bytes = scale_offset + static_cast(total_tokens) * sizeof(float); + uint8_t* slab = moe_w4a8::acquire_qact_scratch(q, slab_bytes); + qact_scratch = reinterpret_cast(slab); + ascale_scratch = reinterpret_cast(slab + scale_offset); + qact = qact_scratch; + ascale = ascale_scratch; + } + + // Decode consumes `expert_id_per_token`; the activation-quant kernel already + // runs one sub-group per token, so it derives the map as well instead of + // paying for a second launch (`fill_expert_id_per_token`) on a timeline where + // one call is issued per generated token. Prefill passes nullptr and the scan + // is not compiled into the work. With pre-quantized activations that kernel + // does not run at all, so decode falls back to the standalone scan. + int* expert_map = nullptr; + if (use_decode) { + expert_map = moe_w4a8::acquire_expert_map_scratch(q, static_cast(total_tokens) * sizeof(int)); + } + + if (prequantized) { + if (use_decode) { + moe_decode_detail::fill_expert_id_per_token(q, expert_map, num_tokens_per_expert, num_experts, + total_tokens); + } + } else if (act_dtype == BTLA_DTYPE::F16) { + moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, + ascale_scratch, total_tokens, K, expert_map, + num_tokens_per_expert, num_experts); + } else { + using BF = sycl::ext::oneapi::bfloat16; + moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, ascale_scratch, + total_tokens, K, expert_map, num_tokens_per_expert, num_experts); + } + + const auto* weights = static_cast(weights_s8); + const auto* wscale = static_cast(wscales); + + if (use_decode) { + if (act_dtype == BTLA_DTYPE::F16) { + moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, + static_cast(outputs), expert_map, + total_tokens, N, K, blocksize, blks); + } else { + using BF = sycl::ext::oneapi::bfloat16; + moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), + expert_map, total_tokens, N, K, blocksize, blks); + } + return; + } + + moe_w4a8::MoEFusedReduce reduce{}; + if (fused_reduce) { + reduce.row_to_token = row_to_token; + reduce.row_weight = routing_weights; + reduce.out = fused_out; + reduce.batch = fused_batch; + } + + if (act_dtype == BTLA_DTYPE::F16) { + moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, + static_cast(outputs), num_tokens_per_expert, + num_experts, N, K, blocksize, blks, total_tokens, reduce); + } else { + using BF = sycl::ext::oneapi::bfloat16; + moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), + num_tokens_per_expert, num_experts, N, K, blocksize, blks, + total_tokens, reduce); + } +} + +// Resolve the effective AUTO_S8 block size (host helper, also exported to +// Python so callers can size the `wscales` tensor consistently). +inline int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size) { + return moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); +} + +// Free the W4A8 activation-quantization / expert-map scratch slabs. +inline void moe_w4a8_release_scratch() { moe_w4a8::moe_w4a8_release_scratch(); } + +} // namespace moe_w4a8_detail + +} // namespace ark + +#endif // ARK_XPU && ARK_SYCL_TLA diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index f893e0b0da..eea17b75e8 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1050,6 +1050,25 @@ The decode K-split mapping additionally needs a re-scale block of at least 256 that is a multiple of 16; shapes that miss it use the original GEMV instead of failing. +## Source layout + +The path is split across three headers by cutlass dependency, so that no single +translation unit compiles more than a handful of kernels: + +| Header | Contents | Needs CuTe | +| --- | --- | --- | +| `sycl_tla_moe_w4a8_helpers.hpp` | Scratch pools, host helpers, the prefill tile ladder, the four public entry points | no | +| `sycl_tla_moe_w4a8_kernels.hpp` | Activation quantization, AUTO_S8 prepack, decode GEMV and its K-split variants | no | +| `sycl_tla_moe_w4a8.hpp` | DPAS tile policies, the grouped prefill GEMM, its launcher | yes | + +`sycl_tla_generation.cmake` then emits 19 translation units instead of one: a +dispatcher that only sees the helpers, twelve prefill TUs (one per dtype x tile, +one DPAS kernel each), and six cutlass-free TUs for decode, activation quant and +prepack (one per dtype). Before the split a single TU instantiated all 52 +kernels and peaked at ~4.2 GB of compiler RSS; the layout mirrors how +`sycl_tla_moe_prefill_s4_*.cpp` splits the S4 prefill. Nothing about the runtime +API or the dispatch decisions changes. + ## Status The W4A8 kernel is a new SYCL/CuTe port, marked diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 12e23eb743..6e26ef622b 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -927,6 +927,24 @@ Qwen3-MoE 的两个 GEMM 都满足以上条件 (`K = 2048` 和 `K = 768`)。 decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 的倍数;不满足的形 状会退回到原 GEMV,而不是报错。 +## 源码结构 + +整条路径按对 cutlass 的依赖拆成三个头文件,使得任何一个翻译单元都不会实例化过多 +kernel: + +| 头文件 | 内容 | 需要 CuTe | +| --- | --- | --- | +| `sycl_tla_moe_w4a8_helpers.hpp` | scratch 池、host 辅助函数、prefill tile 阶梯、四个对外入口 | 否 | +| `sycl_tla_moe_w4a8_kernels.hpp` | 激活量化、AUTO_S8 prepack、decode GEMV 及其 K-split 变体 | 否 | +| `sycl_tla_moe_w4a8.hpp` | DPAS tile policy、分组 prefill GEMM 及其 launcher | 是 | + +`sycl_tla_generation.cmake` 据此生成 19 个翻译单元而不是一个:一个只看到 helpers 的 +dispatcher,十二个 prefill 翻译单元(每个 dtype x tile 一个,各含一个 DPAS kernel), +以及六个完全不依赖 cutlass 的翻译单元,分别对应 decode、激活量化和 prepack(每个 +dtype 一个)。拆分之前,单个翻译单元要实例化全部 52 个 kernel,编译器 RSS 峰值约 +4.2 GB;这里的拆法与 `sycl_tla_moe_prefill_s4_*.cpp` 拆分 S4 prefill 的方式一致。 +运行时 API 与各项 dispatch 决策均不受影响。 + ## 状态 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 From dcf4b24c644f672651d0ec1959c6ff441d4b53cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:08:03 +0000 Subject: [PATCH 087/112] chore: drop stray .orig_moe_w4a8.hpp scratch backup Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .orig_moe_w4a8.hpp | 2353 -------------------------------------------- 1 file changed, 2353 deletions(-) delete mode 100644 .orig_moe_w4a8.hpp diff --git a/.orig_moe_w4a8.hpp b/.orig_moe_w4a8.hpp deleted file mode 100644 index 3fb951fc58..0000000000 --- a/.orig_moe_w4a8.hpp +++ /dev/null @@ -1,2353 +0,0 @@ -// SYCL MoE W4A8 -- INT4 weights / INT8 compute (prefill + decode) -// -// STATUS: PARTIALLY HARDWARE-VALIDATED -- every perf sweep in -// `test_moe_w4a8_perf.py` (`test_perf_prefill_tile_sweep`, -// `test_perf_prefill_tile_sweep_long_seq`, `test_perf_prefill_act_quant_- -// sweep`, `..._unroll_sweep`, `..._single_pass_sweep`, -// `test_perf_prefill_store_sweep`, `test_perf_prefill_epilogue_sweep`, -// `test_perf_decode_config_sweep`) and -// every cross-configuration equivalence test (`test_act_quant_vec_matches_- -// scalar`, `test_act_quant_unroll_matches`, `test_act_quant_single_pass_- -// matches`, `test_full_tile_epilogue_matches_predicated`, -// `test_prefill_2d_store_matches_scalar`, `test_decode_ksplit_matches_legacy`) -// has been run on an Intel Arc Pro B60 -// (Battlemage, BMG-G21 -- 20 Xe2 cores / 160 XVEs at ~2.4 GHz, ~197 int8 TOPS, -// 24 GB GDDR6 at 456 GB/s), so both phases compile and run and **every** -// dispatch default -- tile ladder, activation-quant message width / unroll / -// single-pass, interior-tile epilogue, 2D block store, decode CH / NCOLS -- -// comes from those measurements at the compute-bound batch (384 rows/expert), -// with the tile ladder measured at the 8K-prompt routing (512 / 341 -// rows/expert) as well, and each configuration checked numerically against the -// others before it was -// timed. The accuracy gates against the fp32 reference still need a device -// run. The authoring environment has no XPU and no SYCL compiler, so anything -// added *since* that run follows the porting conventions of its siblings -// `sycl_tla_moe_prefill_int_dpas.hpp` / -// `sycl_tla_moe_prefill_fp8_dpas.hpp`; nothing is currently in that state. -// --------------------------------------------------------------------------- -// -// What this file implements -// ------------------------- -// A W4A8 MoE path: **weights are stored as int4** (auto-round's packed -// `[E, N, K/2]` nibble layout with `[E, N, K/group_size]` per-group scales), -// **the DPAS compute dtype is int8**, and **activations are dynamically -// quantized to int8** (per-token absmax) on the fly. -// -// It covers both MoE phases: -// * prefill -- persistent grouped GEMM over experts, `XE_DPAS_TT<8, int32_t, -// int8_t, int8_t>` (`s8 x s8 -> s32`), modelled on the W4A8 weight-only -// GEMM in `sycl_tla_s8_gemm.hpp` (`sycl_tla_igemm_s8s8_dequant`) and the -// grouped scheduler in `sycl_tla_moe_prefill_int_dpas.hpp`. -// * decode -- int8 GEMV. The default mapping splits K across the sub-group -// lanes (coalesced 256-byte weight reads, `NCOLS` output columns per -// sub-group), mirroring `moe_decode_detail::launch_fp8_ksplit`; -// `ARK_MOE_W4A8_DECODE_KSPLIT=0` restores the original one-lane-per-output -// mapping modelled on `moe_decode_detail::launch_int8`. -// -// The AUTO_S8 re-scale trick -// -------------------------- -// ARK's weight-only GEMM has an `AUTO_S8` option (`ARK_AUTO_S8` / -// `env_params::auto_s8`, see `xpu_wrapper.hpp`): rather than feeding the int8 -// mainloop a per-K-group scale (which forces a partial-accumulator fold at -// every group boundary), it *re-scales* the int4 weights into int8 with a -// coarser block size -- typically `group=-1`, i.e. one scale per output -// channel spanning the whole K axis. The int8 GEMM then runs a single -// full-K int32 accumulation with one scalar multiply in the epilogue, which -// is the most efficient shape for DPAS. -// -// The conversion is exactly the one `packscale` + `unpackq(S8, ...)` perform -// in `xpu_wrapper.hpp`: -// -// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * fullrange / 127 -// w8[e][n][k] = round(w4[e][n][k] * s[e][n][k/group_size] / sxt[e][n][j]) -// -// with `fullrange = 2^(bits-1) = 8` for int4. Because `|w4| <= 8` and -// `s <= sxt * 127 / 8` inside the block, `|w8| <= 127`: the re-scaled weight -// always fits in int8 without clipping, and the dequantized value -// `w8 * sxt` reproduces `w4 * s` up to the int8 rounding step. -// -// The block size is `rescale_group_size` (`-1` / `K` == per output channel == -// the `group=-1` maximum-efficiency case). It can be overridden per-process -// with `ARK_MOE_W4A8_AUTO_S8` (`-1` or a multiple of both `group_size` and 64 -// that divides K). Any invalid value falls back to per-channel. -// -// Because the conversion only depends on the checkpoint it is exposed as a -// separate one-shot entry point (`moe_w4a8_prepack`) so callers can run it at -// load time and keep the int8 weights + FP32 block scales resident, instead of -// paying for it on every forward. -// -// Numerics -// -------- -// out[t][n] = (Σ_j sxt[e][n][j] * Σ_{k in block j} qa[t][k] * w8[e][n][k]) -// * sa[t] -// with `qa = round(a / sa)`, `sa = max_k |a[t][k]| / 127`. The activation -// scale is per token (row), the weight scale is per (output channel, block), -// mirroring `sycl_tla_igemm_s8s8_dequant`'s `scale_a[row] * scale_b[col]` -// epilogue. -// -// Layout convention (identical to `moe_gemm_decode` / `moe_gemm_prefill`) -// ---------------------------------------------------------------------- -// activations : [total_tokens, K] act dtype (tokens pre-sorted by expert) -// weights_s4 : [E, N, K/2] uint8, two nibbles per byte (sym) -// scales : [E, N, K/group_size] act dtype -// weights_s8 : [E, N, K] int8 (prepack output) -// wscales : [E, N, K/rescale_block] float (prepack output) -// outputs : [total_tokens, N] act dtype -// -// Copyright (C) 2026 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef ARK_XPU -#include -#endif - -#if defined(ARK_XPU) && defined(ARK_SYCL_TLA) - -// Pulls in the cutlass-sycl / CuTe include set, the `dpas_policy_base` policy -// root, `make_moe_tensor`, and `get_atomic_scratch_buffer` (via the FP8 -// header it includes). -#include "sycl_tla_moe_prefill_int_dpas.hpp" -// `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE`, and -// the shared nibble decoders. -#include "sycl_tla_moe_decode.hpp" - -namespace ark { -namespace moe_w4a8 { - -using namespace cute; - -using moe_decode_detail::N_TILE; -using moe_decode_detail::SG_SIZE; -using moe_dequant::decode_int4_octet; - -// Symmetric int4 full range: 2^(bits-1). Matches `fullrange` in -// `xpu_wrapper.hpp`'s `packscale` rescale kernel. -constexpr float kInt4FullRange = 8.0f; -constexpr float kInt8Max = 127.0f; - -// K elements decoded per work-item in the prepack kernel (one 32-bit word of -// packed nibbles). Requires `K % 8 == 0`, which the shape gate enforces. -constexpr int kPrepackOctet = 8; - -// --------------------------------------------------------------------------- -// Kernel name tags (one per specialization, required for SYCL kernel naming) -// --------------------------------------------------------------------------- -template -class MoEW4A8ActQuant; - -template -class MoEW4A8ActQuantVec; - -template -class MoEW4A8ActQuantSingle; - -template -class MoEW4A8ScaleReduce; - -template -class MoEW4A8Repack; - -template -class MoEW4A8DecodeGemv; - -template -class MoEW4A8DecodeKSplit; - -template -class MoEW4A8GemmName; - -// --------------------------------------------------------------------------- -// Scratch pools. -// -// The activation-quantization buffers (`[total_tokens, K]` int8 + -// `[total_tokens]` fp32) and the decode expert map (`[total_tokens]` int32) -// are recomputed on every call, so they come from persistent slabs instead of -// a hot-path `malloc_device`. -// -// The slabs are served from the extension-wide `DeviceMemoryPool`, which keys -// on the device UUID rather than on a `sycl::queue*`: a slab therefore follows -// the device and is immune to the caller destroying its queue and to a later -// queue landing on the same address. This mirrors how the int4 decode scratch -// is managed in `sycl_tla_moe_decode_scratch.{hpp,cpp}`. -// -// Slabs are intentionally never freed from a static destructor -- the SYCL -// context may already be torn down by then. `moe_w4a8_release_scratch` -// provides the explicit teardown (exposed to Python under the same name). -// -// Sharing one slab per device means these entry points must not be driven -// concurrently from two queues on one device, which matches every other -// `DeviceMemoryPool` slot. -// --------------------------------------------------------------------------- - -// `DeviceMemoryPool` slots owned by the W4A8 path. Slots 0-7 belong to the -// dnnl / xpu / sycl-s8 / cpu wrappers and the SDPA kernels, slot 8 to the DPAS -// work-group counter, and slots 9-10 to the int4 decode scratch. -inline constexpr size_t kW4A8QactScratchLoc = 11; -inline constexpr size_t kW4A8ExpertMapScratchLoc = 12; - -struct W4A8ScratchState { - std::mutex mu; - // Device key (`DeviceMemoryPool::get_device_key`) -> a queue handle on that - // device, held *by value*: a `sycl::queue` is a reference-counted handle, so - // keeping a copy guarantees the queue outlives the memory allocated against - // it. - std::map queues; -}; - -// Intentionally leaked, see above. -inline W4A8ScratchState& w4a8_scratch_state() { - static W4A8ScratchState* s = new W4A8ScratchState(); - return *s; -} - -// Acquire a slab from the shared pool, synchronizing first when the request -// grows it: `DeviceMemoryPool` frees the old pointer in place when it grows a -// slot, and in-flight kernels may still be reading the old slab, so the wait -// has to happen before the call rather than after. -// -// The caller must hold `W4A8ScratchState::mu`. -inline void* acquire_w4a8_slab(sycl::queue* q, size_t bytes, size_t buf_loc) { - auto* pool = DeviceMemoryPool::Instance(); - const size_t held = pool->get_scratch_size(buf_loc, q); - if (held != 0 && held < bytes) { - q->wait(); - } - void* ptr = pool->get_scratch_mem(bytes, buf_loc, q); - if (ptr == nullptr) { - // The pool records the slot before checking the result, so a failed - // allocation leaves a {bytes, nullptr} entry behind that would satisfy - // every later request of this size or smaller without ever retrying. - // Drop it so the next call allocates again. - pool->detach_scratch_mem(buf_loc, q); - throw std::runtime_error("moe_gemm_w4a8: failed to allocate device scratch buffer"); - } - auto& st = w4a8_scratch_state(); - const size_t key = pool->get_device_key(q); - if (st.queues.find(key) == st.queues.end()) { - st.queues.emplace(key, *q); - } - return ptr; -} - -// Quantized activations + per-token scales. -inline uint8_t* acquire_qact_scratch(sycl::queue* q, size_t bytes) { - if (q == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); - } - if (bytes == 0) return nullptr; - auto& st = w4a8_scratch_state(); - std::lock_guard lock(st.mu); - return static_cast(acquire_w4a8_slab(q, bytes, kW4A8QactScratchLoc)); -} - -// Decode expert map (`[total_tokens]` int32). -inline int* acquire_expert_map_scratch(sycl::queue* q, size_t bytes) { - if (q == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); - } - if (bytes == 0) return nullptr; - auto& st = w4a8_scratch_state(); - std::lock_guard lock(st.mu); - return static_cast(acquire_w4a8_slab(q, bytes, kW4A8ExpertMapScratchLoc)); -} - -// --------------------------------------------------------------------------- -// Per-token dynamic activation quantization: act dtype -> int8 + fp32 scale. -// -// One sub-group per token: lanes stride the K axis (coalesced), reduce the -// absmax with `reduce_over_group`, then write back the quantized row. A row -// that is entirely zero gets `scale = 0` and quantizes to all zeros (the -// reciprocal is forced to 0 instead of inf). -// -// The decode path also needs `expert_id_per_token`, which -// `moe_decode_detail::fill_expert_id_per_token` produces in a kernel of its -// own. That kernel does one tiny scan per token, so at decode sizes it is pure -// launch overhead on a timeline where the GEMV itself is only tens of -// microseconds and one call is issued per generated token. This kernel already -// runs one sub-group per token, so when `expert_id_per_token != nullptr` lane 0 -// folds the same scan in and the separate launch disappears -- the same "one -// fewer kernel launch on the decode timeline" the FP8 DPAS decode dispatch -// gets by consuming `num_tokens_per_expert` directly. The scan is the verbatim -// body of `fill_expert_id_per_token`, including its clamp to -// `num_experts - 1` for a routing table that sums to less than `total_tokens`. -// -// Message width (the prefill cost that matters) -// --------------------------------------------- -// This kernel is a pure streaming pass -- it reads `[T, K]` activations twice -// (absmax, then quantize) and writes `[T, K]` int8 -- so at prefill sizes it is -// a sizeable fraction of the whole `moe_gemm_w4a8` call, not a preamble. At -// 32768 routed rows and K = 2048 it touches ~200 MB, next to the ~400 MB the -// grouped GEMM streams for the qwen3 up-proj weights. -// -// The scalar mapping below (`k = lane; k < K; k += SG_SIZE`) moves that traffic -// in the *narrowest* messages the sub-group can issue: 16 lanes x one 16-bit -// element is a 32-byte load and 16 lanes x one int8 is a **16-byte** store, i.e. -// a quarter of a cache line per store message. That is the same defect the -// decode GEMV had before the K-split rewrite, and it was worth 1.09-1.93x there. -// -// `launch_act_dynamic_quant_vec` fixes it the same way: each lane owns `VEC` -// *consecutive* elements, so one message covers `SG_SIZE * VEC` contiguous -// elements -- 256 bytes of activations and 128 bytes of int8 at `VEC = 8`. -// Both passes read the same `sycl::vec`, and the second pass re-reads a row the -// first pass just touched, so it is served by the cache rather than DRAM. -// -// `VEC` is chosen from K: 8 when `K % 128 == 0` (every shipped MoE shape -- -// 768 / 1536 / 2048 / 3072), otherwise 4, which the `K % 64 == 0` shape gate -// always satisfies. A misaligned base pointer (never the case for torch -// allocations or the scratch pool) falls back to the scalar kernel, and -// `ARK_MOE_W4A8_ACT_QUANT_VEC=0` forces it for A/B measurement. -// -// `test_perf_prefill_act_quant_sweep` on BMG (384 rows/expert, bf16 act) puts -// the widened messages at 1.12x (qwen3 up), 1.10x (qwen3 down), 1.15x (minimax -// up) and 1.07x (minimax down) of the scalar mapping on the *whole* -// `moe_gemm_w4a8` call -- the quantization pass alone is a larger share of -// prefill than that, since the GEMM around it is unchanged. (Earlier runs of -// the same sweep read 1.05 / 1.13 / 1.11 / 1.04, 1.13 / 1.14 / 1.12 / 1.04 and -// 1.14 / 1.07 / 1.10 / 1.07: the ranking is stable, the individual ratios move -// by a few percent between runs.) -// -// `sycl::vec` is used rather than `sycl::vec` -// because `sycl::vec` of `bfloat16` is not universally available; the elements -// are `bit_cast` back one at a time, exactly like the decode kernels' -// `ActVec` loads in `sycl_tla_moe_decode.hpp`. -// -// Requests in flight (the cost widening the messages did not address) -// ------------------------------------------------------------------- -// Wide messages fix how many bytes each *request* moves; they do not change -// how many requests a work-item has outstanding. This kernel walks K with a -// runtime trip count (`steps = K / (SG_SIZE * VEC)`) and folds every vector -// into the same `local_max` accumulator, so the loop reads as: issue one load, -// stall until it returns, `fmax` it, repeat. Xe cores execute in order and -// `fmax` is not reassociated without fast-math, so each thread keeps roughly -// *one* 256-byte load in flight. -// -// That is a Little's-law problem, not a bandwidth one: 1280 concurrent -// sub-groups (the B60's occupancy ceiling -- 160 XVEs x 8 thread slots) x 256 -// bytes is ~320 KB of in-flight reads, under the ~456 KB a 456 GB/s device -// needs to stay busy across a ~1 us memory latency, and a real launch rarely -// fills every slot. The same argument is why the decode GEMV loads two chunks -// per iteration (`launch_w4a8_decode_ksplit`), and why the pair is spelled out -// there rather than left to the compiler. -// -// `UNROLL` gives the pass the same treatment: each iteration loads `UNROLL` -// *independent* vectors before consuming any of them, and reduces them into -// `UNROLL` separate partial maxima so the loads do not serialize behind the -// accumulator chain either. At the default `UNROLL = 4` a thread holds 1 KB, -// which clears the 456 KB well before every slot is occupied. The quantize -// pass batches its loads the same way, and its stores are already independent. -// `steps % UNROLL` vectors are left to a tail loop -- `K = 768` (qwen3 down) -// gives `steps = 6`, so the tail is real code, not a formality. -// -// Nothing that rounds changes: the per-lane partial reduction is still `fmax` -// over the same values (exact and order-independent, so partial maxima merge -// to the same bits), and every element goes through the same `rint`/`clamp` -// expression. `UNROLL = 1` is the previous kernel instruction for instruction, -// so `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` is an exact A/B baseline; -// `test_act_quant_unroll_matches` asserts every depth is bit-identical and -// `test_perf_prefill_act_quant_unroll_sweep` times them. -// -// That sweep keeps the default, though not by much: only minimax up is a real -// A/B in it (the other three shapes take the single-pass kernel below, where -// `UNROLL` is dead code, so their rows are three sets of identical kernels -- -// a useful noise probe, spreading 3.3-7.3% in the latest run and 0.4-3.9% in -// the one before). On that shape the three depths read 6.982 ms at 1, -// 6.795 ms at 2 and 6.837 ms at 4: the batched loads are worth 1.02-1.03x over -// `UNROLL = 1`, and the 0.6% between 2 and 4 is an order of magnitude inside -// the noise the identical-kernel rows show, so the default stays at 4 (the -// earlier run had it 8.959 / 8.967 / 9.139 ms, i.e. 4 fastest). -// -// Reading the row once (the traffic the two passes duplicate) -// ----------------------------------------------------------- -// Batching the loads did not change how many there are. The absmax has to see -// the whole row before the first element can be quantized, so the kernel reads -// `[T, K]`, reduces, then reads `[T, K]` again -- and at 384 rows per expert -// the activation matrix is 1.5 MB for K = 2048, against 3.1 MB of weights for -// the whole GEMM. The re-read is L2-resident when the row is still there, but -// the rows a work-group quantizes second are evicted by the ones it quantized -// first well before the pass ends: at 8 MB of L2 and 4 KB per bf16 row of -// K = 2048, only ~2000 of 2048 tokens' rows fit *if nothing else is resident*, -// and the GEMM's weights are competing for the same cache immediately after. -// -// A row is small enough to keep in registers instead: a lane owns `K / 16` -// elements, so `K = 2048` is 256 bytes -- 64 of the 128 dwords per lane the -// quantizer gets (it launches without `grf_size<256>`, unlike the GEMM). Load -// the row once, reduce it, then quantize out of the registers. The second read -// disappears, and every load is issued before any of them is consumed, which -// subsumes what `UNROLL` was doing (`UNROLL = steps`, effectively) rather than -// competing with it. -// -// `MAX_STEPS` is the compile-time cap that makes the fragment a register array -// rather than scratch: the loop is `#pragma unroll` over `MAX_STEPS` with an -// `if (s < steps)` guard, so every index is a constant and SROA can promote it. -// Two rungs are instantiated -- 8 vectors (K <= 1024 at VEC = 8, 32 dwords) and -// 16 (K <= 2048, 64 dwords) -- and anything longer keeps the two-pass kernel, -// which is why minimax's K = 3072 up-projection still takes the old path. The -// partial maxima stay at four accumulators, as in the two-pass kernel, so the -// reduction chain is unchanged in both cost and value. -// -// This was a register-pressure gamble -- if 64 dwords of row plus addressing -// spilled, the pass would get slower, not faster -- and the sweep settled it in -// its favour: at 384 rows/expert the single-pass kernel is 1.06x (qwen3 down, -// K = 768), 1.04x (qwen3 up, K = 2048, the rung filled exactly) and 1.02x -// (minimax down, K = 1536) against the two-pass one. Nothing spills. minimax -// up (K = 3072) is past the last rung, so both of its rows run the *same* -// two-pass kernel and their 1.01x is this sweep's noise probe. -// `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` still restores the two-pass -// kernel exactly, `test_perf_prefill_act_quant_single_pass_sweep` times the -// pair, and `test_act_quant_single_pass_matches` asserts they agree bit for bit -// (same `fmax` set, same `inv`, same `rint`/`clamp`). -// --------------------------------------------------------------------------- - -// Fold the per-token expert scan (decode only) into the quantization kernel. -// Verbatim body of `moe_decode_detail::fill_expert_id_per_token`. -inline void act_quant_write_scale(float* ascale, int token, float scale, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - ascale[token] = scale; - if (expert_id_per_token == nullptr) return; - int offset = 0; - int expert = num_experts - 1; - for (int e = 0; e < num_experts; ++e) { - const int n = num_tokens_per_expert[e]; - if (token < offset + n) { - expert = e; - break; - } - offset += n; - } - expert_id_per_token[token] = expert; -} - -template -void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, - int total_tokens, int K, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); - static_assert(UNROLL >= 1 && (UNROLL & (UNROLL - 1)) == 0, "UNROLL must be a power of two"); - using ActVec = sycl::vec; - using QVec = sycl::vec; - - // Vectors a lane walks over. `K % (SG_SIZE * VEC) == 0` is checked by the - // caller, so the loop needs no tail -- but `steps` need not be a multiple of - // `UNROLL` (K = 768 gives 6 vectors at VEC = 8), hence the second loop. - const int steps = K / (SG_SIZE * VEC); - const int main_steps = steps - (steps % UNROLL); - - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int lane = static_cast(it.get_local_id(1)); - const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); - QVec* out = reinterpret_cast(qact + static_cast(token) * K); - - // One partial maximum per unrolled slot: `fmax` is exact, so merging - // them below gives the same absmax as a single chain, but the loads no - // longer wait on it. - float part_max[UNROLL]; -#pragma unroll - for (int u = 0; u < UNROLL; ++u) part_max[u] = 0.0f; - - for (int s = 0; s < main_steps; s += UNROLL) { - ActVec v[UNROLL]; -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { - v[u] = row[static_cast(s + u) * SG_SIZE + lane]; - } -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); - part_max[u] = sycl::fmax(part_max[u], sycl::fabs(static_cast(a))); - } - } - } - for (int s = main_steps; s < steps; ++s) { - const ActVec v = row[static_cast(s) * SG_SIZE + lane]; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[e])); - part_max[0] = sycl::fmax(part_max[0], sycl::fabs(static_cast(a))); - } - } - - float local_max = part_max[0]; -#pragma unroll - for (int u = 1; u < UNROLL; ++u) local_max = sycl::fmax(local_max, part_max[u]); - - auto sg = it.get_sub_group(); - const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); - - const float scale = absmax / kInt8Max; - const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; - if (lane == 0) { - act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); - } - - // Same batching on the way back: the re-read of a row the first pass - // just touched is served by the cache, but only if enough of it is - // requested at once. - for (int s = 0; s < main_steps; s += UNROLL) { - ActVec v[UNROLL]; -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { - v[u] = row[static_cast(s + u) * SG_SIZE + lane]; - } -#pragma unroll - for (int u = 0; u < UNROLL; ++u) { - QVec qv; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[u][e])); - const float x = sycl::rint(static_cast(a) * inv); - qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); - } - out[static_cast(s + u) * SG_SIZE + lane] = qv; - } - } - for (int s = main_steps; s < steps; ++s) { - const ActVec v = row[static_cast(s) * SG_SIZE + lane]; - QVec qv; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[e])); - const float x = sycl::rint(static_cast(a) * inv); - qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); - } - out[static_cast(s) * SG_SIZE + lane] = qv; - } - }); -} - -// Vectors a lane loads before it consumes any of them. `4` covers every -// shipped shape's `steps` (6 / 12 / 16 / 24 at VEC = 8) with at most a -// two-vector tail. `ARK_MOE_W4A8_ACT_QUANT_UNROLL` selects 1 (the previous -// kernel), 2 or 4; anything else falls back to the default. -constexpr int kActQuantUnrollDefault = 4; - -// Partial maxima the single-pass kernel reduces into, matching the two-pass -// kernel's default `UNROLL` so the two produce the same value bit for bit. -constexpr int kActQuantSinglePartials = 4; - -// Longest row a lane keeps in registers, in vectors. 16 vectors is 64 dwords -// per lane at VEC = 8 (K = 2048), half the default 128-dword budget; longer -// rows take the two-pass kernel rather than risk a spill. -constexpr int kActQuantSingleMaxSteps = 16; - -// Single-pass variant: the row is loaded once into registers, reduced, then -// quantized out of them. `MAX_STEPS` bounds the register array at compile time -// (see the design note above); `steps <= MAX_STEPS` is the caller's contract. -template -void launch_act_dynamic_quant_vec_single(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, - int total_tokens, int K, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - static_assert(VEC == 4 || VEC == 8, "VEC must be 4 or 8"); - static_assert(MAX_STEPS >= kActQuantSinglePartials, "MAX_STEPS must cover the partial accumulators"); - using ActVec = sycl::vec; - using QVec = sycl::vec; - - const int steps = K / (SG_SIZE * VEC); - - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int lane = static_cast(it.get_local_id(1)); - const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); - QVec* out = reinterpret_cast(qact + static_cast(token) * K); - - // The whole row, one load per slot, every one issued before the first - // is consumed. Constant indices under the unroll keep it in registers. - ActVec v[MAX_STEPS]; -#pragma unroll - for (int s = 0; s < MAX_STEPS; ++s) { - if (s < steps) { - v[s] = row[static_cast(s) * SG_SIZE + lane]; - } - } - - float part_max[kActQuantSinglePartials]; -#pragma unroll - for (int u = 0; u < kActQuantSinglePartials; ++u) part_max[u] = 0.0f; - -#pragma unroll - for (int s = 0; s < MAX_STEPS; ++s) { - if (s < steps) { -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); - part_max[s % kActQuantSinglePartials] = - sycl::fmax(part_max[s % kActQuantSinglePartials], sycl::fabs(static_cast(a))); - } - } - } - - float local_max = part_max[0]; -#pragma unroll - for (int u = 1; u < kActQuantSinglePartials; ++u) local_max = sycl::fmax(local_max, part_max[u]); - - auto sg = it.get_sub_group(); - const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); - - const float scale = absmax / kInt8Max; - const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; - if (lane == 0) { - act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); - } - - // No second read of the row: it is already here. -#pragma unroll - for (int s = 0; s < MAX_STEPS; ++s) { - if (s < steps) { - QVec qv; -#pragma unroll - for (int e = 0; e < VEC; ++e) { - const ScalarT a = sycl::bit_cast(static_cast(v[s][e])); - const float x = sycl::rint(static_cast(a) * inv); - qv[e] = static_cast(sycl::clamp(x, -kInt8Max, kInt8Max)); - } - out[static_cast(s) * SG_SIZE + lane] = qv; - } - } - }); -} - -inline int moe_w4a8_act_quant_unroll() { - const char* env = std::getenv("ARK_MOE_W4A8_ACT_QUANT_UNROLL"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && (v == 1 || v == 2 || v == 4)) return static_cast(v); - } - return kActQuantUnrollDefault; -} - -// How many k-tiles of A and B the prefill mainloop keeps prefetched ahead of -// the tile it is computing. The prologue issues `prefetch_dist` pairs before -// the first DPAS and the loop then issues one pair per tile, so this is the -// depth of the memory pipeline the mainloop runs against -- too shallow and -// the DPAS waits on L2, too deep and the prefetched lines are evicted before -// use (and the prologue itself becomes a serial stall on short K). -// -// 3 is the value the mainloop was written with and the sibling prefill kernels -// use. The shapes here are short in K (12 k-tiles at K = 768), which is exactly -// where the depth is worth re-measuring, so it is a runtime knob rather than a -// constant; `test_perf_prefill_prefetch_sweep` walks it. -inline constexpr int kPrefillPrefetchDefault = 3; - -inline int moe_w4a8_prefill_prefetch_dist() { - const char* env = std::getenv("ARK_MOE_W4A8_PREFILL_PREFETCH"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && v >= 1 && v <= 8) return static_cast(v); - } - return kPrefillPrefetchDefault; -} - -// Runtime unroll depth -> compile-time bridge. -template -void launch_act_dynamic_quant_vec_unroll(int unroll, sycl::queue* q, const ScalarT* activations, int8_t* qact, - float* ascale, int total_tokens, int K, int* expert_id_per_token, - const int* num_tokens_per_expert, int num_experts) { - // Register-resident single pass when the row fits, the two-pass kernel - // otherwise. The smallest rung that covers `steps` is chosen so a short row - // does not reserve registers for slots it never loads. - const int steps = K / (SG_SIZE * VEC); - if (steps <= kActQuantSingleMaxSteps && - moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS", true)) { - if (steps <= kActQuantSingleMaxSteps / 2) { - launch_act_dynamic_quant_vec_single( - q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); - } else { - launch_act_dynamic_quant_vec_single( - q, activations, qact, ascale, total_tokens, K, expert_id_per_token, num_tokens_per_expert, num_experts); - } - return; - } - - if (unroll == 1) { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - } else if (unroll == 2) { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - } else { - launch_act_dynamic_quant_vec(q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - } -} - -template -void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, - int total_tokens, int K, int* expert_id_per_token = nullptr, - const int* num_tokens_per_expert = nullptr, int num_experts = 0) { - static_assert(sizeof(ScalarT) == sizeof(uint16_t), "ScalarT must be a 16-bit floating type"); - if (total_tokens == 0) return; - - // Widest lane chunk this shape and these buffers support. The alignment - // checks never fire for torch allocations or the scratch pool (both are at - // least 256-byte aligned), but a caller-supplied activation view could be - // offset, and an unaligned `sycl::vec` access would be undefined. - if (moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_ACT_QUANT_VEC", true)) { - const auto act_addr = reinterpret_cast(activations); - const auto q_addr = reinterpret_cast(qact); - const int unroll = moe_w4a8_act_quant_unroll(); - if (K % (SG_SIZE * 8) == 0 && act_addr % (8 * sizeof(ScalarT)) == 0 && q_addr % 8 == 0) { - launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - return; - } - if (K % (SG_SIZE * 4) == 0 && act_addr % (4 * sizeof(ScalarT)) == 0 && q_addr % 4 == 0) { - launch_act_dynamic_quant_vec_unroll(unroll, q, activations, qact, ascale, total_tokens, K, - expert_id_per_token, num_tokens_per_expert, num_experts); - return; - } - } - - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int lane = static_cast(it.get_local_id(1)); - const ScalarT* row = activations + static_cast(token) * K; - int8_t* out = qact + static_cast(token) * K; - - float local_max = 0.0f; - for (int k = lane; k < K; k += SG_SIZE) { - local_max = sycl::fmax(local_max, sycl::fabs(static_cast(row[k]))); - } - auto sg = it.get_sub_group(); - const float absmax = sycl::reduce_over_group(sg, local_max, sycl::maximum{}); - - const float scale = absmax / kInt8Max; - const float inv = absmax > 0.0f ? kInt8Max / absmax : 0.0f; - if (lane == 0) { - act_quant_write_scale(ascale, token, scale, expert_id_per_token, num_tokens_per_expert, num_experts); - } - - for (int k = lane; k < K; k += SG_SIZE) { - const float v = sycl::rint(static_cast(row[k]) * inv); - out[k] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); - } - }); -} - -// --------------------------------------------------------------------------- -// AUTO_S8 stage 1: per-(expert, output channel, block) re-scale factor. -// -// sxt[e][n][j] = max_{g in block j} |s[e][n][g]| * (8 / 127) -// -// Verbatim port of the rescale branch of `packscale` in `xpu_wrapper.hpp` -// (with `fullrange = 8` for int4). An all-zero block yields `sxt = 0`; stage 2 -// turns that into all-zero int8 weights, so the (equally zero) product is -// still exact. -// --------------------------------------------------------------------------- -template -void launch_weight_scale_reduce(sycl::queue* q, const ScalarT* scales, float* wscale_out, int E, int N, int K, - int group_size, int rescale_block, int nblk) { - const int groups_k = K / group_size; - const int groups_per_block = rescale_block / group_size; - - q->parallel_for>( - sycl::range<1>(static_cast(E) * static_cast(N) * static_cast(nblk)), - [=](sycl::id<1> id) { - const size_t idx = id[0]; - const int blk = static_cast(idx % static_cast(nblk)); - const size_t row = idx / static_cast(nblk); // e * N + n - const ScalarT* s_row = - scales + row * static_cast(groups_k) + static_cast(blk) * groups_per_block; - - float absmax = 0.0f; - for (int g = 0; g < groups_per_block; ++g) { - absmax = sycl::fmax(absmax, sycl::fabs(static_cast(s_row[g]))); - } - wscale_out[idx] = absmax * (kInt4FullRange / kInt8Max); - }); -} - -// --------------------------------------------------------------------------- -// AUTO_S8 stage 2: int4 -> int8 re-scale. -// -// w8[k] = round(w4[k] * s[k / group_size] / sxt[k / rescale_block]) -// -// Verbatim port of the `CfgDequantS8Rescale` branch of `unpackq` in -// `xpu_wrapper.hpp`. One work-item decodes one 32-bit word (8 nibbles); the -// shape gate guarantees `group_size % 8 == 0` and `rescale_block % 8 == 0`, so -// all 8 K indices of a word share the same group scale and the same block -// scale and both loads hoist out of the inner loop. -// --------------------------------------------------------------------------- -template -void launch_weight_rescale_s4_to_s8(sycl::queue* q, const uint8_t* weights, const ScalarT* scales, - const float* wscale, int8_t* w8_out, int E, int N, int K, int group_size, - int rescale_block, int nblk) { - const int groups_k = K / group_size; - const int octets = K / kPrepackOctet; - - q->parallel_for>( - sycl::range<2>(static_cast(E) * static_cast(N), static_cast(octets)), - [=](sycl::id<2> id) { - const size_t row = id[0]; // e * N + n - const int oct = static_cast(id[1]); - const int k_base = oct * kPrepackOctet; - - const uint8_t* w_ptr = weights + row * static_cast(K / 2) + static_cast(oct) * 4; - const uint32_t word = *reinterpret_cast(w_ptr); - int q4[kPrepackOctet]; - decode_int4_octet(word, q4); - - const float s = static_cast(scales[row * static_cast(groups_k) + k_base / group_size]); - const float sx = wscale[row * static_cast(nblk) + k_base / rescale_block]; - const float f = sx > 0.0f ? s / sx : 0.0f; - - int8_t* out = w8_out + row * static_cast(K) + k_base; -#pragma unroll - for (int j = 0; j < kPrepackOctet; ++j) { - const float v = sycl::rint(static_cast(q4[j]) * f); - out[j] = static_cast(sycl::clamp(v, -kInt8Max, kInt8Max)); - } - }); -} - -// --------------------------------------------------------------------------- -// Tile policies. -// -// `WGTile`'s K extent is 64 -- the int8 DPAS atom's K granularity, matching -// `sycl_tla_s8_gemm.hpp`'s `Shape, Int, _64>`. The M/N -// shapes and sub-group layouts are the reference GEMM's tile ladder -// (`SmallTileSG` / `SmallMidTileSG` / `MediumTileSG` / `LargeTileSG`), which -// keeps `size(mma)` at 64 / 128 / 256 / 512 threads -- all divisors of the 512 -// threads-per-SM budget the persistent scheduler assumes. -// -// Tile shape *is* the prefill bandwidth knob. A `TileM x TileN` tile reads its -// own A and B slabs, so the bytes a whole expert pulls through L2/DRAM are -// -// M*K * ceil(N/TileN) + N*K * ceil(M/TileM) ~= M*N*K * (1/TileN + 1/TileM) -// -// i.e. A is re-read once per N tile and B once per M tile. Both halvings are -// real, and both are cancelled by *padding*: an expert launches -// `ceil(M/TileM) * ceil(N/TileN)` full tiles, so a `TileM` that does not -// divide the rows/expert pays for rows that do not exist. -// -// `test_perf_prefill_tile_sweep` on BMG at the compute-bound batch the suite -// runs (384 rows/expert, bf16 act), with the 2D block store and the -// single-pass activation quantizer in: -// -// shape auto 128x128 128x256 256x128 256x256 -// qwen3 up 3.540 ms 3.518 ms 3.585 ms 4.404 ms 3.970 ms -// qwen3 down 2.472 ms 2.473 ms 2.432 ms 2.696 ms 2.547 ms -// minimax up 6.976 ms 6.823 ms 6.878 ms 8.899 ms 8.019 ms -// minimax down 6.749 ms 7.227 ms 6.874 ms 9.096 ms 7.823 ms -// -// and `test_perf_prefill_tile_sweep_long_seq`, the same sweep at one 8K prompt -// -- 512 rows/expert on qwen3 (128 experts), 341 on minimax (192): -// -// shape auto 128x128 128x256 256x128 256x256 -// qwen3 up 4.371 ms 4.382 ms 4.393 ms 4.468 ms 4.394 ms -// qwen3 down 3.075 ms 3.030 ms 2.903 ms 3.025 ms 3.059 ms -// minimax up 6.564 ms 6.673 ms 6.449 ms 9.057 ms 7.744 ms -// minimax down 6.466 ms 6.725 ms 6.450 ms 9.373 ms 7.307 ms -// -// M: `TileM = 256` never pays. At 384 and 341 rows/expert it is 1.05-1.45x -// *behind*, and that part is arithmetic rather than a register effect: 384 -// rows take `ceil(384/256) = 2` 256-row tiles -- 512 rows scheduled for 384 -// rows of data, a third of the MACs spent on padding -- against exactly 3 full -// 128-row tiles, and the like-for-like ratio on the long-K shapes, where the -// mainloop dominates, is that padding ratio (512/384 = 1.33) to within noise -// (1.25x qwen3 up, 1.30x minimax up, both at `TileN = 128`). -// The 8K prompt is the case where that argument does *not* apply: 512 rows per -// expert is an exact multiple of 256, so both tiles schedule the same rows. -// The 256-row tile is still not ahead there. Like for like on `TileN` it reads -// -2.0% / 0.0% (qwen3 up at `TileN` 128 / 256) and +0.2% / -5.4% (qwen3 down), -// i.e. never better than a tie and 5.4% behind on the shape with the shortest -// mainloop -- so halving how often B is pulled per M tile buys nothing that the -// larger work-group (512 threads, one per Xe core) does not give back in -// scheduling granularity. The only reading ever in its favour is an older run -// at 256 rows/expert, 1.3-3.9% ahead, inside the noise floor. The ladder -// therefore stops taking it (see `moe_w4a8_prefill_dispatch`): it has no -// measured upside, and a routing skewed around the average the ladder sees -// puts individual experts back on the padding cliff. -// -// N: the 256-wide tile is ahead or level everywhere the tables can compare it -// -- at 384 rows/expert it takes minimax down by 1.05x and qwen3 down by 1.02x -// and is 0.8-1.9% behind on the other two, and at the 8K prompt it takes three -// of four by 3.5-4.4% and ties qwen3 up (0.3%). The 35-50% cliff the first -// sweep saw on every 256-wide N tile is gone -- it was the float C shadow the -// mainloop used to keep live (see `xe_gemm_w4a8`), which doubled the per-lane -// C footprint and made `TileN = 256` ask for the entire 256-register large-GRF -// file -- and what remained of it in the second sweep (0-8% behind on three -// shapes, measured with the *scalar* epilogue store) is gone too, now that a -// 32x64 fragment goes out in a handful of block messages instead of 128 scalar -// ones. So the ladder is 256 wide in N wherever N divides into it. -// -// Noise floor for reading all of this: the `auto` column is not an independent -// measurement -- it launches whichever explicit tile the ladder picks, so each -// row above contains one *duplicate* pair (`128x256` at 384 rows/expert and at -// the minimax 8K point, `256x256` at the qwen3 8K point). Across the eight -// pairs the two readings of the same kernel differ by 0.2-1.9%, which is the -// run-to-run floor for these tables; the padding effect reaches 45%. -// -// Every policy stays reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a -// re-sweep on a device with a different register budget. -// --------------------------------------------------------------------------- -class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { - public: - using WGTile = Shape<_8, _128, _64>; - using SGLayout = Layout, Stride<_0, _1, _0>>; -}; - -class w4a8_policy_m_64 : public moe_dpas_fp8::dpas_policy_base { - public: - using WGTile = Shape<_64, _128, _64>; - using SGLayout = Layout, Stride<_4, _1, _0>>; -}; - -class w4a8_policy_m_128 : public moe_dpas_fp8::dpas_policy_base { - public: - using WGTile = Shape<_128, _128, _64>; - using SGLayout = Layout, Stride<_4, _1, _0>>; -}; - -class w4a8_policy_m_128_n256 : public moe_dpas_fp8::dpas_policy_base { - public: - using WGTile = Shape<_128, _256, _64>; - using SGLayout = Layout, Stride<_4, _1, _0>>; -}; - -class w4a8_policy_m_256_n128 : public moe_dpas_fp8::dpas_policy_base { - public: - using WGTile = Shape<_256, _128, _64>; - using SGLayout = Layout, Stride<_4, _1, _0>>; -}; - -class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { - public: - using WGTile = Shape<_256, _256, _64>; - using SGLayout = Layout, Stride<_4, _1, _0>>; -}; - -// --------------------------------------------------------------------------- -// Optional fused top-k reduction (prefill only). -// -// The grouped GEMM's natural output is `[T, N]`, one row per *routed* row, and -// every caller immediately reduces it: a token's `top_k` rows are scaled by -// their routing weights and summed into one `[batch, N]` row. That reduction -// reads `T*N` and writes `batch*N`, and the GEMM wrote `T*N` for it to read -- -// so the unfused contract moves `2*T*N + batch*N` elements where the fused one -// moves `2*batch*N` (a read-modify-write of the accumulator). -// -// It is the largest lever on the down-projection shapes, where D is a third of -// the call's traffic: at qwen3's routing (`top_k = 8`) it takes D from -// `T*N*sizeof(ElementD)` to `batch*N*4*2`, i.e. 192 MB -> 48 MB at 384 -// rows/expert, and deletes the caller's reduction kernel outright. -// -// The accumulator is fp32 and the caller must zero it: rows of the same token -// land on different experts, hence on different work-groups, so the only -// portable combiner is a device-scope atomic add. That makes the result -// **order-dependent** and therefore not bit-identical to the unfused path -- -// the equivalence test for this contract is an SNR/cosine gate, not -// `torch.equal`. Scaling is applied before the atomic (one multiply per -// element), so the atomic itself stays a plain `fetch_add`. -// -// `out == nullptr` selects the unfused path and compiles to the same code as -// before; the branch is uniform across the work-group (it is a kernel -// argument). -// --------------------------------------------------------------------------- -struct MoEFusedReduce { - const int* row_to_token = nullptr; // routed row -> model token (expert-local base) - const float* row_weight = nullptr; // routed row -> routing weight (expert-local base) - float* out = nullptr; // [batch, N] fp32 accumulator, zeroed by the caller - int batch = 0; // rows of `out`; bounds the scatter - - CUTE_HOST_DEVICE bool enabled() const { return out != nullptr; } -}; - -CUTE_DEVICE inline void atomic_add_f32(float* addr, float value) { - sycl::atomic_ref - ref(*addr); - ref.fetch_add(value); -} - -// --------------------------------------------------------------------------- -// Single-tile int8 x int8 -> int32 mainloop with a per-block weight scale and -// a per-row activation scale. -// -// Structure is `igemm_kblock_device_impl` from `sycl_tla_s8_gemm.hpp` (the -// W4A8 weight-only GEMM), with two changes for the grouped/MoE case: -// * the tile coordinate is passed in by the persistent scheduler instead of -// being derived from the work-item's group id, and -// * A/B/D base pointers are the per-expert slices. -// -// `blks == 1` (the AUTO_S8 `group=-1` default) collapses the outer loop to a -// single full-K int32 accumulation -- the maximum-efficiency shape -- and gets -// its own instantiation, because on this architecture the *register file*, not -// the tile traffic, is what the prefill GEMM runs out of. -// -// Why `blks == 1` is specialized (the register-file argument) -// ----------------------------------------------------------- -// The blocked path needs two C fragments: the int32 DPAS accumulator `tCrC`, -// cleared once per re-scale block, and a float `tFrC` that survives across -// blocks because the per-block weight scale has to be applied before the next -// block overwrites `tCrC`. Both are the size of the work-group tile divided by -// the sub-group count, and a lane holds them in GRF for the *entire* mainloop: -// -// tile SG C fragment int32 regs/lane + float regs/lane -// 128x128 32 x 32 64 64 -// 128x256 32 x 64 128 128 -// -// With `grf_size<256>` a lane has 256 registers in total, so at 128x128 the -// float shadow alone reserves a quarter of the register file for the whole -// mainloop, and at 128x256 the two fragments together *are* the register file -// -- leaving nothing for the staged A/B tiles. That is the measured cliff -// documented in the tile-policy comment above (256-wide N tiles 35-50% slower -// than 128-wide ones, split by `TileN` and not by `TileM`), and it is pure -// overhead when `blks == 1`: with one block there is nothing to carry across -// blocks, so the scale can be folded on the way out and no float fragment -// needs to exist while the mainloop runs. -// -// The single-block epilogue therefore keeps only `tCrC` live and applies -// `scale_b[col] * scale_a[row]` in one pass, exactly like the `AccumBlock == -// false` branch of the reference `igemm_device_impl`. -// -// The epilogue writes through the raw `[m, n]` row-major output pointer using -// the coordinates of `thr_mma.partition_C(...)`, exactly like the reference, -// because the int32 accumulator has to be converted and scaled per element -// anyway. A grouped GEMM's per-expert M is arbitrary, so tiles at the M edge -// are partial and their *store* has to be predicated -- but the scale *loads* -// are not: their indices are clamped into range instead. Both scale reads are -// then unconditional loads at a compile-time offset from a uniform base, which -// is what lets the compiler collapse the `size(tCrC)` per-element reads into -// the handful of distinct addresses a sub-group's fragment actually covers -// (all lanes of a row group share `scale_a[row]`, and a lane repeats the same -// `scale_b[col]` for every row it owns). Under the previous `continue` guard -// each read sat in its own basic block and none of that could be hoisted. -// -// Interior tiles skip the guard entirely (the cost that shows up at small K) -// ------------------------------------------------------------------------- -// `m`, `n`, `m_coord` and `n_coord` are all uniform across the work-group, so -// "does this tile touch the M or N edge" is one uniform compare, not a -// per-element one. Off the edge the clamps and the store predicate are dead -// weight: per fragment element they add two compares plus two selects for the -// scale indices and another compare pair for the store, roughly doubling the -// instruction count of an epilogue whose real work is one int32->float -// convert, two multiplies and one store. -// -// That matters because the epilogue is *not* amortized over a long mainloop at -// these shapes. A 128x128 tile runs `K / 64` k-tiles -- 12 of them for the -// qwen3 down-projection (K = 768) -- while it always writes `TileM * TileN` -// elements, and qwen3 down is exactly the shape the sweep reports furthest -// from the compute target (63 TFLOPS against 87-103 for the other three). The -// fast path emits the same expression in the same order for every element it -// stores, so it is bit-identical to the guarded one -// (`test_full_tile_epilogue_matches_predicated`), and -// `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` forces the guarded path for A/B -// measurement. `test_perf_prefill_epilogue_sweep` at 384 rows/expert has it at -// 1.04x (qwen3 down), 1.03x (qwen3 up) and 1.00-1.01x on the two minimax -// shapes -- the shape ordering the instruction-count argument predicted, with -// the gain concentrated where the mainloop is shortest. -// -// The store itself: one 2D block message instead of `size(tCrC)` scalar ones -// ----------------------------------------------------------------------- -// Removing instructions from around the store left the store. The Xe DPAS C -// fragment gives a lane one *column* of each 8x16 atom, so the 16 lanes of a -// sub-group hold 16 *consecutive columns of one row*: a scalar -// `c[row * n + col] = ...` is a 32-byte message for 16-bit `ElementD`, half a -// cache line, and a 32x32 sub-group fragment issues **64** of them. The same -// bytes go out in 4 messages through the hardware 2D block store, which is -// what every sibling prefill kernel already uses for D -// (`sycl_tla_moe_prefill_{fp8,int,s4}_dpas.hpp`) and what the dense GEMM in -// `sycl_tla_dense_gemm.hpp` uses on this exact accumulator shape. -// -// D is the reason this is worth doing at prefill sizes rather than a tidy-up: -// at 384 rows per expert the qwen3 down-projection writes `M*N` fp16 (1.5 MB -// per expert) -- exactly as many bytes as the `N*K` int8 weights it reads, -// because N (2048) is larger than K (768) there, and over a third of the -// expert's traffic. It is the same shape whose mainloop is shortest, so it -// pays the epilogue twice. -// -// The port follows `dense_gemm_detail::gemm_device_impl` rather than the -// sibling MoE kernels, because those `reorder(tCrC, tCrC_out)` from the MMA -// fragment into an explicitly chosen `XE_STORE_2D` atom's fragment, and -// `reorder` moves *registers*: with a `float` accumulator that is free, but -// this kernel's accumulator is `int32` (`FrgTypeC` of -// `XE_DPAS_TT<8, int32_t, int8_t, int8_t>`) and has to be scaled and -// numerically converted first, which `reorder` does not do. `dense_gemm`'s -// shape is the one that fits: `make_block_2d_copy_D(mma, D)` derives its -// layout from the MMA's own C partition, so the scaled `ElementD` fragment -// (`make_tensor_like(tCrC)`, filled through the same `tCgC(i)` -// coordinates the scalar path uses) can be handed straight to -// `copy(copy_d, tCrD, tCgC)` with no `reorder` in between. -// -// It also *removes* the store predicate rather than skipping it: the 2D block -// message clips to the surface (`m` rows x `n` columns) described by the D -// tensor, so a partial tile at the M edge drops its out-of-range rows in -// hardware -- exactly how the sibling grouped GEMMs handle their ragged -// experts. Only the scale *loads* still need their index clamps, and only on -// edge tiles. The value written is computed by the same expression in the same -// order as the scalar path, so the two are bit-identical -// (`test_prefill_2d_store_matches_scalar`); `ARK_MOE_W4A8_PREFILL_STORE_2D=0` -// restores the scalar store for A/B measurement. `test_perf_prefill_store_- -// sweep` at 384 rows/expert makes it the largest single prefill win of the -// set: 1.14x (qwen3 up), 1.21x (qwen3 down -- the shape that pays the epilogue -// twice), 1.09x (minimax up) and 1.16x (minimax down); the run before read -// 1.16 / 1.35 / 1.12 / 1.20, same ordering. -// -// The block 2D descriptor wants a 64-byte aligned base and a row pitch that is -// a multiple of 16 bytes. The base here is the expert's slice -// `Outputs + pre_rows * N`, with `pre_rows` a runtime routing value, so the -// dispatcher gates on `N * sizeof(ElementD) % 64 == 0` (which makes *every* -// expert's base 64-byte aligned given an aligned tensor) and on the base -// pointer itself; anything else keeps the scalar store. -// --------------------------------------------------------------------------- -template -CUTE_DEVICE void xe_gemm_w4a8(const int8_t* a, const int8_t* b, ElementD* c, const float* scale_a, - const float* scale_b, int m, int n, int k, int blocksize, int blks, int m_coord, - int n_coord, bool allow_full_tile, bool allow_block_2d_store, int prefetch_dist, - MoEFusedReduce const& reduce, TiledMMA const& mma) { - auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); - const int local_id = static_cast(item.get_local_linear_id()); - - auto wg_tile = mma.tile_mnk(); - auto wg_coord = make_coord(m_coord, n_coord, 0); - - // The fused path never writes through `c` (it scatters into `reduce.out` - // instead) and its caller has no `[T, N]` buffer to hand over, so `c` is - // null there. D and its 2D copy atom are still built -- they are ordinary - // objects, not lazily constructed -- so give them a valid base to describe. - ElementD* d_base = c != nullptr ? c : reinterpret_cast(reduce.out); - - auto A = make_tensor(make_gmem_ptr(const_cast(a)), make_shape(m, k), make_stride(k, _1{})); - auto B = make_tensor(make_gmem_ptr(const_cast(b)), make_shape(n, k), make_stride(k, _1{})); - auto D = make_tensor(make_gmem_ptr(d_base), make_shape(m, n), make_stride(n, _1{})); - - Tensor cA = make_identity_tensor(A.shape()); - Tensor cB = make_identity_tensor(B.shape()); - Tensor cC = make_identity_tensor(D.shape()); - - Tensor gA = local_tile(cA, select<0, 2>(wg_tile), make_coord(m_coord, _)); - Tensor gB = local_tile(cB, select<1, 2>(wg_tile), make_coord(n_coord, _)); - Tensor gC = local_tile(cC, wg_tile, wg_coord, Step<_1, _1, X>{}); - - auto copy_a = get_block_2d_copy_A(mma, A); - auto copy_b = get_block_2d_copy_B(mma, B); - auto copy_d = make_block_2d_copy_D(mma, D); - - auto thr_mma = mma.get_slice(local_id); - auto thr_copy_a = copy_a.get_slice(local_id); - auto thr_copy_b = copy_b.get_slice(local_id); - - auto tCrA = thr_mma.partition_sg_fragment_A(gA(_, _, 0)); - auto tCrB = thr_mma.partition_sg_fragment_B(gB(_, _, 0)); - - auto tArA = thr_copy_a.partition_sg_fragment_D(gA(_, _, 0)); - auto tBrB = thr_copy_b.partition_sg_fragment_D(gB(_, _, 0)); - - Tensor tAgA = thr_copy_a.partition_S(gA); - Tensor tBgB = thr_copy_b.partition_S(gB); - - Tensor tCrC = partition_fragment_C(mma, select<0, 1>(wg_tile)); - Tensor tCgC = thr_mma.partition_C(gC); - - auto prefetch_a = make_block_2d_prefetch(copy_a); - auto prefetch_b = make_block_2d_prefetch(copy_b); - - auto pAgA = prefetch_a.get_slice(local_id).partition_S(gA); - auto pBgB = prefetch_b.get_slice(local_id).partition_S(gB); - - constexpr auto barrier_scope = ScopeWorkgroup; - - const int k_tile_size = static_cast(get<2>(wg_tile)); - const int k_tiles_per_block = blocksize / k_tile_size; - const int k_tile_count = blks * k_tiles_per_block; - int k_tile_prefetch = 0; - - // One k-tile of the DPAS pipeline. Shared by both paths so the two - // instantiations differ only in what they keep live around it. - auto run_k_tile = [&](int k_tile) { - barrier_arrive(barrier_scope); - - copy(copy_a, tAgA(_, _, _, k_tile), tArA); - copy(copy_b, tBgB(_, _, _, k_tile), tBrB); - - if (k_tile_prefetch < k_tile_count) { - prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); - prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); - } - ++k_tile_prefetch; - - reorder(tArA, tCrA); - reorder(tBrB, tCrB); - cute::gemm(mma, tCrA, tCrB, tCrC); - - barrier_wait(barrier_scope); - }; - - // Runtime bound (`ARK_MOE_W4A8_PREFILL_PREFETCH`), so no unroll pragma: the - // prologue runs once per tile, ahead of a mainloop of `k_tile_count` - // iterations, and its trip count is uniform across the work-group. - for (; k_tile_prefetch < prefetch_dist && k_tile_prefetch < k_tile_count; ++k_tile_prefetch) { - prefetch(prefetch_a, pAgA(_, _, _, k_tile_prefetch)); - prefetch(prefetch_b, pBgB(_, _, _, k_tile_prefetch)); - } - - // Does this tile touch the M or N edge? Uniform across the work-group (`m`, - // `n` and both coordinates are), so the epilogues below branch once instead - // of testing every fragment element. - const bool full_tile = allow_full_tile && (m_coord + 1) * static_cast(get<0>(wg_tile)) <= m && - (n_coord + 1) * static_cast(get<1>(wg_tile)) <= n; - - // `blks` is a kernel argument, so it is uniform across the work-group and - // this branch never splits the split-barrier pairing below. - if (blks == 1) { - clear(tCrC); - - for (int k_tile = 0; k_tile < k_tile_count; ++k_tile) { - run_k_tile(k_tile); - } - - // Single expression, instantiated once guarded and once not. `FullTile` - // only removes work: the value stored is computed by the same operations - // in the same order, so the two paths are bit-identical. - auto store_scaled = [&](auto full) { - CUTE_UNROLL - for (int i = 0; i < size(tCrC); ++i) { - auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); - const int col = static_cast(get<1>(coord)); - if constexpr (decltype(full)::value) { - c[static_cast(row) * n + col] = static_cast( - static_cast(tCrC(i)) * scale_b[static_cast(col)] * scale_a[row]); - } else { - // Clamp rather than branch: an out-of-range element's value is - // dropped by the guarded store, and unconditional loads let the - // redundant reads across the fragment collapse. `m` and `n` are both - // >= 1 here (an expert with no rows contributes no tiles). - const int row_in = row < m ? row : m - 1; - const int col_in = col < n ? col : n - 1; - const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * scale_a[row_in]; - if (row < m && col < n) { - c[static_cast(row) * n + col] = static_cast(value); - } - } - } - }; - - // Same values in the same order, through the hardware 2D block store. The - // element predicate is gone because the message clips to the `m x n` - // surface; only the scale loads still clamp their indices. - auto store_scaled_2d = [&](auto full) { - Tensor tCrD = make_tensor_like(tCrC); - CUTE_UNROLL - for (int i = 0; i < size(tCrC); ++i) { - auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); - const int col = static_cast(get<1>(coord)); - if constexpr (decltype(full)::value) { - tCrD(i) = static_cast(static_cast(tCrC(i)) * scale_b[static_cast(col)] * - scale_a[row]); - } else { - const int row_in = row < m ? row : m - 1; - const int col_in = col < n ? col : n - 1; - tCrD(i) = static_cast(static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * - scale_a[row_in]); - } - } - copy(copy_d, tCrD, tCgC); - }; - - // Fused top-k reduction: scale the row by its routing weight and - // accumulate it into the token's row of the `[batch, n]` fp32 output. - // Out-of-range rows are dropped rather than clamped -- a clamped scatter - // would corrupt a *valid* token's accumulator, which the guarded store - // above cannot do -- but the loads stay unconditional so they still - // collapse across the fragment. `row_to_token` is caller data, so its - // value is range-checked as well: a bad index drops the contribution - // instead of writing outside the accumulator. - auto store_fused = [&](auto full) { - CUTE_UNROLL - for (int i = 0; i < size(tCrC); ++i) { - auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); - const int col = static_cast(get<1>(coord)); - const int row_in = decltype(full)::value ? row : (row < m ? row : m - 1); - const int col_in = decltype(full)::value ? col : (col < n ? col : n - 1); - const float value = static_cast(tCrC(i)) * scale_b[static_cast(col_in)] * - scale_a[row_in] * reduce.row_weight[row_in]; - const int token = reduce.row_to_token[row_in]; - const bool in_tile = decltype(full)::value || (row < m && col < n); - if (in_tile && token >= 0 && token < reduce.batch) { - atomic_add_f32(&reduce.out[static_cast(token) * n + col], value); - } - } - }; - - if (reduce.enabled()) { - if (full_tile) { - store_fused(std::true_type{}); - } else { - store_fused(std::false_type{}); - } - } else if (allow_block_2d_store) { - if (full_tile) { - store_scaled_2d(std::true_type{}); - } else { - store_scaled_2d(std::false_type{}); - } - } else if (full_tile) { - store_scaled(std::true_type{}); - } else { - store_scaled(std::false_type{}); - } - return; - } - - Tensor tFrC = make_tensor_like(tCrC); - - CUTE_UNROLL - for (int i = 0; i < size(tFrC); ++i) { - tFrC(i) = 0.0f; - } - - for (int ib = 0; ib < blks; ++ib) { - clear(tCrC); - - for (int bk = 0; bk < k_tiles_per_block; ++bk) { - run_k_tile(ib * k_tiles_per_block + bk); - } - - if (full_tile) { - CUTE_UNROLL - for (int i = 0; i < size(tCrC); ++i) { - const int col = static_cast(get<1>(tCgC(i))); - tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col) * blks + ib]; - } - } else { - CUTE_UNROLL - for (int i = 0; i < size(tCrC); ++i) { - const int col = static_cast(get<1>(tCgC(i))); - const int col_in = col < n ? col : n - 1; - tFrC(i) += static_cast(tCrC(i)) * scale_b[static_cast(col_in) * blks + ib]; - } - } - } - - if (reduce.enabled()) { - CUTE_UNROLL - for (int i = 0; i < size(tFrC); ++i) { - auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); - const int col = static_cast(get<1>(coord)); - const int row_in = full_tile ? row : (row < m ? row : m - 1); - const float value = tFrC(i) * scale_a[row_in] * reduce.row_weight[row_in]; - const int token = reduce.row_to_token[row_in]; - const bool in_tile = full_tile || (row < m && col < n); - if (in_tile && token >= 0 && token < reduce.batch) { - atomic_add_f32(&reduce.out[static_cast(token) * n + col], value); - } - } - return; - } - - if (allow_block_2d_store) { - Tensor tCrD = make_tensor_like(tFrC); - if (full_tile) { - CUTE_UNROLL - for (int i = 0; i < size(tFrC); ++i) { - const int row = static_cast(get<0>(tCgC(i))); - tCrD(i) = static_cast(tFrC(i) * scale_a[row]); - } - } else { - CUTE_UNROLL - for (int i = 0; i < size(tFrC); ++i) { - const int row = static_cast(get<0>(tCgC(i))); - const int row_in = row < m ? row : m - 1; - tCrD(i) = static_cast(tFrC(i) * scale_a[row_in]); - } - } - copy(copy_d, tCrD, tCgC); - return; - } - - if (full_tile) { - CUTE_UNROLL - for (int i = 0; i < size(tFrC); ++i) { - auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); - const int col = static_cast(get<1>(coord)); - c[static_cast(row) * n + col] = static_cast(tFrC(i) * scale_a[row]); - } - return; - } - - CUTE_UNROLL - for (int i = 0; i < size(tFrC); ++i) { - auto coord = tCgC(i); - const int row = static_cast(get<0>(coord)); - const int col = static_cast(get<1>(coord)); - const int row_in = row < m ? row : m - 1; - const float value = tFrC(i) * scale_a[row_in]; - if (row < m && col < n) { - c[static_cast(row) * n + col] = static_cast(value); - } - } -} - -// --------------------------------------------------------------------------- -// Persistent atomic scheduler over `rows_per_expert`. -// -// Structurally identical to `moe_dpas_int::MoEGEMM_int` (which is itself the -// vllm-xpu-kernels grouped-GEMM scheduler); only the per-expert pointer -// arithmetic and the mainloop call differ: -// * A / D advance by the expert's token offset (`pre_rows`), and so does the -// per-token activation scale. -// * B advances by `expert * N * K` int8 elements, the block scales by -// `expert * N * blks` floats. -// --------------------------------------------------------------------------- -template -CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const float* ScaleA, - const float* ScaleB, ElementD* Outputs, TiledMMA const& mma, - const int* rows_per_expert, const int32_t num_experts, const int32_t gemm_n, - const int32_t gemm_k, const int32_t blocksize, const int32_t blks, - const bool allow_full_tile, const bool allow_block_2d_store, - const int32_t prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer, - const sycl::local_accessor& slm_mem_const) { - auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); - auto wg_tile = mma.tile_mnk(); - auto wg_tile_m = get<0>(wg_tile); - auto wg_tile_n = get<1>(wg_tile); - - int group_id = item.get_group_linear_id(); - int gemm_n_pad = (gemm_n + wg_tile_n - 1) / wg_tile_n * wg_tile_n; - int group_m_id = (group_id * wg_tile_n) / gemm_n_pad; - int group_range = item.get_group_range(1); - int local_id = item.get_local_linear_id(); - - if (group_id == 0 && local_id == 0) { - auto atm = sycl::atomic_ref(atomic_buffer[0]); - atm.store(0); - } - - int pre_rows = 0; - int pre_tiles = 0; - - int32_t* slm_mem = - static_cast(slm_mem_const.template get_multi_ptr().get()); - - for (int i = 0; i < num_experts; ++i) { - int gemm_m = rows_per_expert[i]; - int cumsum_rows_for_experts = pre_rows + gemm_m; - int cumsum_tiles_for_experts = (gemm_m + wg_tile_m - 1) / wg_tile_m + pre_tiles; - - if (group_m_id >= cumsum_tiles_for_experts) { - pre_rows = cumsum_rows_for_experts; - pre_tiles = cumsum_tiles_for_experts; - continue; - } - - const int expert_id = i; - const int64_t B_offset = - static_cast(expert_id) * static_cast(gemm_n) * static_cast(gemm_k); - const int64_t SB_offset = - static_cast(expert_id) * static_cast(gemm_n) * static_cast(blks); - - const int8_t* ptr_A_curr_batch = Activations + static_cast(pre_rows) * gemm_k; - const int8_t* ptr_B_curr_batch = Weights + B_offset; - const float* ptr_SA_curr_batch = ScaleA + pre_rows; - const float* ptr_SB_curr_batch = ScaleB + SB_offset; - ElementD* ptr_D_curr_batch = Outputs == nullptr ? nullptr : Outputs + static_cast(pre_rows) * gemm_n; - - // The scatter targets a `[batch, N]` accumulator shared by every expert, - // so only the per-row side tables advance with the expert; `reduce.out` - // stays put. - MoEFusedReduce expert_reduce = reduce; - if (reduce.enabled()) { - expert_reduce.row_to_token = reduce.row_to_token + pre_rows; - expert_reduce.row_weight = reduce.row_weight + pre_rows; - } - - while (group_m_id < cumsum_tiles_for_experts) { - const int n_coord = (group_id * wg_tile_n) % gemm_n_pad / wg_tile_n; - const int m_coord = (group_m_id - pre_tiles); - - xe_gemm_w4a8(ptr_A_curr_batch, ptr_B_curr_batch, ptr_D_curr_batch, - ptr_SA_curr_batch, ptr_SB_curr_batch, gemm_m, gemm_n, gemm_k, - blocksize, blks, m_coord, n_coord, allow_full_tile, - allow_block_2d_store, prefetch_dist, expert_reduce, mma); - - if (local_id == 0) { - slm_mem[0] = cutlass::atomicAdd(atomic_buffer, 1); - } - item.barrier(sycl::access::fence_space::local_space); - group_id = group_range + slm_mem[0]; - group_m_id = (group_id * wg_tile_n) / gemm_n_pad; - } - pre_rows = cumsum_rows_for_experts; - pre_tiles = cumsum_tiles_for_experts; - } -} - -// --------------------------------------------------------------------------- -// Grouped-GEMM launcher (fork of `moe_dpas_int::MoEGEMMLauncher_int`, with the -// int8 DPAS atom of `sycl_tla_s8_gemm.hpp`). -// --------------------------------------------------------------------------- -template -void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const int8_t* weights, - const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, - const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, - const int blks, const bool allow_full_tile, const bool allow_block_2d_store, - const int prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer) { - using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; - using WGTile = typename Policy::WGTile; - using SGLayout = typename Policy::SGLayout; - using MMA = typename TiledMMAHelper, Layout, SGLayout>::TiledMMA; - auto mma = MMA{}; - - int sm_count = cutlass::KernelHardwareInfo::query_device_multiprocessor_count(0); - auto MaxThreadsPerWorkgroup = size(mma); - - static constexpr int MaxThreadsPerSM = 512; - if (MaxThreadsPerSM % MaxThreadsPerWorkgroup != 0) { - throw std::runtime_error("moe_gemm_w4a8: MaxThreadsPerSM must be divisible by MaxThreadsPerWorkgroup"); - } - - sycl::range<3> local(1, 1, MaxThreadsPerWorkgroup); - sycl::range<3> global(1, sm_count * MaxThreadsPerSM / MaxThreadsPerWorkgroup, 1); - - namespace syclex = sycl::ext::oneapi::experimental; - namespace intelex = sycl::ext::intel::experimental; - - syclex::properties kernel_props{syclex::sub_group_size<16>, intelex::grf_size<256>}; - - using GmemTiledCopyA = typename Policy::GmemTiledCopyA; - using GmemTiledCopyB = typename Policy::GmemTiledCopyB; - - auto event = stream.submit([&](sycl::handler& cgh) { - sycl::local_accessor local_mem(sycl::range<1>(1), cgh); - cgh.parallel_for>( - sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { - MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, - rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, - blks, allow_full_tile, allow_block_2d_store, prefetch_dist, - reduce, atomic_buffer, local_mem); - }); - }); - - EventManager::getInstance().addEvent(event); - event.wait(); -} - -// --------------------------------------------------------------------------- -// Prefill driver: policy selection on the average per-expert M. -// -// The rungs match the tile ladder of `launch_igemm_kblock` in -// `sycl_tla_s8_gemm.hpp`: a grouped GEMM's M is *per expert*, so the ladder -// walks the average rows/expert rather than the total token count. -// -// The M rung is a row threshold and the N rung a divisibility test, and both -// are about not scheduling work the shape does not have: -// -// * `TileM` stops at 128. The 256-row tile halves how often each expert's B -// panel is pulled through L2/DRAM (B is read once per M tile), but an -// expert launches `ceil(M / TileM)` *full* tiles, so it only breaks even -// where `ceil(M/256)*256 == ceil(M/128)*128` -- false at the 384 and 341 -// rows/expert the perf suite measures, where it computes 512 rows for 384 -// rows of data and reads 1.05-1.45x slower. The 8K prompt puts Qwen3-MoE -// at exactly 512 rows/expert, where the padding argument does not apply, -// and `test_perf_prefill_tile_sweep_long_seq` measures it there: still not -// ahead (a tie on qwen3 up, 5.4% behind on qwen3 down). So the rung is -// gone rather than gated -- there is no routing at which it has been -// measured to win, and the ladder only sees the *average* rows/expert, so -// a skewed routing would put individual experts back on the padding cliff -// even when the average divides. Both 256-row policies stay compiled and -// reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a re-sweep. -// -// * `TileN = 256` halves how often A is re-read (once per N tile) and is -// ahead or level everywhere the sweeps can compare it, so it is taken -// whenever N divides into it exactly. `N % 256 != 0` would pad the last -// tile the same way a ragged M does, and no shipped shape needs it: every -// N here (1536 / 2048 / 3072) is a multiple of 256. -// -// The rung used to be `A_avg_M >= 256 -> 256x128` with no padding test and a -// 128-wide N at every rung, which is what made the 384 rows/expert batch -- -// the compute-bound batch the perf suite now runs -- land on the slowest -// column of its own sweep; it then became a padding-gated 256-row tile, which -// the 8K-prompt sweep has now retired. -// -// `ARK_MOE_W4A8_PREFILL_TILE` overrides the choice with an explicit `MxN` tile -// (`8x128`, `64x128`, `128x128`, `128x256`, `256x128`, `256x256`); anything -// else -- including the default `auto` -- keeps the ladder. It exists so the -// tile can be swept on hardware without a rebuild. -// -// `ARK_MOE_W4A8_PREFILL_FULL_TILE=0` makes every tile take the guarded -// epilogue (see `xe_gemm_w4a8`), which is the A/B baseline for the interior- -// tile fast path; it is read here, once per call, rather than on the device. -// -// `ARK_MOE_W4A8_PREFILL_STORE_2D=0` puts the epilogue back on the scalar -// predicated store instead of the hardware 2D block store, the A/B baseline -// for that change. The block message needs a 64-byte aligned surface base and -// a 16-byte multiple row pitch; D's per-expert base is `outputs + pre_rows * N` -// for a routing-dependent `pre_rows`, so the gate is on the row stride itself -// (`N * sizeof(ElementD) % 64 == 0`, which covers the pitch as well) plus the -// tensor base. Every shipped N (1536 / 2048 / 3072 with 16-bit D) clears it; -// anything that does not keeps the scalar store rather than risking a -// misaligned descriptor. -// --------------------------------------------------------------------------- -template -void moe_w4a8_prefill_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* num_tokens_per_expert, int E, - int N, int K, int blocksize, int blks, int total_tokens, - MoEFusedReduce reduce = MoEFusedReduce{}) { - if (E == 0 || N == 0 || K == 0 || total_tokens == 0) return; - - compat::set_default_queue(*q); - - const int A_avg_M = total_tokens / E; - const bool tile_n_256 = (N % 256) == 0; - const bool allow_full_tile = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_FULL_TILE", true); - const bool store_2d_aligned = !reduce.enabled() && (static_cast(N) * sizeof(ElementD)) % 64 == 0 && - reinterpret_cast(outputs) % 64 == 0; - const bool allow_block_2d_store = - store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); - const int prefetch_dist = moe_w4a8_prefill_prefetch_dist(); - int32_t* atomic_buffer = moe_dpas_fp8::get_atomic_scratch_buffer(q); - -#define ARK_MOE_W4A8_LAUNCH(policy) \ - MoEGEMMLauncher_w4a8(*q, qact, weights, ascale, wscale, outputs, N, K, \ - num_tokens_per_expert, E, blocksize, blks, allow_full_tile, \ - allow_block_2d_store, prefetch_dist, reduce, atomic_buffer); - - const char* tile_env = std::getenv("ARK_MOE_W4A8_PREFILL_TILE"); - if (tile_env != nullptr) { - if (std::strcmp(tile_env, "8x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) - return; - } else if (std::strcmp(tile_env, "64x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - return; - } else if (std::strcmp(tile_env, "128x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) - return; - } else if (std::strcmp(tile_env, "128x256") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) - return; - } else if (std::strcmp(tile_env, "256x128") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_256_n128) - return; - } else if (std::strcmp(tile_env, "256x256") == 0) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_large) - return; - } - } - - if (A_avg_M < 16) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_8) - } else if (A_avg_M < 128) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_64) - } else if (tile_n_256) { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128_n256) - } else { - ARK_MOE_W4A8_LAUNCH(w4a8_policy_m_128) - } -#undef ARK_MOE_W4A8_LAUNCH -} - -// --------------------------------------------------------------------------- -// Decode GEMV: int8 x int8 -> int32, one output column per sub-group lane. -// -// Same work decomposition as `moe_decode_detail::launch_int8` (work-group = -// one sub-group covering 16 consecutive N columns of one token), with the -// per-K-group float dequantization replaced by a per-block int32 dot product. -// Two accumulators hide the multiply-add latency; int32 cannot overflow here -// (|a|,|w| <= 127 gives < 2^14 per product, so K would have to exceed 130k). -// --------------------------------------------------------------------------- -template -void launch_w4a8_decode(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* expert_id_per_token, int total_tokens, - int N, int K, int blocksize, int blks) { - if (N % N_TILE != 0) { - throw std::invalid_argument("moe_gemm_w4a8(decode): N must be a multiple of 16"); - } - if (total_tokens == 0) return; - - const int n_tiles = N / N_TILE; - sycl::range<2> global{static_cast(total_tokens), static_cast(n_tiles * SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const int token = static_cast(it.get_global_id(0)); - const int n_tile = static_cast(it.get_group(1)); - const int lane = static_cast(it.get_local_id(1)); - const int n_global = n_tile * N_TILE + lane; - - const int expert = expert_id_per_token[token]; - const int8_t* act_row = qact + static_cast(token) * K; - const int8_t* w_row = weights + (static_cast(expert) * N + static_cast(n_global)) * K; - const float* s_row = - wscale + (static_cast(expert) * N + static_cast(n_global)) * blks; - - constexpr int SUB = 16; - using QVec = sycl::vec; - - float accf = 0.0f; - for (int ib = 0; ib < blks; ++ib) { - const int k_base = ib * blocksize; - int acc0 = 0; - int acc1 = 0; - int kk = 0; - const int end = (blocksize / SUB) * SUB; - for (; kk < end; kk += SUB) { - const QVec av = *reinterpret_cast(act_row + k_base + kk); - const QVec wv = *reinterpret_cast(w_row + k_base + kk); -#pragma unroll - for (int u = 0; u < SUB; u += 2) { - acc0 += static_cast(av[u]) * static_cast(wv[u]); - acc1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); - } - } - for (; kk < blocksize; ++kk) { - acc0 += static_cast(act_row[k_base + kk]) * static_cast(w_row[k_base + kk]); - } - accf += static_cast(acc0 + acc1) * s_row[ib]; - } - - outputs[static_cast(token) * N + n_global] = static_cast(accf * ascale[token]); - }); -} - -// --------------------------------------------------------------------------- -// Decode GEMV, K-split lane mapping (default) -- one sub-group per output -// element, lanes splitting K, plus N-blocking over `NCOLS` columns. -// -// `launch_w4a8_decode` above maps one *work-item* to one output element, so a -// lane walks a whole `[n_global, K]` int8 weight row on its own. That is the -// same mapping the FP8 decode GEMV started from, and it costs the same two -// things on a kernel that does exactly one multiply-add per weight byte: -// -// 1. Weight loads are not coalesced. Lanes `l` and `l+1` read bytes `K` -// apart, so each 16-byte load turns into 16 scattered cache-line -// requests. No DRAM byte is wasted (each lane consumes its lines as it -// walks the row), but the memory controller sees 16 independent streams -// per sub-group -- the pattern DRAM row buffers handle worst. -// 2. The grid is small: `total_tokens * N / 16` sub-groups, i.e. 768 SIMD16 -// threads for a Qwen3-MoE batch-1 step (8 routed rows, N = 1536). That is -// below the thread slots of a BMG-class GPU (1280 on a B60), so there are -// never enough loads in flight to cover DRAM latency. -// -// This kernel transposes the mapping exactly like `launch_fp8_ksplit`: a whole -// sub-group cooperates on one output element and lane `l` owns the `CH` -// consecutive K elements at `l * CH` inside each `SG_SIZE * CH`-wide K tile. -// One instruction then covers `SG_SIZE * CH` *contiguous* weight bytes (256 B = -// four full cache lines at the default `CH = 16`, 512 B at `CH = 32`) and the -// same span of int8 activations, every thread walks a single sequential stream, -// and the sub-group count grows by `SG_SIZE` (12288 for that batch-1 step). The -// price is one `reduce_over_group` per output element -- a handful of shuffles -// against `K` multiply-adds. -// -// On top of that the sub-group blocks N: it owns `NCOLS` consecutive columns -// and reuses one activation load for all of them, which cuts activation -// messages per weight chunk to `1 / NCOLS` and multiplies the number of -// independent weight loads in flight by `NCOLS` (see -// `moe_w4a8_decode_ksplit_ncols`). -// -// Numerics are equivalent, not bit-identical: the int32 partial sums are still -// folded to float once per AUTO_S8 block with that block's scale, but they are -// split across the 16 lanes and summed at the end. Integer addition is exact -// and associative, so the *integer* partition is lossless; only the float -// accumulation is reordered (per lane, then across lanes, instead of one lane -// folding every block in sequence), which can differ from the legacy result by -// a rounding step. A lane's chunk is `CH` consecutive K elements starting at a -// multiple of `CH`, and the shape gate requires the block to be a multiple of -// `CH`, so a chunk never straddles two blocks. -// --------------------------------------------------------------------------- - -// K elements a lane owns per step: `KSPLIT_CH_DEFAULT` is one 16-byte int8 -// weight load and one 16-byte int8 activation load, the same transactions the -// legacy GEMV issues. `ARK_MOE_W4A8_DECODE_KSPLIT_CH=32` doubles them to -// 32-byte loads, which halves the number of memory messages per byte and -// doubles the bytes a thread keeps in flight; it costs GRF (2 x NCOLS chunks -// live at once) and needs `blocksize >= SG_SIZE * CH`. -// -// Measured (`test_perf_decode_config_sweep`, BMG, 8 routed rows, bf16 act), at -// the default NCOLS: 284.0 -> 278.9 GB/s (qwen3 up), 280.1 -> 244.4 (qwen3 -// down), 268.1 -> 259.9 (minimax up), 315.5 -> 308.7 (minimax down). The wider -// chunk never wins at any NCOLS and costs up to 13%, so 16 stays the default -// and 32 stays an opt-in sweep point. -constexpr int KSPLIT_CH_DEFAULT = 16; -constexpr int KSPLIT_CH_MAX = 32; -// Sub-groups per work-group. Each owns `NCOLS` output columns, so a work-group -// covers `KSPLIT_WG_SGS * NCOLS` consecutive columns. -constexpr int KSPLIT_WG_SGS = N_TILE; -// `NCOLS = 2` is the measured default: it is the fastest configuration on two -// of the four swept shapes and within 2% of the best (`4` on qwen3 down, `1` on -// minimax up) on the other two, while `1` costs 47% on qwen3 up and `4` costs -// 14% on minimax up. -constexpr int KSPLIT_NCOLS_DEFAULT = 2; -constexpr int KSPLIT_NCOLS_MAX = 4; - -// A lane's `CH`-byte chunk, as a register type. -// -// `sycl::vec` only exists for 1, 2, 3, 4, 8 and 16 elements, so a `CH = 32` -// chunk cannot be spelled `sycl::vec`: that instantiation is a hard -// static_assert in the SYCL headers ("Invalid number of elements for -// sycl::vec"). A chunk is therefore an aggregate of `CH / 16` 16-byte -// sub-vectors covering *consecutive* bytes. The lane still reads one contiguous -// `CH`-byte span at a `CH`-aligned address (`moe_w4a8_decode_ksplit_shape_ok` -// makes every chunk offset a multiple of `CH` off a row base that is a multiple -// of `K`, itself a multiple of `CH`), the sub-vectors are adjacent both in -// memory and in the GRF, and the declared alignment lets IGC fold the pair back -// into a single wider message. At the default `CH = 16` the aggregate holds a -// single `sycl::vec`, i.e. exactly the load this kernel issued -// before. -// -// `operator[]` is only ever called from the fully unrolled inner loops, so the -// sub-vector selection folds away at compile time and no dynamic indexing -// (which would push the chunk out to scratch) is generated. -template -struct alignas(CH) QChunk { - static constexpr int kSub = KSPLIT_CH_DEFAULT; - static_assert(CH % kSub == 0, "chunk width must be a whole number of 16-byte sub-vectors"); - sycl::vec v[CH / kSub]; - - int8_t operator[](int i) const { return v[i / kSub][i % kSub]; } -}; - -// `ARK_MOE_W4A8_DECODE_KSPLIT` (default ON). Setting it to "0" / "false" / -// "off" / "no" forces the legacy per-lane-strided GEMV, for A/B comparison and -// as a regression escape. Re-read on every call so benchmarks can toggle the -// path in-process. -inline bool moe_w4a8_decode_ksplit_enabled() { - return moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_DECODE_KSPLIT", true); -} - -// Per-lane chunk width in K elements (= bytes). 16 or 32; anything else falls -// back to the default. -inline int moe_w4a8_decode_ksplit_chunk() { - const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_CH"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && (v == 16 || v == 32)) return static_cast(v); - } - return KSPLIT_CH_DEFAULT; -} - -// Shape gate. `blocksize >= SG_SIZE * ch` keeps every lane of the sub-group -// busy: below that some lanes own no chunk in a block and only pay the -// reduction, which is the one regime where splitting K cannot pay for itself. -// `blocksize % ch == 0` combined with `K % blocksize == 0` also makes every -// chunk offset a multiple of `ch` off a row base that is a multiple of `K`, so -// the vector loads stay naturally aligned. The resolved AUTO_S8 block is always -// a multiple of 64 that divides K, so the conditions hold for every shipped -// configuration and only very fine re-scale blocks fall back to the legacy -// GEMV. -inline bool moe_w4a8_decode_ksplit_shape_ok(int N, int K, int blocksize, int ch = KSPLIT_CH_DEFAULT) { - if (N % N_TILE != 0) return false; - if (blocksize < SG_SIZE * ch) return false; - if (blocksize % ch != 0) return false; - if (K % blocksize != 0) return false; - return true; -} - -// N-blocking factor. A work-group covers `KSPLIT_WG_SGS * ncols` columns, so -// `ncols` shrinks until it tiles N. `ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS` -// overrides the default (1, 2 or 4); `NCOLS == 1` reproduces the plain K-split -// mapping instruction for instruction. -inline int moe_w4a8_decode_ksplit_ncols(int N) { - int ncols = KSPLIT_NCOLS_DEFAULT; - const char* env = std::getenv("ARK_MOE_W4A8_DECODE_KSPLIT_NCOLS"); - if (env != nullptr) { - char* end = nullptr; - const long long v = std::strtoll(env, &end, 10); - if (end != env && v >= 1 && v <= KSPLIT_NCOLS_MAX && (v & (v - 1)) == 0) { - ncols = static_cast(v); - } - } - while (ncols > 1 && (N % (KSPLIT_WG_SGS * ncols)) != 0) ncols /= 2; - return ncols; -} - -template -void launch_w4a8_decode_ksplit(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* expert_id_per_token, - int total_tokens, int N, int K, int blocksize, int blks) { - static_assert(NCOLS >= 1 && (NCOLS & (NCOLS - 1)) == 0, "NCOLS must be a power of two"); - static_assert(CH == 16 || CH == KSPLIT_CH_MAX, "CH must be 16 or 32"); - // K elements a sub-group covers per step -- the contiguous span its 16 lanes - // read in one instruction. - constexpr int STEP = SG_SIZE * CH; - if (!moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, CH) || (N % (KSPLIT_WG_SGS * NCOLS)) != 0) { - throw std::invalid_argument("moe_gemm_w4a8(decode): K-split GEMV called on an unsupported shape"); - } - if (total_tokens == 0) return; - - // One sub-group per (token, NCOLS columns); `KSPLIT_WG_SGS` of them per - // work-group. - sycl::range<2> global{static_cast(total_tokens), static_cast(N / NCOLS) * SG_SIZE}; - sycl::range<2> local{1, static_cast(KSPLIT_WG_SGS * SG_SIZE)}; - - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { - const auto sg = it.get_sub_group(); - const int token = static_cast(it.get_global_id(0)); - const int local_id = static_cast(it.get_local_id(1)); - // The work-group is one row of `KSPLIT_WG_SGS * SG_SIZE` work-items, so - // sub-group index and lane index are the halves of the local id. - const int lane = local_id % SG_SIZE; - const int n_base = (static_cast(it.get_group(1)) * KSPLIT_WG_SGS + local_id / SG_SIZE) * NCOLS; - - const int expert = expert_id_per_token[token]; - const int8_t* act_row = qact + static_cast(token) * K; - const size_t row0 = static_cast(expert) * N + static_cast(n_base); - const int8_t* w_rows[NCOLS]; - const float* s_rows[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - w_rows[c] = weights + (row0 + static_cast(c)) * K; - s_rows[c] = wscale + (row0 + static_cast(c)) * blks; - } - - using QVec = QChunk; - - float acc[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) acc[c] = 0.0f; - - for (int ib = 0; ib < blks; ++ib) { - const int block_begin = ib * blocksize; - const int block_end = block_begin + blocksize; - int32_t iacc[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) iacc[c] = 0; - - int k0 = block_begin + lane * CH; - // Two chunks per iteration: their loads are independent, so the pair - // doubles the weight requests a thread keeps in flight. All - // `2 * NCOLS` weight loads are issued before the first is consumed. - for (; k0 + STEP + CH <= block_end; k0 += 2 * STEP) { - const QVec av0 = *reinterpret_cast(act_row + k0); - const QVec av1 = *reinterpret_cast(act_row + k0 + STEP); - QVec wv0[NCOLS], wv1[NCOLS]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - wv0[c] = *reinterpret_cast(w_rows[c] + k0); - wv1[c] = *reinterpret_cast(w_rows[c] + k0 + STEP); - } -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - int p0 = 0; - int p1 = 0; -#pragma unroll - for (int u = 0; u < CH; u += 2) { - p0 += static_cast(av0[u]) * static_cast(wv0[c][u]); - p1 += static_cast(av0[u + 1]) * static_cast(wv0[c][u + 1]); - p0 += static_cast(av1[u]) * static_cast(wv1[c][u]); - p1 += static_cast(av1[u + 1]) * static_cast(wv1[c][u + 1]); - } - iacc[c] += p0 + p1; - } - } - // Tail: the chunk of a lane whose partner a full step away falls - // outside the block. At most one chunk per lane. - for (; k0 < block_end; k0 += STEP) { - const QVec av = *reinterpret_cast(act_row + k0); -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - const QVec wv = *reinterpret_cast(w_rows[c] + k0); - int p0 = 0; - int p1 = 0; -#pragma unroll - for (int u = 0; u < CH; u += 2) { - p0 += static_cast(av[u]) * static_cast(wv[u]); - p1 += static_cast(av[u + 1]) * static_cast(wv[u + 1]); - } - iacc[c] += p0 + p1; - } - } - -#pragma unroll - for (int c = 0; c < NCOLS; ++c) acc[c] += static_cast(iacc[c]) * s_rows[c][ib]; - } - - const float sa = ascale[token]; -#pragma unroll - for (int c = 0; c < NCOLS; ++c) { - const float total = sycl::reduce_over_group(sg, acc[c], sycl::plus{}); - if (lane == 0) { - outputs[static_cast(token) * N + n_base + c] = static_cast(total * sa); - } - } - }); -} - -// Runtime (NCOLS, CH) -> compile-time bridge, plus the K-split / legacy choice. -// `CH = 32` needs a block of at least 512 elements, so it silently falls back to -// 16 on shapes it cannot serve rather than dropping to the legacy GEMV. -template -void launch_w4a8_decode_dispatch(sycl::queue* q, const int8_t* qact, const float* ascale, const int8_t* weights, - const float* wscale, ElementD* outputs, const int* expert_id_per_token, - int total_tokens, int N, int K, int blocksize, int blks) { - if (moe_w4a8_decode_ksplit_enabled() && moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize)) { - const int ncols = moe_w4a8_decode_ksplit_ncols(N); - const int ch = moe_w4a8_decode_ksplit_chunk() == KSPLIT_CH_MAX && - moe_w4a8_decode_ksplit_shape_ok(N, K, blocksize, KSPLIT_CH_MAX) - ? KSPLIT_CH_MAX - : KSPLIT_CH_DEFAULT; - -#define ARK_MOE_W4A8_KSPLIT(ncols_v, ch_v) \ - launch_w4a8_decode_ksplit(q, qact, ascale, weights, wscale, outputs, \ - expert_id_per_token, total_tokens, N, K, blocksize, blks); \ - return; - - if (ch == KSPLIT_CH_MAX) { - switch (ncols) { - case 4: - ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_MAX) - case 2: - ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_MAX) - default: - ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_MAX) - } - } - switch (ncols) { - case 4: - ARK_MOE_W4A8_KSPLIT(4, KSPLIT_CH_DEFAULT) - case 2: - ARK_MOE_W4A8_KSPLIT(2, KSPLIT_CH_DEFAULT) - default: - ARK_MOE_W4A8_KSPLIT(1, KSPLIT_CH_DEFAULT) - } -#undef ARK_MOE_W4A8_KSPLIT - } - launch_w4a8_decode(q, qact, ascale, weights, wscale, outputs, expert_id_per_token, total_tokens, N, K, - blocksize, blks); -} - -// --------------------------------------------------------------------------- -// Host-side helpers -// --------------------------------------------------------------------------- - -// Resolve the effective AUTO_S8 re-scale block size. -// -// `requested <= 0` (the `group=-1` spelling) or any value that is not a valid -// block size falls back to `K`, i.e. one scale per output channel -- the -// maximum-efficiency shape. `ARK_MOE_W4A8_AUTO_S8` overrides the argument so -// benchmarks can sweep the block size without touching the caller. -inline int moe_w4a8_rescale_block_size(int K, int group_size, int requested) { - int v = requested; - const char* env = std::getenv("ARK_MOE_W4A8_AUTO_S8"); - if (env != nullptr) { - char* end = nullptr; - const long parsed = std::strtol(env, &end, 10); - if (end != env) v = static_cast(parsed); - } - if (K <= 0) return K; - if (v <= 0 || v >= K) return K; - if (group_size > 0 && (v < group_size || v % group_size != 0)) return K; - if (K % v != 0) return K; - // The mainloop slices each block into 64-wide DPAS K tiles. - if (v % 64 != 0) return K; - return v; -} - -// Shape preconditions shared by the prepack, prefill and decode paths. -inline bool moe_w4a8_shape_ok(int N, int K, int group_size) { - if (N <= 0 || K <= 0 || group_size <= 0) return false; - if (N % N_TILE != 0) return false; - if (K % 64 != 0) return false; - if (group_size % kPrepackOctet != 0) return false; - if (K % group_size != 0) return false; - return true; -} - -// Token count at or below which the auto phase selection picks the decode -// GEMV. Mirrors `ARK_MOE_AUTO_DECODE_MAX_TOKENS` used by the Python `moe()` -// dispatcher; overridable with `ARK_MOE_W4A8_DECODE_MAX_TOKENS`. -inline int moe_w4a8_decode_max_tokens() { - const char* env = std::getenv("ARK_MOE_W4A8_DECODE_MAX_TOKENS"); - if (env == nullptr) return 128; - char* end = nullptr; - const long parsed = std::strtol(env, &end, 10); - if (end == env || parsed < 0) return 128; - return static_cast(parsed); -} - -inline void moe_w4a8_release_scratch() { - auto& st = w4a8_scratch_state(); - - // Detach everything under the lock, then drop the lock before the device sync - // and the frees: `wait()` blocks for an unbounded time and must not be held - // across. Because the slabs are already out of the pool's tables, an acquire - // that races in behind us allocates fresh ones instead of handing back a - // pointer we are about to free. - std::vector> pending; - { - std::lock_guard lock(st.mu); - auto* pool = DeviceMemoryPool::Instance(); - for (auto& kv : st.queues) { - sycl::queue q = kv.second; - for (size_t loc : {kW4A8QactScratchLoc, kW4A8ExpertMapScratchLoc}) { - void* ptr = pool->detach_scratch_mem(loc, &q); - if (ptr != nullptr) pending.emplace_back(q, ptr); - } - } - st.queues.clear(); - } - - for (auto& item : pending) { - item.first.wait(); - sycl::free(item.second, item.first); - } -} - -} // namespace moe_w4a8 - -// The public `ark::` entry points below are thin wrappers emitted by the -// generated `sycl_tla_moe_w4a8.cpp` translation unit (MOE_SOURCE_MODE -// 21); they live in their own namespace here so the header stays free of -// external definitions and only that one TU pays the kernel compile cost. -namespace moe_w4a8_detail { - -// --------------------------------------------------------------------------- -// Public entry point 1 -- one-shot AUTO_S8 prepack. -// -// Converts auto-round's packed int4-sym weights + per-group scales into the -// int8 weights + FP32 block scales the W4A8 kernels consume. Callers are -// expected to run this once per checkpoint and keep the results resident. -// --------------------------------------------------------------------------- -inline void moe_w4a8_prepack(sycl::queue* q, void* weights_s4, void* scales, void* weights_s8, void* wscales, - BTLA_DTYPE act_dtype, int num_experts, int N, int K, int group_size, - int rescale_group_size) { - if (num_experts <= 0) return; - if (!moe_w4a8::moe_w4a8_shape_ok(N, K, group_size)) { - throw std::invalid_argument( - "moe_w4a8_prepack: unsupported shape (need N % 16 == 0, K % 64 == 0, " - "group_size % 8 == 0 and K % group_size == 0)"); - } - if (weights_s4 == nullptr || scales == nullptr || weights_s8 == nullptr || wscales == nullptr) { - throw std::invalid_argument("moe_w4a8_prepack: null buffer"); - } - - const int blocksize = moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); - const int blks = K / blocksize; - - if (act_dtype == BTLA_DTYPE::F16) { - using ScalarT = sycl::half; - moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), - static_cast(wscales), num_experts, N, K, group_size, - blocksize, blks); - moe_w4a8::launch_weight_rescale_s4_to_s8( - q, static_cast(weights_s4), static_cast(scales), - static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, - blocksize, blks); - } else if (act_dtype == BTLA_DTYPE::BF16) { - using ScalarT = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_weight_scale_reduce(q, static_cast(scales), - static_cast(wscales), num_experts, N, K, group_size, - blocksize, blks); - moe_w4a8::launch_weight_rescale_s4_to_s8( - q, static_cast(weights_s4), static_cast(scales), - static_cast(wscales), static_cast(weights_s8), num_experts, N, K, group_size, - blocksize, blks); - } else { - throw std::invalid_argument("moe_w4a8_prepack: act_dtype must be F16 or BF16"); - } -} - -// --------------------------------------------------------------------------- -// Public entry point 2 -- W4A8 MoE GEMM (prefill + decode). -// -// `phase`: 0 = auto (decode when `total_tokens <= -// ARK_MOE_W4A8_DECODE_MAX_TOKENS`), 1 = force decode GEMV, 2 = force prefill -// grouped GEMM. -// -// Two optional call contracts trade interface work for DRAM traffic. Both are -// opt-in and the defaults are unchanged. -// -// Pre-quantized activations (`qact_in` + `ascale_in`) -// --------------------------------------------------- -// By default the call quantizes `[T, K]` itself: it reads the 16-bit -// activations, writes an int8 copy and the GEMM reads that copy back, i.e. -// `4 * T * K` bytes on top of the GEMM's own operands. On the down-projection -// that is 27% of everything the call moves -- and it is redundant, because the -// producer of those activations (the SiLU/gate elementwise kernel) already -// writes `[T, K]` once and could write int8 plus a per-row scale instead: the -// absmax it needs is a reduction over the row it is already holding. When both -// pointers are supplied all three streams disappear, along with a kernel -// launch. `ascale_in` is `[T]` fp32, `scale = absmax / 127`, matching what -// `launch_act_dynamic_quant` writes. -// -// Fused top-k reduction (`row_to_token` + `routing_weights` + `fused_out`) -// ----------------------------------------------------------------------- -// See `MoEFusedReduce`. Prefill only, and the accumulator must be zeroed by -// the caller; `outputs` is then unused and may be null. -// --------------------------------------------------------------------------- -inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* wscales, void* outputs, - BTLA_DTYPE act_dtype, int N, int K, int rescale_block_size, - int* num_tokens_per_expert, int num_experts, int total_tokens, int phase, - const void* qact_in = nullptr, const float* ascale_in = nullptr, - const int* row_to_token = nullptr, const float* routing_weights = nullptr, - float* fused_out = nullptr, int fused_batch = 0) { - if (total_tokens == 0 || num_experts <= 0) return; - if (N % moe_w4a8::N_TILE != 0) { - throw std::invalid_argument("moe_gemm_w4a8: N must be a multiple of 16"); - } - if (K % 64 != 0) { - throw std::invalid_argument("moe_gemm_w4a8: K must be a multiple of 64"); - } - if (rescale_block_size <= 0 || rescale_block_size > K || K % rescale_block_size != 0 || - rescale_block_size % 64 != 0) { - throw std::invalid_argument( - "moe_gemm_w4a8: rescale_block_size must be a multiple of 64 that divides K " - "(use moe_w4a8_rescale_block_size to resolve it)"); - } - if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { - throw std::invalid_argument("moe_gemm_w4a8: act_dtype must be F16 or BF16"); - } - - const bool prequantized = qact_in != nullptr || ascale_in != nullptr; - if (prequantized && (qact_in == nullptr || ascale_in == nullptr)) { - throw std::invalid_argument("moe_gemm_w4a8: pre-quantized activations need both qact and ascale"); - } - if (!prequantized && activations == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: null activations"); - } - - const bool fused_reduce = row_to_token != nullptr || routing_weights != nullptr || fused_out != nullptr; - if (fused_reduce && (row_to_token == nullptr || routing_weights == nullptr || fused_out == nullptr || - fused_batch <= 0)) { - throw std::invalid_argument( - "moe_gemm_w4a8: the fused top-k reduction needs row_to_token, routing_weights, a zeroed [batch, N] " - "fp32 output and batch > 0"); - } - if (!fused_reduce && outputs == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: null outputs"); - } - - const int blocksize = rescale_block_size; - const int blks = K / blocksize; - - const bool use_decode = - phase == 1 || (phase != 2 && total_tokens <= moe_w4a8::moe_w4a8_decode_max_tokens()); - - if (fused_reduce && use_decode) { - throw std::invalid_argument("moe_gemm_w4a8: the fused top-k reduction is prefill-only"); - } - - const int8_t* qact = static_cast(qact_in); - const float* ascale = ascale_in; - int8_t* qact_scratch = nullptr; - float* ascale_scratch = nullptr; - - if (!prequantized) { - // Quantized activations + per-token scales share one slab: `[T, K]` int8 - // followed by `[T]` fp32 (the int8 region is already 4-byte aligned because - // K is a multiple of 64). - const size_t qact_bytes = static_cast(total_tokens) * static_cast(K); - const size_t scale_offset = (qact_bytes + sizeof(float) - 1) / sizeof(float) * sizeof(float); - const size_t slab_bytes = scale_offset + static_cast(total_tokens) * sizeof(float); - uint8_t* slab = moe_w4a8::acquire_qact_scratch(q, slab_bytes); - qact_scratch = reinterpret_cast(slab); - ascale_scratch = reinterpret_cast(slab + scale_offset); - qact = qact_scratch; - ascale = ascale_scratch; - } - - // Decode consumes `expert_id_per_token`; the activation-quant kernel already - // runs one sub-group per token, so it derives the map as well instead of - // paying for a second launch (`fill_expert_id_per_token`) on a timeline where - // one call is issued per generated token. Prefill passes nullptr and the scan - // is not compiled into the work. With pre-quantized activations that kernel - // does not run at all, so decode falls back to the standalone scan. - int* expert_map = nullptr; - if (use_decode) { - expert_map = moe_w4a8::acquire_expert_map_scratch(q, static_cast(total_tokens) * sizeof(int)); - } - - if (prequantized) { - if (use_decode) { - moe_decode_detail::fill_expert_id_per_token(q, expert_map, num_tokens_per_expert, num_experts, - total_tokens); - } - } else if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, - ascale_scratch, total_tokens, K, expert_map, - num_tokens_per_expert, num_experts); - } else { - using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_act_dynamic_quant(q, static_cast(activations), qact_scratch, ascale_scratch, - total_tokens, K, expert_map, num_tokens_per_expert, num_experts); - } - - const auto* weights = static_cast(weights_s8); - const auto* wscale = static_cast(wscales); - - if (use_decode) { - if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, - static_cast(outputs), expert_map, - total_tokens, N, K, blocksize, blks); - } else { - using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::launch_w4a8_decode_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), - expert_map, total_tokens, N, K, blocksize, blks); - } - return; - } - - moe_w4a8::MoEFusedReduce reduce{}; - if (fused_reduce) { - reduce.row_to_token = row_to_token; - reduce.row_weight = routing_weights; - reduce.out = fused_out; - reduce.batch = fused_batch; - } - - if (act_dtype == BTLA_DTYPE::F16) { - moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, - static_cast(outputs), num_tokens_per_expert, - num_experts, N, K, blocksize, blks, total_tokens, reduce); - } else { - using BF = sycl::ext::oneapi::bfloat16; - moe_w4a8::moe_w4a8_prefill_dispatch(q, qact, ascale, weights, wscale, static_cast(outputs), - num_tokens_per_expert, num_experts, N, K, blocksize, blks, - total_tokens, reduce); - } -} - -// Resolve the effective AUTO_S8 block size (host helper, also exported to -// Python so callers can size the `wscales` tensor consistently). -inline int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size) { - return moe_w4a8::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); -} - -// Free the W4A8 activation-quantization / expert-map scratch slabs. -inline void moe_w4a8_release_scratch() { moe_w4a8::moe_w4a8_release_scratch(); } - -} // namespace moe_w4a8_detail - -} // namespace ark - -#endif // ARK_XPU && ARK_SYCL_TLA From f2c43e6f95c777d758dce1f47e9fb564dfc91fd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:13:32 +0000 Subject: [PATCH 088/112] fix: include utils.hpp for DeviceMemoryPool in the W4A8 helpers header Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8_helpers.hpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp index 9af1f0aba9..75ecdc6d31 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp @@ -37,9 +37,14 @@ #if defined(ARK_XPU) && defined(ARK_SYCL_TLA) -// `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE` and the -// `DeviceMemoryPool` declaration. Cutlass-free, unlike the DPAS headers. +// `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE`. +// Cutlass-free, unlike the DPAS headers. #include "sycl_tla_moe_decode.hpp" +// `DeviceMemoryPool`, backing the scratch slabs below. Before the split this +// header's contents lived in `sycl_tla_moe_w4a8.hpp`, which picked the pool up +// transitively through `sycl_tla_moe_prefill_int_dpas.hpp`; that chain runs +// through cutlass, so the include has to be explicit here. +#include "utils.hpp" namespace ark { namespace moe_w4a8 { From 53ff07903dd027f8c8c42e85aa72ea6dbd9fb160 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:28:56 +0000 Subject: [PATCH 089/112] refactor: move W4A8 scratch pools into their own TU to keep utils.hpp out of the light TUs Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../sycl_tla_moe_w4a8_scratch.cpp | 125 ++++++++++++++++ .../include/sycl_tla_moe_w4a8_helpers.hpp | 141 ++---------------- .../include/sycl_tla_moe_w4a8_scratch.hpp | 69 +++++++++ 3 files changed, 203 insertions(+), 132 deletions(-) create mode 100644 auto_round_extension/ark/auto_round_kernel/sycl_tla_moe_w4a8_scratch.cpp create mode 100644 auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_scratch.hpp diff --git a/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe_w4a8_scratch.cpp b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe_w4a8_scratch.cpp new file mode 100644 index 0000000000..d478fcb005 --- /dev/null +++ b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe_w4a8_scratch.cpp @@ -0,0 +1,125 @@ +// Device scratch management for the W4A8 MoE path. +// +// The bookkeeping below is deliberately defined here rather than in a header so +// that the module holds exactly one instance of it, and so that `utils.hpp` +// (and the bestla JIT headers behind it) stays out of the cutlass-free W4A8 +// translation units. See `sycl_tla_moe_w4a8_scratch.hpp` for the rationale. +// +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "sycl_tla_moe_w4a8_scratch.hpp" + +#if defined(ARK_XPU) && defined(ARK_SYCL_TLA) + +#include +#include +#include +#include +#include + +#include "utils.hpp" + +namespace ark { +namespace moe_w4a8 { + +namespace { + +struct W4A8ScratchState { + std::mutex mu; + // Device key (`DeviceMemoryPool::get_device_key`) -> a queue handle on that + // device, held *by value*: a `sycl::queue` is a reference-counted handle, so + // keeping a copy guarantees the queue outlives the memory allocated against + // it. + std::map queues; +}; + +// Intentionally leaked, see the header. +W4A8ScratchState& w4a8_scratch_state() { + static W4A8ScratchState* s = new W4A8ScratchState(); + return *s; +} + +// Acquire a slab from the shared pool, synchronizing first when the request +// grows it: `DeviceMemoryPool` frees the old pointer in place when it grows a +// slot, and in-flight kernels may still be reading the old slab, so the wait +// has to happen before the call rather than after. +// +// The caller must hold `W4A8ScratchState::mu`. +void* acquire_w4a8_slab(sycl::queue* q, size_t bytes, size_t buf_loc) { + auto* pool = DeviceMemoryPool::Instance(); + const size_t held = pool->get_scratch_size(buf_loc, q); + if (held != 0 && held < bytes) { + q->wait(); + } + void* ptr = pool->get_scratch_mem(bytes, buf_loc, q); + if (ptr == nullptr) { + // The pool records the slot before checking the result, so a failed + // allocation leaves a {bytes, nullptr} entry behind that would satisfy + // every later request of this size or smaller without ever retrying. + // Drop it so the next call allocates again. + pool->detach_scratch_mem(buf_loc, q); + throw std::runtime_error("moe_gemm_w4a8: failed to allocate device scratch buffer"); + } + auto& st = w4a8_scratch_state(); + const size_t key = pool->get_device_key(q); + if (st.queues.find(key) == st.queues.end()) { + st.queues.emplace(key, *q); + } + return ptr; +} + +} // namespace + +uint8_t* acquire_qact_scratch(sycl::queue* q, size_t bytes) { + if (q == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); + } + if (bytes == 0) return nullptr; + auto& st = w4a8_scratch_state(); + std::lock_guard lock(st.mu); + return static_cast(acquire_w4a8_slab(q, bytes, kW4A8QactScratchLoc)); +} + +int* acquire_expert_map_scratch(sycl::queue* q, size_t bytes) { + if (q == nullptr) { + throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); + } + if (bytes == 0) return nullptr; + auto& st = w4a8_scratch_state(); + std::lock_guard lock(st.mu); + return static_cast(acquire_w4a8_slab(q, bytes, kW4A8ExpertMapScratchLoc)); +} + +void moe_w4a8_release_scratch() { + auto& st = w4a8_scratch_state(); + + // Detach everything under the lock, then drop the lock before the device sync + // and the frees: `wait()` blocks for an unbounded time and must not be held + // across. Because the slabs are already out of the pool's tables, an acquire + // that races in behind us allocates fresh ones instead of handing back a + // pointer we are about to free. + std::vector> pending; + { + std::lock_guard lock(st.mu); + auto* pool = DeviceMemoryPool::Instance(); + for (auto& kv : st.queues) { + sycl::queue q = kv.second; + for (size_t loc : {kW4A8QactScratchLoc, kW4A8ExpertMapScratchLoc}) { + void* ptr = pool->detach_scratch_mem(loc, &q); + if (ptr != nullptr) pending.emplace_back(q, ptr); + } + } + st.queues.clear(); + } + + for (auto& item : pending) { + item.first.wait(); + sycl::free(item.second, item.first); + } +} + +} // namespace moe_w4a8 +} // namespace ark + +#endif // ARK_XPU && ARK_SYCL_TLA diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp index 75ecdc6d31..8047d2c5e8 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp @@ -7,10 +7,11 @@ // the generated `sycl_tla_moe_w4a8.cpp` peak at ~4.2 GB of compiler RSS. // // This header carries the part the public entry points need and no kernels: -// the scratch pools, the host-side shape/environment helpers, POD parameter -// structs, and declarations of the per-variant `dispatch` entry points. It -// includes `sycl_tla_moe_decode.hpp` only, which is cutlass-free, so the -// dispatcher TU that includes *this* header compiles for almost nothing. +// the host-side shape/environment helpers, POD parameter structs, and +// declarations of the per-variant `dispatch` entry points. Everything it +// includes is cutlass-free, so the dispatcher TU that includes *this* header +// compiles for almost nothing. The device scratch pools are declared in +// `sycl_tla_moe_w4a8_scratch.hpp` and defined in the matching `.cpp`. // // Each declared entry point is defined in its own generated TU (see // `sycl_tla_generation.cmake`), mirroring how `sycl_tla_moe_prefill_s4_- @@ -40,11 +41,10 @@ // `env_flag_enabled`, `fill_expert_id_per_token`, `SG_SIZE` / `N_TILE`. // Cutlass-free, unlike the DPAS headers. #include "sycl_tla_moe_decode.hpp" -// `DeviceMemoryPool`, backing the scratch slabs below. Before the split this -// header's contents lived in `sycl_tla_moe_w4a8.hpp`, which picked the pool up -// transitively through `sycl_tla_moe_prefill_int_dpas.hpp`; that chain runs -// through cutlass, so the include has to be explicit here. -#include "utils.hpp" +// Scratch slab acquire/release. Declarations only -- the `DeviceMemoryPool` +// bookkeeping lives in `sycl_tla_moe_w4a8_scratch.cpp`, which keeps `utils.hpp` +// and the bestla JIT headers behind it out of this include chain. +#include "sycl_tla_moe_w4a8_scratch.hpp" namespace ark { namespace moe_w4a8 { @@ -61,101 +61,6 @@ constexpr float kInt8Max = 127.0f; // packed nibbles). Requires `K % 8 == 0`, which the shape gate enforces. constexpr int kPrepackOctet = 8; -// --------------------------------------------------------------------------- -// Scratch pools. -// -// The activation-quantization buffers (`[total_tokens, K]` int8 + -// `[total_tokens]` fp32) and the decode expert map (`[total_tokens]` int32) -// are recomputed on every call, so they come from persistent slabs instead of -// a hot-path `malloc_device`. -// -// The slabs are served from the extension-wide `DeviceMemoryPool`, which keys -// on the device UUID rather than on a `sycl::queue*`: a slab therefore follows -// the device and is immune to the caller destroying its queue and to a later -// queue landing on the same address. This mirrors how the int4 decode scratch -// is managed in `sycl_tla_moe_decode_scratch.{hpp,cpp}`. -// -// Slabs are intentionally never freed from a static destructor -- the SYCL -// context may already be torn down by then. `moe_w4a8_release_scratch` -// provides the explicit teardown (exposed to Python under the same name). -// -// Sharing one slab per device means these entry points must not be driven -// concurrently from two queues on one device, which matches every other -// `DeviceMemoryPool` slot. -// --------------------------------------------------------------------------- - -// `DeviceMemoryPool` slots owned by the W4A8 path. Slots 0-7 belong to the -// dnnl / xpu / sycl-s8 / cpu wrappers and the SDPA kernels, slot 8 to the DPAS -// work-group counter, and slots 9-10 to the int4 decode scratch. -inline constexpr size_t kW4A8QactScratchLoc = 11; -inline constexpr size_t kW4A8ExpertMapScratchLoc = 12; - -struct W4A8ScratchState { - std::mutex mu; - // Device key (`DeviceMemoryPool::get_device_key`) -> a queue handle on that - // device, held *by value*: a `sycl::queue` is a reference-counted handle, so - // keeping a copy guarantees the queue outlives the memory allocated against - // it. - std::map queues; -}; - -// Intentionally leaked, see above. -inline W4A8ScratchState& w4a8_scratch_state() { - static W4A8ScratchState* s = new W4A8ScratchState(); - return *s; -} - -// Acquire a slab from the shared pool, synchronizing first when the request -// grows it: `DeviceMemoryPool` frees the old pointer in place when it grows a -// slot, and in-flight kernels may still be reading the old slab, so the wait -// has to happen before the call rather than after. -// -// The caller must hold `W4A8ScratchState::mu`. -inline void* acquire_w4a8_slab(sycl::queue* q, size_t bytes, size_t buf_loc) { - auto* pool = DeviceMemoryPool::Instance(); - const size_t held = pool->get_scratch_size(buf_loc, q); - if (held != 0 && held < bytes) { - q->wait(); - } - void* ptr = pool->get_scratch_mem(bytes, buf_loc, q); - if (ptr == nullptr) { - // The pool records the slot before checking the result, so a failed - // allocation leaves a {bytes, nullptr} entry behind that would satisfy - // every later request of this size or smaller without ever retrying. - // Drop it so the next call allocates again. - pool->detach_scratch_mem(buf_loc, q); - throw std::runtime_error("moe_gemm_w4a8: failed to allocate device scratch buffer"); - } - auto& st = w4a8_scratch_state(); - const size_t key = pool->get_device_key(q); - if (st.queues.find(key) == st.queues.end()) { - st.queues.emplace(key, *q); - } - return ptr; -} - -// Quantized activations + per-token scales. -inline uint8_t* acquire_qact_scratch(sycl::queue* q, size_t bytes) { - if (q == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); - } - if (bytes == 0) return nullptr; - auto& st = w4a8_scratch_state(); - std::lock_guard lock(st.mu); - return static_cast(acquire_w4a8_slab(q, bytes, kW4A8QactScratchLoc)); -} - -// Decode expert map (`[total_tokens]` int32). -inline int* acquire_expert_map_scratch(sycl::queue* q, size_t bytes) { - if (q == nullptr) { - throw std::invalid_argument("moe_gemm_w4a8: device scratch requires a non-null SYCL queue"); - } - if (bytes == 0) return nullptr; - auto& st = w4a8_scratch_state(); - std::lock_guard lock(st.mu); - return static_cast(acquire_w4a8_slab(q, bytes, kW4A8ExpertMapScratchLoc)); -} - // Host-side helpers // --------------------------------------------------------------------------- @@ -238,34 +143,6 @@ inline W4A8PrefillTile moe_w4a8_prefill_select_tile(int A_avg_M, int N) { return W4A8PrefillTile::M128; } -inline void moe_w4a8_release_scratch() { - auto& st = w4a8_scratch_state(); - - // Detach everything under the lock, then drop the lock before the device sync - // and the frees: `wait()` blocks for an unbounded time and must not be held - // across. Because the slabs are already out of the pool's tables, an acquire - // that races in behind us allocates fresh ones instead of handing back a - // pointer we are about to free. - std::vector> pending; - { - std::lock_guard lock(st.mu); - auto* pool = DeviceMemoryPool::Instance(); - for (auto& kv : st.queues) { - sycl::queue q = kv.second; - for (size_t loc : {kW4A8QactScratchLoc, kW4A8ExpertMapScratchLoc}) { - void* ptr = pool->detach_scratch_mem(loc, &q); - if (ptr != nullptr) pending.emplace_back(q, ptr); - } - } - st.queues.clear(); - } - - for (auto& item : pending) { - item.first.wait(); - sycl::free(item.second, item.first); - } -} - } // namespace moe_w4a8 // --------------------------------------------------------------------------- diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_scratch.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_scratch.hpp new file mode 100644 index 0000000000..64e2e09731 --- /dev/null +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_scratch.hpp @@ -0,0 +1,69 @@ +// Device scratch for the W4A8 MoE path. +// +// The activation-quantization buffers (`[total_tokens, K]` int8 + +// `[total_tokens]` fp32) and the decode expert map (`[total_tokens]` int32) are +// recomputed on every call, so they come from persistent slabs instead of a +// hot-path `malloc_device`. +// +// The slabs are served from the extension-wide `DeviceMemoryPool`, which keys +// on the device UUID rather than on a `sycl::queue*`: a slab therefore follows +// the device and is immune to the caller destroying its queue and to a later +// queue landing on the same address. +// +// Slabs are intentionally never freed from a static destructor -- the SYCL +// context may already be torn down by then. `moe_w4a8_release_scratch` provides +// the explicit teardown (exposed to Python under the same name). +// +// Sharing one slab per device means these entry points must not be driven +// concurrently from two queues on one device, which matches every other +// `DeviceMemoryPool` slot. +// +// Only the declarations live here; the bookkeeping is defined in +// `sycl_tla_moe_w4a8_scratch.cpp` so that the module holds exactly one instance +// of it and -- just as importantly -- so that `utils.hpp` stays out of this +// include chain. `utils.hpp` drags in bestla's AVX512/xbyak JIT headers, which +// would grow every cutlass-free W4A8 translation unit by an order of magnitude +// and undo much of the TU split. This mirrors +// `sycl_tla_moe_decode_scratch.{hpp,cpp}`. +// +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#ifdef ARK_XPU +#include +#endif + +#if defined(ARK_XPU) && defined(ARK_SYCL_TLA) + +namespace ark { +namespace moe_w4a8 { + +// `DeviceMemoryPool` slots owned by the W4A8 path. Slots 0-7 belong to the +// dnnl / xpu / sycl-s8 / cpu wrappers and the SDPA kernels, slot 8 to the DPAS +// work-group counter, and slots 9-10 to the int4 decode scratch. +inline constexpr size_t kW4A8QactScratchLoc = 11; +inline constexpr size_t kW4A8ExpertMapScratchLoc = 12; + +// Acquire the quantized-activation slab (int8 activations + per-token scales). +// Returns `nullptr` for a zero-byte request; throws `std::invalid_argument` on +// a null queue and `std::runtime_error` if the pool cannot allocate. +uint8_t* acquire_qact_scratch(sycl::queue* q, size_t bytes); + +// Acquire the decode expert map (`[total_tokens]` int32). Same contract. +int* acquire_expert_map_scratch(sycl::queue* q, size_t bytes); + +// Release both slabs for every device they were allocated on. Must not overlap +// an acquire on the same device: the acquire entry points hand out a raw +// pointer, so a caller that is between the acquire and its kernel submission +// would have the memory freed underneath it. +void moe_w4a8_release_scratch(); + +} // namespace moe_w4a8 +} // namespace ark + +#endif // ARK_XPU && ARK_SYCL_TLA From c9adeed1ea7fcd5a00e2259bc0f8ad8328777419 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:29:46 +0000 Subject: [PATCH 090/112] docs: document the W4A8 scratch TU split in both READMEs and the header layout note Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 7 +++++-- auto_round_extension/ark/test/README_MOE_W4A8.md | 12 +++++++++--- auto_round_extension/ark/test/README_MOE_W4A8_CN.md | 12 +++++++++--- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 2117f692d1..565f6857d9 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -28,10 +28,13 @@ // // File layout // ----------- -// The W4A8 path is spread over three headers so that no translation unit pays +// The W4A8 path is spread over four headers so that no translation unit pays // for more than it uses: // -// sycl_tla_moe_w4a8_helpers.hpp declarations, host helpers, scratch pools +// sycl_tla_moe_w4a8_scratch.hpp device scratch slabs; declarations only, so +// that `utils.hpp` (and bestla's JIT headers +// behind it) stay out of the light TUs +// sycl_tla_moe_w4a8_helpers.hpp declarations, host helpers, tile ladder // (cutlass-free; what the dispatcher sees) // sycl_tla_moe_w4a8_kernels.hpp activation quant, AUTO_S8 prepack, decode // GEMV (cutlass-free, plain SYCL) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index eea17b75e8..7fc2d68fc4 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1052,12 +1052,13 @@ failing. ## Source layout -The path is split across three headers by cutlass dependency, so that no single -translation unit compiles more than a handful of kernels: +The path is split across four headers by cutlass (and bestla) dependency, so +that no single translation unit compiles more than a handful of kernels: | Header | Contents | Needs CuTe | | --- | --- | --- | -| `sycl_tla_moe_w4a8_helpers.hpp` | Scratch pools, host helpers, the prefill tile ladder, the four public entry points | no | +| `sycl_tla_moe_w4a8_scratch.hpp` / `.cpp` | Device scratch slabs (`DeviceMemoryPool`), declared in the header and defined in the `.cpp` | no | +| `sycl_tla_moe_w4a8_helpers.hpp` | Host helpers, the prefill tile ladder, the four public entry points | no | | `sycl_tla_moe_w4a8_kernels.hpp` | Activation quantization, AUTO_S8 prepack, decode GEMV and its K-split variants | no | | `sycl_tla_moe_w4a8.hpp` | DPAS tile policies, the grouped prefill GEMM, its launcher | yes | @@ -1069,6 +1070,11 @@ kernels and peaked at ~4.2 GB of compiler RSS; the layout mirrors how `sycl_tla_moe_prefill_s4_*.cpp` splits the S4 prefill. Nothing about the runtime API or the dispatch decisions changes. +The scratch pools are split off the same way `sycl_tla_moe_decode_scratch.{hpp,cpp}` +is: they need `DeviceMemoryPool` from `utils.hpp`, which drags in bestla's +AVX512/xbyak JIT headers, and including that from a header would grow every +cutlass-free W4A8 TU from ~3.7k to ~44k header lines. + ## Status The W4A8 kernel is a new SYCL/CuTe port, marked diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 6e26ef622b..245bbe8a97 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -929,12 +929,13 @@ decode 的 K-split 映射还额外要求重缩放 block 不小于 256 且是 16 ## 源码结构 -整条路径按对 cutlass 的依赖拆成三个头文件,使得任何一个翻译单元都不会实例化过多 -kernel: +整条路径按对 cutlass(以及 bestla)的依赖拆成四个头文件,使得任何一个翻译单元都不会 +实例化过多 kernel: | 头文件 | 内容 | 需要 CuTe | | --- | --- | --- | -| `sycl_tla_moe_w4a8_helpers.hpp` | scratch 池、host 辅助函数、prefill tile 阶梯、四个对外入口 | 否 | +| `sycl_tla_moe_w4a8_scratch.hpp` / `.cpp` | 设备 scratch 显存(`DeviceMemoryPool`),头文件只放声明,实现放在 `.cpp` | 否 | +| `sycl_tla_moe_w4a8_helpers.hpp` | host 辅助函数、prefill tile 阶梯、四个对外入口 | 否 | | `sycl_tla_moe_w4a8_kernels.hpp` | 激活量化、AUTO_S8 prepack、decode GEMV 及其 K-split 变体 | 否 | | `sycl_tla_moe_w4a8.hpp` | DPAS tile policy、分组 prefill GEMM 及其 launcher | 是 | @@ -945,6 +946,11 @@ dtype 一个)。拆分之前,单个翻译单元要实例化全部 52 个 ker 4.2 GB;这里的拆法与 `sycl_tla_moe_prefill_s4_*.cpp` 拆分 S4 prefill 的方式一致。 运行时 API 与各项 dispatch 决策均不受影响。 +scratch 池按照 `sycl_tla_moe_decode_scratch.{hpp,cpp}` 的同样方式单独拆出:它需要 +`utils.hpp` 里的 `DeviceMemoryPool`,而该头文件会带入 bestla 的 AVX512/xbyak JIT +头文件;如果从头文件包含,每个不依赖 cutlass 的 W4A8 翻译单元的头文件行数会从约 +3.7k 膨胀到约 44k。 + ## 状态 W4A8 kernel 是新移植的 SYCL/CuTe 实现,在 From 4c1fef56b1592ab4622a828c1fce4a95e8f456d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:55:34 +0000 Subject: [PATCH 091/112] test: sweep W4A8 prefill prefetch depth over 1-8 at the long-prompt routing Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 85 ++++++++++++++++++- .../ark/test/README_MOE_W4A8_CN.md | 72 +++++++++++++++- .../ark/test/test_moe_w4a8_perf.py | 70 ++++++++++++++- 3 files changed, 224 insertions(+), 3 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 7fc2d68fc4..4f416d2d0f 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -208,6 +208,59 @@ prefill are far below it, so readings of 0.55–0.71× are expected there: W4A8 a large-batch prefill optimization, and at decode it can only help by improving the *memory* path. +### Why `vs w4a16` is ~1.2–1.7x at large batches + +Above the crossover the ratio stops being about the crossover and becomes a +traffic ratio. A B70 reading of the shipped contract at one 8K prompt: + +| shape | E | N | K | rows/E | w4a16 | w4a8 | TFLOPS | vs w4a16 | +|---|---|---|---|---|---|---|---|---| +| qwen3 up | 128 | 1536 | 2048 | 512 | 3.964 ms | 2.845 ms | 144.92 | 1.39x | +| qwen3 down | 128 | 2048 | 768 | 512 | 2.413 ms | 2.062 ms | 99.98 | 1.17x | +| minimax up | 192 | 1536 | 3072 | 341 | 6.828 ms | 4.358 ms | 141.91 | 1.57x | +| minimax down | 192 | 3072 | 1536 | 341 | 7.023 ms | 4.260 ms | 145.18 | 1.65x | + +Those are not four readings of the same kernel quality. Put both paths through +`_traffic_bytes` and the W4A8 call moves **1.43–1.81x the bytes** the int4 path +does — the doubled weights, plus the activation round-trip the int4 path never +pays. The int4 path is still dequant-bound at 151–195 GB/s; W4A8 runs at +325–439 GB/s, i.e. into the memory wall. Against the 439 GB/s the fastest row +demonstrates: + +| shape | ceiling | measured | % of roofline | +|---|---|---|---| +| qwen3 up | 158.7 | 144.92 | 91% | +| qwen3 down | 134.9 | 99.98 | **74%** | +| minimax up | 142.0 | 141.91 | 100% | +| minimax down | 158.7 | 145.18 | 92% | + +So three of the four are within 8–9% of what the part can stream, and the +`vs w4a16` spread tracks arithmetic intensity (K), not kernel quality: qwen3 +trails minimax because its K is smaller, so the weight term it amortizes the +activation streams against is smaller too. No tile, store, epilogue or +scheduling change moves any of it — every one of those configurations moves the +same bytes. The two levers that do are the [call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) +below, which on this reading project 2.0–2.5x; `qwen3 down` is the one shape +with kernel headroom left, which is what +[Prefetch depth and K](#prefetch-depth-and-k) is about. + +### Why the int8 weight copy is still there + +The remaining traffic lever is the prepack itself: keeping int4 in DRAM and +widening to int8 in-register would drop `E × N × K / 2` bytes — 18% of qwen3 +up's traffic, 24% of minimax up's — and halve the [prepack footprint](#memory-cost) +at the same time. + +It is not implemented because it forfeits the property the design rests on. The +AUTO_S8 rescale exists so the K loop can be a single full-width int32 +accumulation (`blks == 1`); reading int4 puts per-group folding back in the +loop, so at `group_size = 32` one accumulation becomes 64 partial ones. That +trades bandwidth for compute, which is the right direction here — but it is a +mainloop rewrite, not a tuning change, and `sycl_tla_s8_gemm.hpp` takes +`const int8_t* b`, so the in-tree W4A8 GEMM offers no template for it. The +contracts reach the same traffic reduction without touching the mainloop, so +this stays unstarted until a shape is measured short *with* them enabled. + ## Cutting the prefill traffic: two optional call contracts Read the roofline the other way round. At the compute-bound batch the four @@ -1018,6 +1071,36 @@ the device-copy probe actually reaches), so only minimax down clears the 300 GB/s target. A decode step reads one weight byte per multiply-add and nothing else, so the remaining gap is message efficiency, not arithmetic. +### Prefetch depth and K + +`moe_w4a8_prefill_prefetch_dist` returns one constant (`3`) for every shape, and +it stays that way until a sweep says otherwise. + +The reason it is worth questioning is that the constant is a very different +fraction of the mainloop on each shipped shape. At a 64-element k-tile the qwen3 +down-projection (`K = 768`) runs 12 k-tile iterations, qwen3 up (`K = 2048`) runs +32 and minimax up (`K = 3072`) runs 48, so a prologue of 3 is a quarter of the +first loop and a sixteenth of the last. That shape is also the one with +headroom: on B70's shipped contract it reads 74% of its DRAM ceiling where the +other three sit at 91–100%. + +What that does *not* establish is the direction. A shorter prologue starts +computing sooner; a longer one runs the DPAS chain against a deeper memory +pipeline. Both scale with the same k-tile count, and on a mainloop that short +either can dominate. Picking one from the k-tile count alone would be asserting +the answer, so the sweeps below span the whole legal range at both prefill +points and the default only moves if they separate the shapes: + +```bash +pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v +python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq +``` + +A K-aware default is justified only if some depth beats `3` on qwen3 down *and* +`3` (or another depth) still wins on the long-K shapes. If one depth wins +everywhere, the constant simply changes value; if the ranking is flat, the +prologue was never the limiter and the 74% belongs to something else. + ## Environment variables | Variable | Effect | @@ -1033,7 +1116,7 @@ nothing else, so the remaining gap is message efficiency, not arithmetic. | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | -| `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` times `2 / 3 / 4 / 6`. Values outside `1`–`8` fall back to the default. | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The default is a single constant on purpose — see [Prefetch depth and K](#prefetch-depth-and-k). | ## Shape constraints diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 245bbe8a97..0529ae94f3 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -187,6 +187,50 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 0.55–0.71× 是预期结果:W4A8 是面向大 batch prefill 的优化,在 decode 阶段只能通过 改善**访存**路径来获益。 +### 为什么大 batch 下 `vs w4a16` 只有约 1.2–1.7x + +越过临界点之后,这个比值就不再取决于临界点,而是变成了一个流量比。B70 上以单条 8K +提示词测得的已发布契约数据: + +| 形状 | E | N | K | rows/E | w4a16 | w4a8 | TFLOPS | vs w4a16 | +|---|---|---|---|---|---|---|---|---| +| qwen3 up | 128 | 1536 | 2048 | 512 | 3.964 ms | 2.845 ms | 144.92 | 1.39x | +| qwen3 down | 128 | 2048 | 768 | 512 | 2.413 ms | 2.062 ms | 99.98 | 1.17x | +| minimax up | 192 | 1536 | 3072 | 341 | 6.828 ms | 4.358 ms | 141.91 | 1.57x | +| minimax down | 192 | 3072 | 1536 | 341 | 7.023 ms | 4.260 ms | 145.18 | 1.65x | + +这四行并不是对同一个内核质量的四次测量。把两条路径都代入 `_traffic_bytes`,W4A8 +搬运的字节数是 int4 路径的 **1.43–1.81 倍**——翻倍的权重,加上 int4 路径根本不用付 +的激活往返。int4 路径仍然受限于反量化,只有 151–195 GB/s;W4A8 跑在 325–439 GB/s, +也就是已经撞上了访存墙。以最快一行实测到的 439 GB/s 为准: + +| 形状 | 上限 | 实测 | 占 roofline | +|---|---|---|---| +| qwen3 up | 158.7 | 144.92 | 91% | +| qwen3 down | 134.9 | 99.98 | **74%** | +| minimax up | 142.0 | 141.91 | 100% | +| minimax down | 158.7 | 145.18 | 92% | + +也就是说四个形状里有三个已经在设备可达带宽的 8–9% 以内,而 `vs w4a16` 的差异跟随的 +是算术强度 (K),不是内核质量:qwen3 落后于 minimax 是因为它的 K 更小,用来摊薄激活 +数据流的权重项也就更小。tile、store、epilogue 或调度上的任何改动都撼动不了这一点—— +这些配置搬运的字节数完全相同。真正有效的两个杠杆是下面的[调用契约](#削减-prefill-流量两个可选的调用契约), +按这组数据推算可达 2.0–2.5x;`qwen3 down` 是唯一还留有内核余量的形状,这正是 +[预取深度与 K](#预取深度与-k) 所讨论的内容。 + +### 为什么仍然保留 int8 权重副本 + +剩下的流量杠杆就是 prepack 本身:让 int4 留在 DRAM 里、在寄存器内扩宽成 int8,可以 +省掉 `E × N × K / 2` 字节——占 qwen3 up 流量的 18%、minimax up 的 24%——同时还能把 +[prepack 显存开销](#内存开销)减半。 + +没有实现它,是因为这会放弃整个设计所依赖的性质。AUTO_S8 重缩放的存在,就是为了让 K +循环成为一次全宽 int32 累加 (`blks == 1`);改读 int4 就把按组折算重新放回循环,于是在 +`group_size = 32` 下一次累加会变成 64 次部分累加。这是用计算换带宽,方向在这里是对的 +——但它是一次 mainloop 重写,而不是调参,而且 `sycl_tla_s8_gemm.hpp` 接收的是 +`const int8_t* b`,树内的 W4A8 GEMM 并不能作为它的模板。调用契约无需触碰 mainloop 就能 +达到同等的流量削减,因此在启用契约*之后*仍然测出有形状不达标之前,这一项不会启动。 + ## 削减 prefill 流量:两个可选的调用契约 把 roofline 反过来读。计算受限 batch 下四个形状跑在各自天花板的 61–76%,也就是说 @@ -896,6 +940,32 @@ legacy GEMV 的收益为 1.09–1.93×。 带宽为基准则是 68–79%),因此只有 minimax down 越过了 300 GB/s 的目标。decode 每做 一次乘加就要读一个权重字节、别无其他,所以剩下的差距在访存消息效率,而不在算力。 +### 预取深度与 K + +`moe_w4a8_prefill_prefetch_dist` 对所有形状都返回同一个常量 (`3`),在扫描给出 +结论之前维持不变。 + +值得质疑它的原因在于:这个常量在各个已发布形状上所占 mainloop 的比例差别很大。 +在 64 元素的 k-tile 下,qwen3 down (`K = 768`) 的 mainloop 只有 12 次迭代, +qwen3 up (`K = 2048`) 是 32 次,minimax up (`K = 3072`) 是 48 次,因此长度为 3 的 +prologue 在第一个循环里占四分之一,在最后一个里只占十六分之一。这个形状也正是 +还有余量的那个:在 B70 的已发布契约下它只达到 DRAM 上限的 74%,而另外三个是 +91–100%。 + +但这并不能确定方向。更短的 prologue 让计算更早开始;更长的则让 DPAS 链跑在更深的 +访存流水线上。两者都随同一个 k-tile 数变化,在这么短的 mainloop 上任何一方都可能 +占优。仅凭 k-tile 数就选定一方等于直接断言结论,因此下面的扫描在两个 prefill 点上 +覆盖完整的合法区间,只有当它们把不同形状区分开时才改动默认值: + +```bash +pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v +python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq +``` + +只有当某个深度在 qwen3 down 上胜过 `3`,*并且* 在长 K 形状上仍由 `3` (或另一个 +深度) 取胜时,K 相关的默认值才成立。如果某个深度处处最优,那就只是常量换个取值; +如果排名没有区分度,说明 prologue 从来不是瓶颈,那 74% 另有原因。 + ## 环境变量 | 变量 | 作用 | @@ -911,7 +981,7 @@ legacy GEMV 的收益为 1.09–1.93×。 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | -| `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` 会对 `2 / 3 / 4 / 6` 计时。超出 `1`–`8` 的取值回退到默认值。 | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。默认值刻意保持为单一常量——参见[预取深度与 K](#预取深度与-k)。 | ## 形状约束 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 0c926e6d64..cead7be665 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1445,8 +1445,17 @@ def run_perf( # written with; the shipped shapes are short in K (12 k-tiles at K = 768, where # a prologue of 3 is a quarter of the whole mainloop), which is exactly the # regime where the depth is worth re-measuring in both directions. +# +# The sweep spans the whole range the kernel accepts (1-8) rather than a window +# around the default. The narrower `2 / 3 / 4 / 6` it used to probe cannot +# distinguish "3 is the optimum" from "3 is the best of the four points that +# were tried": both endpoints are legal values the clamp in +# `moe_w4a8_prefill_prefetch_dist` admits, and on a 12-k-tile mainloop they are +# the two hypotheses that matter -- 1 is the shortest prologue the kernel can +# have, 8 is deep enough to cover two thirds of that mainloop before the first +# DPAS. A K-aware default can only be justified by a sweep that includes them. _PREFILL_PREFETCH_CONFIGS = [ - (f"prefetch {dist}", {"ARK_MOE_W4A8_PREFILL_PREFETCH": str(dist)}) for dist in (2, 3, 4, 6) + (f"prefetch {dist}", {"ARK_MOE_W4A8_PREFILL_PREFETCH": str(dist)}) for dist in (1, 2, 3, 4, 6, 8) ] # Prefill: the two call contracts that cut traffic instead of cycles. @@ -1936,6 +1945,44 @@ def test_perf_prefill_prefetch_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"prefetch depth {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_prefetch_sweep_long_seq(self, request): + """Time the mainloop's prefetch depth at the 8K-prompt routing. + + Same sweep as above at the other prefill point, and the one that + has to decide whether the depth should depend on K. The compute- + bound batch derives its token count per model so every shape lands + on 384 rows per expert; a fixed 8K prompt does not, so the qwen3 + shapes get 512 rows per expert and the minimax ones 341. That is + the routing the shipped-contract numbers were taken at, and the one + where the qwen3 down-projection reads 74% of its DRAM ceiling while + the other three sit at 91-100% -- i.e. the only shape in the suite + with headroom a scheduling change could still take. + + K is what separates it: at a 64-element k-tile its mainloop is 12 + iterations against 32 for qwen3 up and 48 for minimax up, so a + prologue of 3 is a quarter of the loop there and a sixteenth here. + Whether that costs anything is not decidable from the shape alone + -- a shorter prologue starts computing sooner but runs against a + shallower memory pipeline -- so `moe_w4a8_prefill_prefetch_dist` + deliberately stays a single constant until this sweep separates the + two shapes. Making it K-aware on the strength of the k-tile count + alone would be picking one of those two effects by assertion. + + Nothing about the arithmetic changes, so every row must be + bit-identical to the first; only the timing is a measurement. + """ + rows = run_config_sweep( + "prefill", + _PREFILL_PREFETCH_CONFIGS, + models=_models_option(request), + batches=_long_seq_batches(), + ) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"prefetch depth {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_contract_sweep(self, request): """Time the two traffic-cutting call contracts at the compute-bound batch. @@ -2506,6 +2553,16 @@ def _parse_args(argv): "These change what the call moves, so the printed ceiling and BW@100T follow the contract." ), ) + parser.add_argument( + "--prefetch", + action="store_true", + help=( + "Also sweep the prefill mainloop's prefetch depth (1-8) at whichever prefill points are " + "selected. This is the measurement a K-dependent default would have to rest on: the " + "qwen3 down-projection has 12 k-tiles where minimax up has 48, so a fixed depth is a very " + "different fraction of the mainloop on each. Pair with --long-seq for the 8K-prompt routing." + ), + ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") return parser.parse_args(argv) @@ -2578,6 +2635,17 @@ def main(argv=None) -> int: run_config_sweep(phase, configs, dtype=dtype, models=models, batches=_long_seq_batches()) if phase == "prefill" and args.contracts: run_config_sweep(phase, _PREFILL_CONTRACT_CONFIGS, dtype=dtype, models=models) + if phase == "prefill" and args.prefetch: + if not args.long_seq or args.compute_bound: + run_config_sweep(phase, _PREFILL_PREFETCH_CONFIGS, dtype=dtype, models=models) + if args.long_seq: + run_config_sweep( + phase, + _PREFILL_PREFETCH_CONFIGS, + dtype=dtype, + models=models, + batches=_long_seq_batches(), + ) if failures: print() From f98a776a89ecd234badf0fa395439d4777903dbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:53:42 +0000 Subject: [PATCH 092/112] docs: record the measured prefetch sweep and the real prefill limiter Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 161 +++++++++++++----- .../ark/test/README_MOE_W4A8_CN.md | 135 +++++++++++---- 2 files changed, 226 insertions(+), 70 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 4f416d2d0f..14a6d22709 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -58,6 +58,15 @@ An **Intel Arc Pro B60** (Battlemage, `BMG-G21` — the default AOT target So the two targets below are 51% of int8 peak and 66% of pin bandwidth. The `Arc Pro B60 Dual` card exposes two such devices; the kernel sees one. +> **These ceilings are B60's and do not transfer to B70.** The B70 readings +> quoted further down have the W4A16 baseline running qwen3 up at 104 TFLOPS in +> bf16, which is *above* the 98 TFLOPS bf16 peak in the table — so that part is +> larger, and every ceiling, `BW@100T` and PASS/FAIL verdict derived from the +> numbers above is wrong there. The harness probes the device it is on +> (`_device_bandwidth_gbps`, printed as `device copy bandwidth probe:` at the +> top of every perf table); read the verdicts against that line, and re-measure +> this section before quoting it for a non-B60 part. + The goals for this kernel are **prefill > 100 TFLOPS** and **decode > 300 GB/s** of weight bandwidth. Whether the prefill goal is reachable at all is decided by the *routing*, not by the kernel. @@ -242,24 +251,55 @@ scheduling change moves any of it — every one of those configurations moves th same bytes. The two levers that do are the [call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) below, which on this reading project 2.0–2.5x; `qwen3 down` is the one shape with kernel headroom left, which is what -[Prefetch depth and K](#prefetch-depth-and-k) is about. - -### Why the int8 weight copy is still there - -The remaining traffic lever is the prepack itself: keeping int4 in DRAM and -widening to int8 in-register would drop `E × N × K / 2` bytes — 18% of qwen3 -up's traffic, 24% of minimax up's — and halve the [prepack footprint](#memory-cost) -at the same time. - -It is not implemented because it forfeits the property the design rests on. The -AUTO_S8 rescale exists so the K loop can be a single full-width int32 -accumulation (`blks == 1`); reading int4 puts per-group folding back in the -loop, so at `group_size = 32` one accumulation becomes 64 partial ones. That -trades bandwidth for compute, which is the right direction here — but it is a -mainloop rewrite, not a tuning change, and `sycl_tla_s8_gemm.hpp` takes -`const int8_t* b`, so the in-tree W4A8 GEMM offers no template for it. The -contracts reach the same traffic reduction without touching the mainloop, so -this stays unstarted until a shape is measured short *with* them enabled. +[Prefetch depth and K](#prefetch-depth-and-k--measured-and-the-answer-is-no) is about. + +### The int8 weight copy, and the in-tree precedent against it + +The other traffic lever is the prepack itself. Keeping int4 in DRAM and widening +to int8 in-register would drop `E × N × K / 2` bytes — 18% of qwen3 up's +traffic, 24% of minimax up's — and halve the [prepack footprint](#memory-cost), +which is what makes a single-card 30B model spill. + +There is a direct in-tree precedent, and it cuts against the current design. +`sycl_tla_moe_prefill_s4_dpas.hpp` exists because the s4 prefill path used to do +exactly what this kernel's AUTO_S8 prepack does — materialize `[E, N, K]` int8 +from int4 through a workspace (`launch_upcast_int4_sym_to_int8`) and hand the +buffer to an int8 DPAS mainloop. Its header records the outcome: + +> The upcast pass writes `E * N * K` bytes and the mainloop then reads them back +> through L2 — essentially doubling the B-side global-memory traffic vs. a +> direct packed-nibble read. On BMG this workspace round-trip regressed the DPAS +> path *below* the legacy bf16-dequant + stock GEMM fallback for every shape in +> the auto-round prefill sweep. + +That path was rewritten to read packed `[E, N, K/2]` nibbles and unpack in +registers via `NumericArrayConverter` through the +same `cute::reorder(tBrB, tCrB)` machinery this kernel already uses, with a +deferred per-group fold on the `k_tile * tile_k % group_size == 0` boundary. So +the template exists, and an earlier claim in this document that it did not +(pointing at `sycl_tla_s8_gemm.hpp`, which takes `const int8_t* b`) was looking +at the wrong header. + +What is *not* settled is whether it wins here, because W4A8 is not the s4 path. +AUTO_S8 buys a single full-width int32 accumulation (`blks == 1`); reading int4 +puts per-group folding back in the K loop, so at `group_size = 32` one +accumulation becomes 64 partial ones. That trades bandwidth for compute — the +right direction while the kernel is bandwidth-bound, and the wrong one once it +is not. The contracts above are what decide that: they remove ~45% of the +traffic without touching the mainloop, and a kernel that is compute-bound +*after* them would be made slower, not faster, by adding folds to save bytes it +is no longer waiting on. + +One caveat on how much weight to put on that precedent: the regression it +reports is a measurement, but the header that replaced it is itself marked +`STATUS: NEEDS-HARDWARE-VALIDATION -- untested single-pass port`, gated behind an +env switch so it can be neutralized at runtime. It is a template for the +mainloop structure, not evidence that the structure is faster here. + +Hence the order: measure the contracts on B70 first, and only then decide +whether the packed-nibble mainloop is worth building. If a shape is still short +with both contracts on, this is the next change and the s4 header is the +template to copy. ## Cutting the prefill traffic: two optional call contracts @@ -1071,35 +1111,78 @@ the device-copy probe actually reaches), so only minimax down clears the 300 GB/s target. A decode step reads one weight byte per multiply-add and nothing else, so the remaining gap is message efficiency, not arithmetic. -### Prefetch depth and K +### Prefetch depth and K — measured, and the answer is no -`moe_w4a8_prefill_prefetch_dist` returns one constant (`3`) for every shape, and -it stays that way until a sweep says otherwise. +`moe_w4a8_prefill_prefetch_dist` returns one constant (`3`) for every shape. +The sweep that was built to question that has now run on B70, and the constant +stays. -The reason it is worth questioning is that the constant is a very different -fraction of the mainloop on each shipped shape. At a 64-element k-tile the qwen3 -down-projection (`K = 768`) runs 12 k-tile iterations, qwen3 up (`K = 2048`) runs -32 and minimax up (`K = 3072`) runs 48, so a prologue of 3 is a quarter of the -first loop and a sixteenth of the last. That shape is also the one with -headroom: on B70's shipped contract it reads 74% of its DRAM ceiling where the -other three sit at 91–100%. +The reason it was worth questioning is that `3` is a very different fraction of +the mainloop on each shipped shape. At a 64-element k-tile the qwen3 +down-projection (`K = 768`) runs 12 k-tile iterations, qwen3 up (`K = 2048`) +runs 32 and minimax up (`K = 3072`) runs 48, so the prologue is a quarter of the +first loop and a sixteenth of the last. qwen3 down is also the shape with +headroom — it reads 74% of its DRAM ceiling where the other three sit at +91–100%. If the prologue were the reason, depth would separate the shapes. -What that does *not* establish is the direction. A shorter prologue starts -computing sooner; a longer one runs the DPAS chain against a deeper memory -pipeline. Both scale with the same k-tile count, and on a mainloop that short -either can dominate. Picking one from the k-tile count alone would be asserting -the answer, so the sweeps below span the whole legal range at both prefill -points and the default only moves if they separate the shapes: +It does not. B70, both prefill points, `TFLOPS`: + +| depth | up @384 | down @384 | up @512 | down @512 | +|---|---|---|---|---| +| 1 | 123.29 | 85.34 | 135.99 | 89.45 | +| 2 | 122.82 | **85.37** | 136.67 | 92.93 | +| 3 *(default)* | **130.09** | 85.28 | 141.96 | 93.32 | +| 4 | 129.87 | 84.82 | **143.85** | **93.33** | +| 6 | 127.78 | 84.59 | 141.28 | 90.33 | +| 8 | 119.76 | 84.65 | 130.49 | 90.34 | + +The spread is 1.06x at best and the ranking does not hold still: qwen3 down's +"winner" is depth 2 at 384 rows/expert and depth 4 at 512, with depths 2/3/4 +inside 0.1% of each other at 384 — noise, not a signal. The one real effect is +that depth 8 *costs* 3–4% on qwen3 up, i.e. too deep hurts and the default is +already on the flat part of the curve. Nothing here justifies a K-aware +heuristic, and specifically nothing here explains qwen3 down's 74%. + +So the prologue was never the limiter, and the 74% belongs to something else — +see [What is left](#what-is-left). To re-run it on another part: ```bash pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq ``` -A K-aware default is justified only if some depth beats `3` on qwen3 down *and* -`3` (or another depth) still wins on the long-K shapes. If one depth wins -everywhere, the constant simply changes value; if the ranking is flat, the -prologue was never the limiter and the 74% belongs to something else. +### What is left + +With the prologue ruled out by measurement and the tile ladder already swept, +the remaining gap is traffic, and it is not distributed evenly: + +| shape | reads | writes | writes as % | +|---|---|---|---| +| qwen3 up | 805 MB | 336 MB | 29% | +| qwen3 down | 352 MB | 319 MB | **48%** | + +qwen3 down's unreduced `[T, N]` output alone is 268 MB — 40% of everything the +call moves, and its single largest stream, larger than the weights. That is the +whole of its 74%: it is the most write-heavy shape in the suite, and it streams +325 GB/s where qwen3 up manages 401 GB/s — the two figures the harness itself +prints in its `DRAM GB/s` column, so the split above is the measured run +decomposed, not a separate model. No prefetch depth, tile, store mode or +scheduling change moves a write-bound shape, which is exactly what the sweeps +keep reporting. + +The stream is removed — not rescheduled — by the fused reduction contract, which +replaces the `[T, N]` write with a `[batch, N]` accumulate. **These contracts +are implemented and have never been measured on B70**, and they are the only +lever left that changes the byte count: + +```bash +pytest test_moe_w4a8_perf.py -k contracts_long_seq -v +``` + +Both are free in a real MoE layer: `up`/`gate` share activations so the int8 +copy is made once and handed to both, and `down`'s consumer is the unpermute + +weighted sum the epilogue would be doing anyway. Treat them as the calling +convention rather than an optimization. ## Environment variables @@ -1116,7 +1199,7 @@ prologue was never the limiter and the 74% belongs to something else. | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | -| `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The default is a single constant on purpose — see [Prefetch depth and K](#prefetch-depth-and-k). | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-and-the-answer-is-no). | ## Shape constraints diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 0529ae94f3..5d99b9e79b 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -56,6 +56,13 @@ w8[e][n][k] = round( w4[e][n][k] * s[e][n][k / group_size] / sxt[e][n][j] ) 因此下面两个目标分别是 int8 峰值的 51% 和引脚带宽的 66%。`Arc Pro B60 Dual` 卡会 暴露两个这样的设备,kernel 只看到其中一个。 +> **这些上限属于 B60,不能套用到 B70。** 下文引用的 B70 数据里,W4A16 基线在 +> qwen3 up 上跑到了 104 TFLOPS (bf16),*高于*上表中 98 TFLOPS 的 bf16 峰值——说明 +> 那是一块更大的芯片,因而基于上表推导出的所有上限、`BW@100T` 以及 PASS/FAIL 判定 +> 在那里都是错的。测试脚本会探测自己所在的设备 (`_device_bandwidth_gbps`,在每张 +> 性能表顶部打印为 `device copy bandwidth probe:`);请以那一行为准来读判定结果, +> 并在把本节用于非 B60 芯片之前重新测量。 + 本 kernel 的目标是 **prefill > 100 TFLOPS**、**decode 权重带宽 > 300 GB/s**。 prefill 目标是否*可能*达到,取决于**路由**而不是 kernel 本身。 @@ -216,20 +223,46 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 数据流的权重项也就更小。tile、store、epilogue 或调度上的任何改动都撼动不了这一点—— 这些配置搬运的字节数完全相同。真正有效的两个杠杆是下面的[调用契约](#削减-prefill-流量两个可选的调用契约), 按这组数据推算可达 2.0–2.5x;`qwen3 down` 是唯一还留有内核余量的形状,这正是 -[预取深度与 K](#预取深度与-k) 所讨论的内容。 - -### 为什么仍然保留 int8 权重副本 - -剩下的流量杠杆就是 prepack 本身:让 int4 留在 DRAM 里、在寄存器内扩宽成 int8,可以 -省掉 `E × N × K / 2` 字节——占 qwen3 up 流量的 18%、minimax up 的 24%——同时还能把 -[prepack 显存开销](#内存开销)减半。 - -没有实现它,是因为这会放弃整个设计所依赖的性质。AUTO_S8 重缩放的存在,就是为了让 K -循环成为一次全宽 int32 累加 (`blks == 1`);改读 int4 就把按组折算重新放回循环,于是在 -`group_size = 32` 下一次累加会变成 64 次部分累加。这是用计算换带宽,方向在这里是对的 -——但它是一次 mainloop 重写,而不是调参,而且 `sycl_tla_s8_gemm.hpp` 接收的是 -`const int8_t* b`,树内的 W4A8 GEMM 并不能作为它的模板。调用契约无需触碰 mainloop 就能 -达到同等的流量削减,因此在启用契约*之后*仍然测出有形状不达标之前,这一项不会启动。 +[预取深度与 K](#预取深度与-k--已实测结论是否定的) 所讨论的内容。 + +### int8 权重副本,以及树内反对它的先例 + +另一个流量杠杆是 prepack 本身。让 int4 留在 DRAM 里、在寄存器内扩宽成 int8,可以省掉 +`E × N × K / 2` 字节——占 qwen3 up 流量的 18%、minimax up 的 24%——同时还能把 +[prepack 显存开销](#内存开销)减半,而这正是单卡跑 30B 模型会爆显存的原因。 + +树内存在一个直接的先例,而且它的结论与当前设计相反。`sycl_tla_moe_prefill_s4_dpas.hpp` +之所以存在,就是因为 s4 prefill 路径曾经做过与本 kernel 的 AUTO_S8 prepack 完全相同的 +事——通过一块 workspace 把 int4 物化成 `[E, N, K]` int8 +(`launch_upcast_int4_sym_to_int8`),再把这块缓冲交给 int8 DPAS mainloop。该头文件记录了 +结果: + +> upcast pass 写出 `E * N * K` 字节,mainloop 再经由 L2 把它们读回来——相比直接读取 +> packed nibble,这基本上让 B 侧的全局内存流量翻了一倍。在 BMG 上,这次 workspace +> 往返使 DPAS 路径在 auto-round prefill 扫描的*每一个*形状上都退化到了传统 +> bf16-dequant + 标准 GEMM 回退路径*之下*。 + +那条路径后来被改写为直接读取 packed 的 `[E, N, K/2]` nibble,并通过本 kernel 已经在用的 +同一套 `cute::reorder(tBrB, tCrB)` 机制、借助 +`NumericArrayConverter` 在寄存器内解包,同时在 +`k_tile * tile_k % group_size == 0` 边界上做延迟的按组折算。所以模板是存在的;本文档早前 +声称它不存在 (当时指向的是 `sycl_tla_s8_gemm.hpp`,那个接口接收 `const int8_t* b`),那是 +看错了头文件。 + +尚未确定的是它在这里是否划算,因为 W4A8 并不等同于 s4 路径。AUTO_S8 换来的是一次全宽 +int32 累加 (`blks == 1`);改读 int4 就把按组折算重新放回 K 循环,于是在 `group_size = 32` +下一次累加会变成 64 次部分累加。这是用带宽换计算——在 kernel 受带宽限制时方向是对的,而 +一旦不再受带宽限制,方向就是错的。上面的契约正是决定这一点的关键:它们在不触碰 mainloop +的前提下削减约 45% 的流量,而一个在启用契约*之后*已经受计算限制的 kernel,再增加折算去省 +它本来就不必等待的字节,只会更慢而不是更快。 + +关于这个先例该采信到什么程度,有一点需要说明:它报告的那次退化是实测结果,但取代它的那个 +头文件自身标注着 `STATUS: NEEDS-HARDWARE-VALIDATION -- untested single-pass port`,并由 +env 开关控制、以便在运行时直接屏蔽。它提供的是 mainloop 结构的模板,而不是"这个结构在这里 +更快"的证据。 + +因此顺序是:先在 B70 上实测契约,然后再决定 packed-nibble mainloop 值不值得做。如果在两个 +契约都启用之后仍有形状不达标,那它就是下一项改动,而 s4 头文件就是可以照抄的模板。 ## 削减 prefill 流量:两个可选的调用契约 @@ -940,31 +973,71 @@ legacy GEMV 的收益为 1.09–1.93×。 带宽为基准则是 68–79%),因此只有 minimax down 越过了 300 GB/s 的目标。decode 每做 一次乘加就要读一个权重字节、别无其他,所以剩下的差距在访存消息效率,而不在算力。 -### 预取深度与 K +### 预取深度与 K —— 已实测,结论是否定的 -`moe_w4a8_prefill_prefetch_dist` 对所有形状都返回同一个常量 (`3`),在扫描给出 -结论之前维持不变。 +`moe_w4a8_prefill_prefetch_dist` 对所有形状都返回同一个常量 (`3`)。为质疑它而 +搭建的扫描已经在 B70 上跑过,结论是这个常量维持不变。 -值得质疑它的原因在于:这个常量在各个已发布形状上所占 mainloop 的比例差别很大。 -在 64 元素的 k-tile 下,qwen3 down (`K = 768`) 的 mainloop 只有 12 次迭代, -qwen3 up (`K = 2048`) 是 32 次,minimax up (`K = 3072`) 是 48 次,因此长度为 3 的 -prologue 在第一个循环里占四分之一,在最后一个里只占十六分之一。这个形状也正是 -还有余量的那个:在 B70 的已发布契约下它只达到 DRAM 上限的 74%,而另外三个是 -91–100%。 +值得质疑的理由在于:`3` 在各个已发布形状上所占 mainloop 的比例差别很大。在 64 +元素的 k-tile 下,qwen3 down (`K = 768`) 的 mainloop 只有 12 次迭代,qwen3 up +(`K = 2048`) 是 32 次,minimax up (`K = 3072`) 是 48 次,因此 prologue 在第一个 +循环里占四分之一,在最后一个里只占十六分之一。qwen3 down 也正是还有余量的那个 +形状——它只达到 DRAM 上限的 74%,而另外三个是 91–100%。如果 prologue 真是原因, +深度就应该把这些形状区分开。 -但这并不能确定方向。更短的 prologue 让计算更早开始;更长的则让 DPAS 链跑在更深的 -访存流水线上。两者都随同一个 k-tile 数变化,在这么短的 mainloop 上任何一方都可能 -占优。仅凭 k-tile 数就选定一方等于直接断言结论,因此下面的扫描在两个 prefill 点上 -覆盖完整的合法区间,只有当它们把不同形状区分开时才改动默认值: +但它没有。B70,两个 prefill 点,`TFLOPS`: + +| 深度 | up @384 | down @384 | up @512 | down @512 | +|---|---|---|---|---| +| 1 | 123.29 | 85.34 | 135.99 | 89.45 | +| 2 | 122.82 | **85.37** | 136.67 | 92.93 | +| 3 *(默认)* | **130.09** | 85.28 | 141.96 | 93.32 | +| 4 | 129.87 | 84.82 | **143.85** | **93.33** | +| 6 | 127.78 | 84.59 | 141.28 | 90.33 | +| 8 | 119.76 | 84.65 | 130.49 | 90.34 | + +最大差距只有 1.06x,而且排名并不稳定:qwen3 down 的"最优"在 384 rows/expert 下是 +深度 2,在 512 下是深度 4,且 384 时深度 2/3/4 彼此相差不到 0.1%——这是噪声,不是 +信号。唯一真实的效应是深度 8 在 qwen3 up 上*损失* 3–4%,也就是说太深会有害,而默认 +值已经落在曲线的平坦段上。这里没有任何证据支持 K 相关的启发式,尤其没有解释 +qwen3 down 的 74%。 + +所以 prologue 从来不是瓶颈,74% 另有原因——参见[还剩下什么](#还剩下什么)。若要在 +其他芯片上重跑: ```bash pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq ``` -只有当某个深度在 qwen3 down 上胜过 `3`,*并且* 在长 K 形状上仍由 `3` (或另一个 -深度) 取胜时,K 相关的默认值才成立。如果某个深度处处最优,那就只是常量换个取值; -如果排名没有区分度,说明 prologue 从来不是瓶颈,那 74% 另有原因。 +### 还剩下什么 + +在 prologue 已被实测排除、tile 阶梯也已扫描过之后,剩下的差距在于流量,而且它的 +分布并不均匀: + +| 形状 | 读 | 写 | 写占比 | +|---|---|---|---| +| qwen3 up | 805 MB | 336 MB | 29% | +| qwen3 down | 352 MB | 319 MB | **48%** | + +qwen3 down 未规约的 `[T, N]` 输出单独就有 268 MB——占整个调用搬运量的 40%,是它最大 +的单条数据流,比权重还大。这就是那 74% 的全部原因:它是整个测试集中写入占比最高的 +形状,只跑出 325 GB/s,而 qwen3 up 能跑到 401 GB/s——这两个数正是测试脚本自己在 +`DRAM GB/s` 一列打印出来的值,所以上面的拆分是对实测结果的分解,而不是另一套模型。 +任何预取深度、tile、store 模式或调度改动都撼动不了一个受写入限制的形状——而这正是 +各轮扫描反复给出的结果。 + +这条数据流不是靠重新调度、而是靠 fused 规约契约*直接消除*的:它把 `[T, N]` 的写入 +换成 `[batch, N]` 的累加。**这两个契约都已实现,但从未在 B70 上测过**,而且它们是 +唯一还能改变字节数的杠杆: + +```bash +pytest test_moe_w4a8_perf.py -k contracts_long_seq -v +``` + +在真实的 MoE 层里这两项都是免费的:`up`/`gate` 共享激活,因此 int8 副本只需做一次 +就能同时喂给两者;而 `down` 的下游本来就是 epilogue 可以顺手完成的 unpermute + 加权 +求和。应当把它们当作调用约定,而不是一项优化。 ## 环境变量 @@ -981,7 +1054,7 @@ python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | -| `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。默认值刻意保持为单一常量——参见[预取深度与 K](#预取深度与-k)。 | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测结论是否定的)。 | ## 形状约束 From 6d17d0a4a78b301c2fe141624deee21104594c43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:07:36 +0000 Subject: [PATCH 093/112] fix: make the W4A8 perf sweeps robust to clock drift Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/test_moe_w4a8_perf.py | 129 ++++++++++++++++-- 1 file changed, 114 insertions(+), 15 deletions(-) diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index cead7be665..de9c114030 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -160,6 +160,11 @@ def _w4a8_skip_reason() -> str: WARMUP = 5 ITERS = 30 +# Rounds the config sweeps round-robin over their configurations. Three is +# enough to separate a real ranking from clock drift without making the sweep +# slower: the per-round iteration count is ITERS // SWEEP_ROUNDS, so the total +# number of timed iterations per configuration is unchanged. +SWEEP_ROUNDS = 3 def _release_xpu_memory() -> None: @@ -1229,15 +1234,35 @@ def run_perf( rescale_group_size=rescale_group_size, ) - w4a8_ms = _xpu_time_ms( - lambda: _w4a8(case, weights_s8, wscales, block, phase, prequant=prequantized, fused=fused_reduce) - ) - torch_ms = _xpu_time_ms(lambda: _torch_baseline(case)) if torch_baseline else None - try: - w4a16_ms = _xpu_time_ms(lambda: _w4a16(case, phase)) - except Exception as exc: # pragma: no cover - depends on build - print(f"[moe-w4a8-perf] W4A16 timing unavailable for {nk_label}: {exc}") - w4a16_ms = None + # Round-robin over the three implementations rather than timing each + # to completion in turn. Measuring w4a8 first and w4a16 last put the + # two on opposite ends of the sweep's clock droop, which biases the + # headline `vs w4a16` ratio *upward* -- both the numerator being + # measured hot and the denominator cold push it the same way. Taking + # each one's least-throttled round removes that from the ratio; see + # `_sweep_timings` for the measurement this is guarding against. + w4a8_samples, torch_samples, w4a16_samples = [], [], [] + w4a16_failed = None + iters = max(1, ITERS // SWEEP_ROUNDS) + for _ in range(SWEEP_ROUNDS): + w4a8_samples.append( + _xpu_time_ms( + lambda: _w4a8(case, weights_s8, wscales, block, phase, prequant=prequantized, fused=fused_reduce), + iters=iters, + ) + ) + if torch_baseline: + torch_samples.append(_xpu_time_ms(lambda: _torch_baseline(case), iters=iters)) + if w4a16_failed is None: + try: + w4a16_samples.append(_xpu_time_ms(lambda: _w4a16(case, phase), iters=iters)) + except Exception as exc: # pragma: no cover - depends on build + w4a16_failed = exc + if w4a16_failed is not None: + print(f"[moe-w4a8-perf] W4A16 timing unavailable for {nk_label}: {w4a16_failed}") + w4a8_ms = min(w4a8_samples) + torch_ms = min(torch_samples) if torch_samples else None + w4a16_ms = min(w4a16_samples) if w4a16_samples else None active_experts = sum(1 for n_e in case["tpe"] if n_e > 0) tflops = _flops(total_tokens, N, K) / (w4a8_ms * 1e-3) / 1e12 @@ -1485,13 +1510,44 @@ def run_perf( _SWEEP_MIN_SNR_DB = 40.0 +def _sweep_timings(configs, call): + """Time every configuration, round-robin, and return the per-round samples. + + Timing each configuration to completion in turn -- warmup and all its + iterations, then on to the next -- silently ranks by position as much as + by configuration. These shapes draw enough power to droop the clock over + a sweep, so every configuration measured later is handicapped by the + heat the earlier ones produced. On the qwen3 up-proj shape that artefact + is worth 6-7% run to run, which is *larger* than the spread the sweep is + being used to rank: two runs of the prefill prefetch sweep an hour apart + put the optimum at depth 3-4 and then at depth 1-2, with the ordering + essentially reversed and the last-measured configuration slowest in both. + + Round-robin instead, so the drift is spread evenly across configurations + rather than accumulating against the later ones. The caller takes the + minimum across rounds (each configuration at its least-throttled) and + keeps the spread as the noise floor a ranking has to clear. + + ``call`` is invoked as ``call(call_kwargs)`` with the configuration's env + overrides in force. Returns one list of per-round timings per + configuration, positionally aligned with ``configs``. + """ + samples = [[] for _ in configs] + iters = max(1, ITERS // SWEEP_ROUNDS) + for _ in range(SWEEP_ROUNDS): + for idx, (_, overrides, call_kwargs) in enumerate(configs): + with _env_override(**overrides): + samples[idx].append(_xpu_time_ms(lambda: call(call_kwargs), iters=iters)) + return samples + + def _print_sweep_header(title: str, metric: str) -> None: print() print("=" * _PERF_WIDTH) print(title) print( f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'rows/E':>8} " - f"{'config':<22}{'ms':>10}{metric:>10}{'vs default':>12}{'SNR(dB)':>10}" + f"{'config':<22}{'ms':>10}{metric:>10}{'vs default':>12}{'SNR(dB)':>10}{'drift':>9}" ) print("-" * _PERF_WIDTH) @@ -1555,21 +1611,40 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= active_experts = sum(1 for n_e in case["tpe"] if n_e > 0) rows_per_expert = _rows_per_expert(total_tokens, active_experts) + # Correctness first, one configuration at a time, so that only + # the baseline output and the one being compared to it are + # alive: a six-configuration sweep that held every output at + # once would need six [T, N] tensors (1.2 GB on the qwen3 + # up-proj shape). baseline_out = None - baseline_ms = None + snrs = [] for label, overrides, call_kwargs in configs: with _env_override(**overrides): out = _w4a8(case, weights_s8, wscales, block, phase, **call_kwargs) - ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, phase, **call_kwargs)) if need_routing and not call_kwargs.get("fused"): out = _reduce_topk(case, out) if baseline_out is None: # Cloned: the kernel may hand back a reused scratch # buffer, which would make every later comparison # compare a tensor with itself. - baseline_out, baseline_ms, snr = out.clone(), ms, float("inf") + baseline_out = out.clone() + snrs.append(float("inf")) else: - snr = _snr_db(baseline_out.to(torch.float32), out.to(torch.float32)) + snrs.append(_snr_db(baseline_out.to(torch.float32), out.to(torch.float32))) + out = None + baseline_out = None + + samples = _sweep_timings(configs, lambda kw: _w4a8(case, weights_s8, wscales, block, phase, **kw)) + + baseline_ms = None + for idx, (label, overrides, call_kwargs) in enumerate(configs): + # The least-throttled round, and the spread across rounds + # as the noise floor this shape's ranking has to clear. + ms = min(samples[idx]) + noise = (max(samples[idx]) - ms) / ms if ms else 0.0 + snr = snrs[idx] + if baseline_ms is None: + baseline_ms = ms tflops = _flops(total_tokens, N, K) / (ms * 1e-3) / 1e12 gbps = _weight_bytes(active_experts, N, K, 8) / (ms * 1e-3) / 1e9 row = { @@ -1584,6 +1659,7 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= "tokens": total_tokens, "rows_per_expert": rows_per_expert, "w4a8_ms": ms, + "noise": noise, "tflops": tflops, "gbps": gbps, "snr_db": snr, @@ -1598,6 +1674,7 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= print( f"{nk_label:<14}{E:>5}{N:>7}{K:>7}{total_tokens:>8}{rows_per_expert:>8.1f} " f"{label:<22}{ms:>10.3f}{metric:>10.2f}{speedup_txt:>12}{snr_txt:>10}" + f"{noise * 100:>8.1f}%" ) out = None case = weights_s8 = wscales = baseline_out = None @@ -1639,6 +1716,19 @@ def _print_sweep_best(phase, rows) -> None: print(f" {name:<14} no numerically-equivalent configuration") continue best = min(candidates, key=lambda r: r["w4a8_ms"]) + # A winner is only a winner if its lead over the sweep's own + # first (= default) configuration is larger than the drift the + # sweep measured on itself. Below that the ranking is reporting + # which configuration happened to be timed on the coolest + # device, and recommending it bakes an artefact into a default. + noise = max(r.get("noise", 0.0) for r in group) + lead = group[0]["w4a8_ms"] / best["w4a8_ms"] - 1.0 if best["w4a8_ms"] else 0.0 + if lead <= noise: + print( + f" {name:<14} inconclusive: best is {lead * 100:.1f}% ahead of the default, " + f"within the {noise * 100:.1f}% round-to-round drift -- keep the default" + ) + continue metric = f"{best['tflops']:.2f} TFLOPS" if is_prefill else f"{best['gbps']:.1f} GB/s" parts = [f"{k}={v}" for k, v in sorted(best["overrides"].items()) if v is not None] parts += [f"{k}={v}" for k, v in sorted(best.get("call_kwargs", {}).items()) if v] @@ -2565,11 +2655,19 @@ def _parse_args(argv): ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") + parser.add_argument( + "--rounds", + type=int, + default=SWEEP_ROUNDS, + help=f"Rounds the sweeps round-robin over their configurations (default {SWEEP_ROUNDS}). " + "Each round times ITERS // ROUNDS iterations, so the total is unchanged; raise it when the " + "reported drift is comparable to the spread being ranked.", + ) return parser.parse_args(argv) def main(argv=None) -> int: - global WARMUP, ITERS + global WARMUP, ITERS, SWEEP_ROUNDS args = _parse_args(sys.argv[1:] if argv is None else argv) if _W4A8_SKIP: print(f"[moe-w4a8-perf] cannot run: {_W4A8_SKIP}") @@ -2577,6 +2675,7 @@ def main(argv=None) -> int: WARMUP = args.warmup ITERS = args.iters + SWEEP_ROUNDS = max(1, args.rounds) dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16 phases = ("decode", "prefill") if args.phase == "both" else (args.phase,) From a72cd6d4abd643a05fec4e4a309b3fe69a0177c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:10:02 +0000 Subject: [PATCH 094/112] Changes before error encountered Agent-Logs-Url: https://github.com/intel/auto-round/sessions/06f4ff5b-62d7-4720-a710-6bfddfe66d96 Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 80 +++++++++++++------ .../ark/test/README_MOE_W4A8_CN.md | 69 +++++++++++----- 2 files changed, 105 insertions(+), 44 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 14a6d22709..27e6afb2cf 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -251,7 +251,7 @@ scheduling change moves any of it — every one of those configurations moves th same bytes. The two levers that do are the [call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) below, which on this reading project 2.0–2.5x; `qwen3 down` is the one shape with kernel headroom left, which is what -[Prefetch depth and K](#prefetch-depth-and-k--measured-and-the-answer-is-no) is about. +[Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no) is about. ### The int8 weight copy, and the in-tree precedent against it @@ -803,8 +803,14 @@ python test_moe_w4a8_perf.py --contracts # add the int8-in + fused-red python test_moe_w4a8_perf.py --dtype fp16 # fp16 activations python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 +python test_moe_w4a8_perf.py --rounds 5 # more interleaved rounds when drift is high ``` +`--rounds` sets how many times the sweeps round-robin over their +configurations (default 3). Each round times `ITERS // rounds` iterations, so +raising it costs almost nothing; raise it when the `drift` column is +comparable to the spread being ranked. + `--long-seq` also repeats the prefill tile sweep at the 8K prompt when combined with `--sweep-configs`; `--contracts` adds the contract A/B sweep the same way. @@ -1111,11 +1117,11 @@ the device-copy probe actually reaches), so only minimax down clears the 300 GB/s target. A decode step reads one weight byte per multiply-add and nothing else, so the remaining gap is message efficiency, not arithmetic. -### Prefetch depth and K — measured, and the answer is no +### Prefetch depth and K — measured twice, and the answer is no `moe_w4a8_prefill_prefetch_dist` returns one constant (`3`) for every shape. -The sweep that was built to question that has now run on B70, and the constant -stays. +The sweep that was built to question that has now run on B70 twice, and the +constant stays. The reason it was worth questioning is that `3` is a very different fraction of the mainloop on each shipped shape. At a 64-element k-tile the qwen3 @@ -1125,30 +1131,58 @@ first loop and a sixteenth of the last. qwen3 down is also the shape with headroom — it reads 74% of its DRAM ceiling where the other three sit at 91–100%. If the prologue were the reason, depth would separate the shapes. -It does not. B70, both prefill points, `TFLOPS`: +It does not, and the second run is the one that settles it. B70, `TFLOPS`, +run 1 / run 2: | depth | up @384 | down @384 | up @512 | down @512 | |---|---|---|---|---| -| 1 | 123.29 | 85.34 | 135.99 | 89.45 | -| 2 | 122.82 | **85.37** | 136.67 | 92.93 | -| 3 *(default)* | **130.09** | 85.28 | 141.96 | 93.32 | -| 4 | 129.87 | 84.82 | **143.85** | **93.33** | -| 6 | 127.78 | 84.59 | 141.28 | 90.33 | -| 8 | 119.76 | 84.65 | 130.49 | 90.34 | - -The spread is 1.06x at best and the ranking does not hold still: qwen3 down's -"winner" is depth 2 at 384 rows/expert and depth 4 at 512, with depths 2/3/4 -inside 0.1% of each other at 384 — noise, not a signal. The one real effect is -that depth 8 *costs* 3–4% on qwen3 up, i.e. too deep hurts and the default is -already on the flat part of the curve. Nothing here justifies a K-aware -heuristic, and specifically nothing here explains qwen3 down's 74%. - -So the prologue was never the limiter, and the 74% belongs to something else — -see [What is left](#what-is-left). To re-run it on another part: +| 1 | 123.29 / 123.72 | 85.34 / 85.32 | 135.99 / **143.08** | 89.45 / 89.86 | +| 2 | 122.82 / **124.65** | **85.37** / **85.54** | 136.67 / **144.61** | 92.93 / 92.52 | +| 3 *(default)* | **130.09** / 122.65 | 85.28 / 85.14 | 141.96 / 136.44 | 93.32 / **93.16** | +| 4 | 129.87 / 121.34 | 84.82 / 84.81 | **143.85** / 135.26 | **93.33** / 92.97 | +| 6 | 127.78 / 121.02 | 84.59 / 84.79 | 141.28 / 135.14 | 90.33 / 90.63 | +| 8 | 119.76 / 120.14 | 84.65 / 85.10 | 130.49 / 133.05 | 90.34 / 90.77 | + +Read the two runs against each other rather than down either column. On qwen3 +up the ranking **inverted**: run 1 peaked at depth 3–4 and run 2 peaks at depth +1–2, and the run-to-run difference at a *fixed* depth (up to 7.0% at 384, 6.4% +at 512) is larger than the 4–10% spread the sweep is being used to rank. A +ranking cannot survive that. Normalizing each run to its own best shows what is +actually being measured: + +| position swept | 1st | 2nd | 3rd | 4th | 5th | 6th | +|---|---|---|---|---|---|---| +| up @512 run 1 | 94.5 | 95.0 | 98.7 | **100.0** | 98.2 | 90.7 | +| up @512 run 2 | 98.9 | **100.0** | 94.4 | 93.5 | 93.5 | 92.0 | + +Run 2 declines monotonically from the second position, run 1 warms up and then +declines, and the last-measured configuration is the slowest in both. That is a +clock droop over the sweep, not a property of the depth: qwen3 up runs at 144 +TFLOPS of int8 and heats the part faster than the sweep can measure it, so +whichever configuration is timed early wins. + +qwen3 down does not do this — it reproduces to 0.5% at every depth in both runs, +because at 93 TFLOPS and 48% writes it draws much less power. And it is the only +shape with a real signal: depth 1 is genuinely 4% down, depths 2/3/4 are a +plateau, and 6/8 give back ~3%. Both runs agree, and the shipped default sits in +the middle of that plateau. + +So: no K-aware heuristic, and nothing here explains qwen3 down's 74% — see +[What is left](#what-is-left). + +The harness has been fixed rather than the kernel. `_sweep_timings` now +round-robins the configurations across `SWEEP_ROUNDS` rounds instead of running +each to completion in turn, reports each one at its least-throttled round, and +prints the round-to-round `drift` per configuration; the "best configuration" +line is suppressed when the winner's lead is inside that drift. The per-round +iteration count is `ITERS // SWEEP_ROUNDS`, so the sweep is no slower than +before. `run_perf` is interleaved the same way — it used to time w4a8 first and +w4a16 last, which put the numerator and denominator of the headline `vs w4a16` +ratio at opposite ends of the droop and biased it *upward*. ```bash pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v -python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq +python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq --rounds 5 ``` ### What is left @@ -1199,7 +1233,7 @@ convention rather than an optimization. | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | -| `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-and-the-answer-is-no). | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no). | ## Shape constraints diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 5d99b9e79b..c33417562c 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -223,7 +223,7 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 数据流的权重项也就更小。tile、store、epilogue 或调度上的任何改动都撼动不了这一点—— 这些配置搬运的字节数完全相同。真正有效的两个杠杆是下面的[调用契约](#削减-prefill-流量两个可选的调用契约), 按这组数据推算可达 2.0–2.5x;`qwen3 down` 是唯一还留有内核余量的形状,这正是 -[预取深度与 K](#预取深度与-k--已实测结论是否定的) 所讨论的内容。 +[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的) 所讨论的内容。 ### int8 权重副本,以及树内反对它的先例 @@ -704,8 +704,13 @@ python test_moe_w4a8_perf.py --contracts # 追加 int8 输入 + 融合 python test_moe_w4a8_perf.py --dtype fp16 # fp16 激活 python test_moe_w4a8_perf.py --rescale-group-size 256 python test_moe_w4a8_perf.py --warmup 10 --iters 100 +python test_moe_w4a8_perf.py --rounds 5 # drift 偏大时增加轮转次数 ``` +`--rounds` 控制扫描在各配置之间轮转的次数 (默认 3)。每一轮计时 +`ITERS // rounds` 次迭代,因此调大它几乎没有额外开销;当 `drift` 一列与想要区分的 +差距相当时,应当调大它。 + `--long-seq` 与 `--sweep-configs` 一起使用时,还会在 8K 提示词下重跑一遍 prefill 的 tile 扫描;`--contracts` 以同样方式追加契约的 A/B 扫描。 @@ -973,10 +978,10 @@ legacy GEMV 的收益为 1.09–1.93×。 带宽为基准则是 68–79%),因此只有 minimax down 越过了 300 GB/s 的目标。decode 每做 一次乘加就要读一个权重字节、别无其他,所以剩下的差距在访存消息效率,而不在算力。 -### 预取深度与 K —— 已实测,结论是否定的 +### 预取深度与 K —— 已实测两轮,结论是否定的 `moe_w4a8_prefill_prefetch_dist` 对所有形状都返回同一个常量 (`3`)。为质疑它而 -搭建的扫描已经在 B70 上跑过,结论是这个常量维持不变。 +搭建的扫描已经在 B70 上跑过两轮,结论是这个常量维持不变。 值得质疑的理由在于:`3` 在各个已发布形状上所占 mainloop 的比例差别很大。在 64 元素的 k-tile 下,qwen3 down (`K = 768`) 的 mainloop 只有 12 次迭代,qwen3 up @@ -985,29 +990,51 @@ legacy GEMV 的收益为 1.09–1.93×。 形状——它只达到 DRAM 上限的 74%,而另外三个是 91–100%。如果 prologue 真是原因, 深度就应该把这些形状区分开。 -但它没有。B70,两个 prefill 点,`TFLOPS`: +但它没有,而真正给出结论的是第二轮。B70,`TFLOPS`,第 1 轮 / 第 2 轮: | 深度 | up @384 | down @384 | up @512 | down @512 | |---|---|---|---|---| -| 1 | 123.29 | 85.34 | 135.99 | 89.45 | -| 2 | 122.82 | **85.37** | 136.67 | 92.93 | -| 3 *(默认)* | **130.09** | 85.28 | 141.96 | 93.32 | -| 4 | 129.87 | 84.82 | **143.85** | **93.33** | -| 6 | 127.78 | 84.59 | 141.28 | 90.33 | -| 8 | 119.76 | 84.65 | 130.49 | 90.34 | - -最大差距只有 1.06x,而且排名并不稳定:qwen3 down 的"最优"在 384 rows/expert 下是 -深度 2,在 512 下是深度 4,且 384 时深度 2/3/4 彼此相差不到 0.1%——这是噪声,不是 -信号。唯一真实的效应是深度 8 在 qwen3 up 上*损失* 3–4%,也就是说太深会有害,而默认 -值已经落在曲线的平坦段上。这里没有任何证据支持 K 相关的启发式,尤其没有解释 -qwen3 down 的 74%。 - -所以 prologue 从来不是瓶颈,74% 另有原因——参见[还剩下什么](#还剩下什么)。若要在 -其他芯片上重跑: +| 1 | 123.29 / 123.72 | 85.34 / 85.32 | 135.99 / **143.08** | 89.45 / 89.86 | +| 2 | 122.82 / **124.65** | **85.37** / **85.54** | 136.67 / **144.61** | 92.93 / 92.52 | +| 3 *(默认)* | **130.09** / 122.65 | 85.28 / 85.14 | 141.96 / 136.44 | 93.32 / **93.16** | +| 4 | 129.87 / 121.34 | 84.82 / 84.81 | **143.85** / 135.26 | **93.33** / 92.97 | +| 6 | 127.78 / 121.02 | 84.59 / 84.79 | 141.28 / 135.14 | 90.33 / 90.63 | +| 8 | 119.76 / 120.14 | 84.65 / 85.10 | 130.49 / 133.05 | 90.34 / 90.77 | + +要横向比较两轮,而不是纵向读某一列。在 qwen3 up 上排名**发生了反转**:第 1 轮的 +峰值在深度 3–4,第 2 轮的峰值在深度 1–2;而在*同一个深度*上两轮之间的差异 +(@384 最大 7.0%,@512 最大 6.4%) 比这次扫描想要区分的 4–10% 差距还要大。任何排名 +都撑不过这种情况。把每一轮按各自的最优值归一化,就能看出实际被测量的是什么: + +| 扫描位次 | 第 1 | 第 2 | 第 3 | 第 4 | 第 5 | 第 6 | +|---|---|---|---|---|---|---| +| up @512 第 1 轮 | 94.5 | 95.0 | 98.7 | **100.0** | 98.2 | 90.7 | +| up @512 第 2 轮 | 98.9 | **100.0** | 94.4 | 93.5 | 93.5 | 92.0 | + +第 2 轮从第二个位次起单调下降,第 1 轮先热身再下降,而**最后测量的那个配置在两轮 +里都是最慢的**。这是扫描过程中的频率下降 (clock droop),而不是深度本身的性质: +qwen3 up 跑在 144 TFLOPS 的 int8 负载上,把芯片加热的速度快过扫描测量它的速度, +于是谁被排在前面测,谁就赢。 + +qwen3 down 没有这个现象——它在两轮中每个深度都能复现到 0.5% 以内,因为它只有 +93 TFLOPS 且 48% 是写入,功耗低得多。而它也是唯一有真实信号的形状:深度 1 确实慢 +4%,深度 2/3/4 构成一个平台,6/8 又退回约 3%。两轮结论一致,而已发布的默认值正好 +落在这个平台的中间。 + +所以:不需要 K 相关的启发式,而且这里也没有任何东西能解释 qwen3 down 的 +74%——参见[还剩下什么](#还剩下什么)。 + +被修的是测试工具而不是 kernel。`_sweep_timings` 现在会把各个配置在 +`SWEEP_ROUNDS` 轮之间轮转 (round-robin),而不是把每个配置一次跑完,取每个配置 +受降频影响最小的那一轮,并按配置打印轮次间的 `drift`;当优胜者领先幅度落在该 +drift 之内时,"best configuration" 那一行会被抑制。每轮的迭代次数是 +`ITERS // SWEEP_ROUNDS`,因此扫描并不会比以前更慢。`run_perf` 也做了同样的轮转 +改造——它此前先测 w4a8、最后测 w4a16,把headline `vs w4a16` 比值的分子与分母放在 +了降频曲线的两端,从而使该比值*偏高*。 ```bash pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v -python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq +python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq --rounds 5 ``` ### 还剩下什么 @@ -1054,7 +1081,7 @@ pytest test_moe_w4a8_perf.py -k contracts_long_seq -v | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | -| `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测结论是否定的)。 | +| `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的)。 | ## 形状约束 From 4591c4bd898f992c363f3f3756a153614007a841 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:54:29 +0000 Subject: [PATCH 095/112] fix: charge the caller-side reduction to unfused rows in the contract sweep Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 37 ++++- .../ark/test/README_MOE_W4A8_CN.md | 24 +++- .../ark/test/test_moe_w4a8_perf.py | 128 ++++++++++++++++-- 3 files changed, 170 insertions(+), 19 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 27e6afb2cf..4e0e18836f 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1213,10 +1213,39 @@ lever left that changes the byte count: pytest test_moe_w4a8_perf.py -k contracts_long_seq -v ``` -Both are free in a real MoE layer: `up`/`gate` share activations so the int8 -copy is made once and handed to both, and `down`'s consumer is the unpermute + -weighted sum the epilogue would be doing anyway. Treat them as the calling -convention rather than an optimization. +That sweep used to answer the question unfairly, and the bug ran against the +contract. It timed every configuration as a bare GEMM, so the fused row paid for +the reduction inside its epilogue while the unfused rows left a `[T, N]` tensor +their caller still had to reduce — work that was never on anyone's clock. The +sweep now charges each unfused row the reduction it owes and shows it in its own +`+reduce` column, so `vs default` ranks on the cost of *producing the routed +output* rather than of returning from the GEMM. + +Read the two columns as bounds, because neither alone is the answer: + +* `ms` alone (the old behaviour) is the **lower** bound on the contract's value + — it credits the baseline with skipping the reduction entirely. +* `ms + reduce` is the **upper** bound — the reduction is timed as a torch + `index_add_`, which materializes fp32 temporaries a hand-written epilogue + would not. + +For `qwen3 down` the gap between those bounds is not a detail: its unreduced +`[T, N]` output is 268 MB, so the reduction it hands back reads that 268 MB and +writes 67 MB more. That is the single largest stream in the whole call, and the +old accounting billed none of it. + +The charge lands on the down-projection rows only. A MoE layer reduces just the +second GEMM's output; the up/gate result stays expanded, one row per routed +token, straight into SiLU. So the `up` rows show `+reduce` as `0.000` and the +fused contract shows up there as a small *regression* — that is the correct +reading, not a measurement artefact: the fused epilogue scatters where a plain +store would do, and on that projection nothing is saved in exchange. Contract 2 +is a down-projection contract. + +Both contracts are free in a real MoE layer: `up`/`gate` share activations so +the int8 copy is made once and handed to both, and `down`'s consumer is the +unpermute + weighted sum the epilogue would be doing anyway. Treat them as the +calling convention rather than an optimization. ## Environment variables diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index c33417562c..89e72176b7 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -1062,7 +1062,29 @@ qwen3 down 未规约的 `[T, N]` 输出单独就有 268 MB——占整个调用 pytest test_moe_w4a8_perf.py -k contracts_long_seq -v ``` -在真实的 MoE 层里这两项都是免费的:`up`/`gate` 共享激活,因此 int8 副本只需做一次 +这个扫描此前的对比方式并不公平,而且偏差的方向恰好不利于该契约:它把每个配置都只按 +一次裸 GEMM 计时,于是 fused 那一行要在自己的 epilogue 里承担规约开销,而其余各行留下 +一个 `[T, N]` 张量、把规约丢给调用方——那部分工作从来没有被计时。现在扫描会把每个未融合 +配置本应承担的规约计入,并单列出一个 `+reduce` 列,因此 `vs default` 比较的是*产出已规约 +输出*的代价,而不是"从 GEMM 返回"的代价。 + +这两列应当作为上下界来读,因为单看任何一列都不是答案: + +* 只看 `ms` (也就是此前的行为) 是该契约价值的**下界**——它默认基线可以完全跳过规约。 +* `ms + reduce` 是**上界**——规约是用 torch 的 `index_add_` 计时的,它会产生 fp32 临时 + 张量,而手写 epilogue 并不需要。 + +对 `qwen3 down` 来说,这两个界之间的差距并非细节:它未规约的 `[T, N]` 输出有 268 MB, +因此它交还给调用方的那次规约要读这 268 MB、再多写 67 MB。这是整个调用中最大的一条数据 +流,而旧的计费方式一个字节都没算。 + +这笔开销只会记在 down 投影那几行上。MoE 层只对第二个 GEMM 的输出做规约;up/gate 的结果 +仍然是展开状态、每个路由 token 一行,直接进入 SiLU。因此 `up` 那几行的 `+reduce` 显示为 +`0.000`,而 fused 契约在那里会表现为轻微的**退化**——这是正确的读数,而不是测量假象: +fused epilogue 在本可以直接 store 的地方做了 scatter,而在该投影上并没有换来任何节省。 +契约 2 是一个只属于 down 投影的契约。 + +在真实的 MoE 层里这两个契约都是免费的:`up`/`gate` 共享激活,因此 int8 副本只需做一次 就能同时喂给两者;而 `down` 的下游本来就是 epilogue 可以顺手完成的 unpermute + 加权 求和。应当把它们当作调用约定,而不是一项优化。 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index de9c114030..e6729ab7ef 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -725,6 +725,20 @@ def _prequantized(case): return case["qact"], case["ascale"] +def _reduces_topk(nk_label: str) -> bool: + """Whether this projection's output is the one the top-k combine reduces. + + Only the second (down) GEMM of a MoE layer produces rows that are scaled by + the routing weights and summed back per token. The up/gate output stays + expanded -- one row per routed token, straight into SiLU -- so it owes no + caller-side reduction and cannot use the fused epilogue. Charging it one + would credit the fused contract with removing work that never existed, the + same overstatement :func:`_traffic_bytes` avoids by leaving ``fused_rows`` + unset. + """ + return nk_label.strip().endswith("down") + + def _reduce_topk(case, out): """Reduce an unfused ``[T, N]`` output the way a caller would. @@ -840,6 +854,13 @@ def _traffic_bytes(total_tokens, active_experts, N, K, act_bytes=2, out_bytes=2, * ``fused_rows``: the epilogue reduces into a ``[batch, N]`` fp32 accumulator, so instead of writing ``T * N`` elements it reads *and* writes ``batch * N`` fp32 ones. + + This counts one *call*, so the caller-side reduction an unfused result + still owes is deliberately not in here: only the second projection's output + is reduced in a real layer, and the up/gate output feeds SiLU unreduced, so + charging every unfused shape for a reduction would overstate the traffic on + half of them. The contract sweep accounts for that reduction in the timing + instead, where it can be applied per configuration. """ act_read = 0.0 if prequantized else float(total_tokens) * K * act_bytes qact_write_read = (1.0 if prequantized else 2.0) * float(total_tokens) * K @@ -1510,7 +1531,7 @@ def run_perf( _SWEEP_MIN_SNR_DB = 40.0 -def _sweep_timings(configs, call): +def _sweep_timings(configs, call, extra=None): """Time every configuration, round-robin, and return the per-round samples. Timing each configuration to completion in turn -- warmup and all its @@ -1529,25 +1550,36 @@ def _sweep_timings(configs, call): keeps the spread as the noise floor a ranking has to clear. ``call`` is invoked as ``call(call_kwargs)`` with the configuration's env - overrides in force. Returns one list of per-round timings per - configuration, positionally aligned with ``configs``. + overrides in force. ``extra`` is an optional zero-argument callable timed + once per round alongside them, for work that is charged to some + configurations but is not itself one. Returns + ``(per_config_samples, extra_samples)``, the former positionally aligned + with ``configs``. """ samples = [[] for _ in configs] + extra_samples = [] iters = max(1, ITERS // SWEEP_ROUNDS) for _ in range(SWEEP_ROUNDS): for idx, (_, overrides, call_kwargs) in enumerate(configs): with _env_override(**overrides): samples[idx].append(_xpu_time_ms(lambda: call(call_kwargs), iters=iters)) - return samples + if extra is not None: + # Timed inside the round-robin, not after it: a measurement taken + # once at the end of the sweep lands at the sweep's hottest point + # and would be biased high against the configurations it is added + # to, which is the artefact the round-robin exists to remove. + extra_samples.append(_xpu_time_ms(extra, iters=iters)) + return samples, extra_samples -def _print_sweep_header(title: str, metric: str) -> None: +def _print_sweep_header(title: str, metric: str, with_reduce: bool = False) -> None: print() print("=" * _PERF_WIDTH) print(title) + extra = f"{'+reduce':>10}" if with_reduce else "" print( f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'rows/E':>8} " - f"{'config':<22}{'ms':>10}{metric:>10}{'vs default':>12}{'SNR(dB)':>10}{'drift':>9}" + f"{'config':<22}{'ms':>10}{extra}{metric:>10}{'vs default':>12}{'SNR(dB)':>10}{'drift':>9}" ) print("-" * _PERF_WIDTH) @@ -1568,6 +1600,20 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= :func:`_w4a8`. A contract that reduces inside the kernel returns a ``[batch, N]`` tensor where the others return ``[T, N]``, so outputs are put in the same frame (:func:`_reduce_topk`) before they are compared. + + When some configuration fuses the top-k reduction, timing the others as a + bare GEMM would rank them against a baseline that skips work: the fused row + pays for the reduction inside its epilogue while the unfused rows leave a + ``[T, N]`` tensor their caller still has to reduce. Every row therefore also + carries the caller-side reduction it would owe (``+reduce``, zero for the + fused rows), and ``vs default`` ranks on the sum -- the cost of *producing + the routed output*, which is the thing the contract actually changes. + + That sum brackets the win rather than pinning it. The reduction is timed as + :func:`_reduce_topk`, a torch ``index_add_`` that materializes fp32 + temporaries, so it is an upper bound on a hand-written one; the GEMM-only + ``ms`` column is the lower bound. The truth is between the two columns, + which is why both are printed. """ is_prefill = phase == "prefill" # An explicit batch list opts out of the compute-bound derivation, which @@ -1585,6 +1631,7 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= f"W4A8 config sweep [{phase}] (models={'+'.join(n for n, _ in resolved)}, " f"act={str(dtype).split('.')[-1]}) -- same workload, one row per kernel configuration", metric_name, + with_reduce=need_routing, ) if batches is None: batches = _DECODE_BATCHES @@ -1634,7 +1681,20 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= out = None baseline_out = None - samples = _sweep_timings(configs, lambda kw: _w4a8(case, weights_s8, wscales, block, phase, **kw)) + # What an unfused caller still owes after the GEMM returns -- + # but only on the projection whose output is actually reduced. + # Timed inside the round-robin so it is min-filtered on the + # same basis as the configurations it is charged to. + charge_reduce = need_routing and _reduces_topk(nk_label) + unreduced = _w4a8(case, weights_s8, wscales, block, phase) if charge_reduce else None + samples, reduce_samples = _sweep_timings( + configs, + lambda kw: _w4a8(case, weights_s8, wscales, block, phase, **kw), + extra=(lambda: _reduce_topk(case, unreduced)) if charge_reduce else None, + ) + reduce_ms = min(reduce_samples) if reduce_samples else 0.0 + unreduced = None + _release_xpu_memory() baseline_ms = None for idx, (label, overrides, call_kwargs) in enumerate(configs): @@ -1643,8 +1703,12 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= ms = min(samples[idx]) noise = (max(samples[idx]) - ms) / ms if ms else 0.0 snr = snrs[idx] + # The fused epilogue has already done the reduction; every + # other configuration leaves it for the caller. + row_reduce_ms = 0.0 if call_kwargs.get("fused") else reduce_ms + total_ms = ms + row_reduce_ms if baseline_ms is None: - baseline_ms = ms + baseline_ms = total_ms tflops = _flops(total_tokens, N, K) / (ms * 1e-3) / 1e12 gbps = _weight_bytes(active_experts, N, K, 8) / (ms * 1e-3) / 1e9 row = { @@ -1659,11 +1723,13 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= "tokens": total_tokens, "rows_per_expert": rows_per_expert, "w4a8_ms": ms, + "reduce_ms": row_reduce_ms, + "total_ms": total_ms, "noise": noise, "tflops": tflops, "gbps": gbps, "snr_db": snr, - "speedup": baseline_ms / ms if ms else None, + "speedup": baseline_ms / total_ms if total_ms else None, "device_bw_gbps": device_bw, } rows.append(row) @@ -1671,9 +1737,10 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= metric = tflops if is_prefill else gbps snr_txt = "--" if math.isinf(snr) else f"{snr:.1f}" speedup_txt = "--" if row["speedup"] is None else f"{row['speedup']:.2f}x" + reduce_txt = f"{row_reduce_ms:>10.3f}" if need_routing else "" print( f"{nk_label:<14}{E:>5}{N:>7}{K:>7}{total_tokens:>8}{rows_per_expert:>8.1f} " - f"{label:<22}{ms:>10.3f}{metric:>10.2f}{speedup_txt:>12}{snr_txt:>10}" + f"{label:<22}{ms:>10.3f}{reduce_txt}{metric:>10.2f}{speedup_txt:>12}{snr_txt:>10}" f"{noise * 100:>8.1f}%" ) out = None @@ -1686,6 +1753,27 @@ def run_config_sweep(phase, configs, dtype=torch.bfloat16, models=None, verbose= return rows +def _ranked_ms(row): + """The time a configuration is ranked on: GEMM plus any reduction it left. + + Equal to ``w4a8_ms`` for every sweep that has no fused-reduce + configuration, so the dispatch-knob sweeps rank exactly as before. + """ + return row.get("total_ms", row["w4a8_ms"]) + + +def _ranked_noise(row): + """``row``'s round-to-round drift as a fraction of the time it is ranked on. + + The drift is measured on the GEMM call, so charging a configuration an + unmeasured constant on top of it dilutes the same absolute jitter. + """ + ranked = _ranked_ms(row) + if not ranked: + return row.get("noise", 0.0) + return row.get("noise", 0.0) * row["w4a8_ms"] / ranked + + def _print_sweep_best(phase, rows) -> None: """Report, per shape, the fastest numerically-equivalent configuration. @@ -1715,14 +1803,19 @@ def _print_sweep_best(phase, rows) -> None: if not candidates: print(f" {name:<14} no numerically-equivalent configuration") continue - best = min(candidates, key=lambda r: r["w4a8_ms"]) + best = min(candidates, key=_ranked_ms) # A winner is only a winner if its lead over the sweep's own # first (= default) configuration is larger than the drift the # sweep measured on itself. Below that the ranking is reporting # which configuration happened to be timed on the coolest # device, and recommending it bakes an artefact into a default. - noise = max(r.get("noise", 0.0) for r in group) - lead = group[0]["w4a8_ms"] / best["w4a8_ms"] - 1.0 if best["w4a8_ms"] else 0.0 + # ``noise`` is a spread relative to the GEMM time, but the lead is + # a ratio of GEMM+reduce times. Adding the same reduction to both + # sides of that ratio shrinks it, so the gate has to be put on the + # same basis or a real win between two unfused configurations gets + # discarded as drift. + noise = max(_ranked_noise(r) for r in group) + lead = _ranked_ms(group[0]) / _ranked_ms(best) - 1.0 if _ranked_ms(best) else 0.0 if lead <= noise: print( f" {name:<14} inconclusive: best is {lead * 100:.1f}% ahead of the default, " @@ -1733,7 +1826,14 @@ def _print_sweep_best(phase, rows) -> None: parts = [f"{k}={v}" for k, v in sorted(best["overrides"].items()) if v is not None] parts += [f"{k}={v}" for k, v in sorted(best.get("call_kwargs", {}).items()) if v] env = " ".join(parts) or "(defaults)" - print(f" {name:<14} {best['config']:<22} {best['w4a8_ms']:.3f} ms {metric:<16} {env}") + # The metric is derived from the GEMM time, so the GEMM time is what + # is printed beside it; the reduction is shown as a separate term so + # the ranked total stays recoverable without implying the metric was + # computed from it. + ms_txt = f"{best['w4a8_ms']:.3f} ms" + if best.get("reduce_ms"): + ms_txt += f" + {best['reduce_ms']:.3f} reduce" + print(f" {name:<14} {best['config']:<22} {ms_txt:<26} {metric:<16} {env}") # --------------------------------------------------------------------------- From 463dcbe5aa1438d6a205de469b7af94b34687e99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:02:28 +0000 Subject: [PATCH 096/112] test: isolate the int8-in contract and stop fusing the up projection Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 115 ++++++++++++------ .../ark/test/README_MOE_W4A8_CN.md | 84 +++++++++---- .../ark/test/test_moe_w4a8_perf.py | 59 +++++++-- 3 files changed, 189 insertions(+), 69 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 4e0e18836f..4ebe8d8ed2 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -248,9 +248,11 @@ So three of the four are within 8–9% of what the part can stream, and the trails minimax because its K is smaller, so the weight term it amortizes the activation streams against is smaller too. No tile, store, epilogue or scheduling change moves any of it — every one of those configurations moves the -same bytes. The two levers that do are the [call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) -below, which on this reading project 2.0–2.5x; `qwen3 down` is the one shape -with kernel headroom left, which is what +same bytes. The only levers that do are the [call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) +below — and of the two, only [contract 1](#contract-1--caller-supplied-int8-activations) +survives measurement on B70; contract 2 removes bytes but adds more time than it +saves (see [What is left](#what-is-left)). `qwen3 down` is the one shape with +kernel headroom left, which is what [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no) is about. ### The int8 weight copy, and the in-tree precedent against it @@ -285,10 +287,12 @@ AUTO_S8 buys a single full-width int32 accumulation (`blks == 1`); reading int4 puts per-group folding back in the K loop, so at `group_size = 32` one accumulation becomes 64 partial ones. That trades bandwidth for compute — the right direction while the kernel is bandwidth-bound, and the wrong one once it -is not. The contracts above are what decide that: they remove ~45% of the -traffic without touching the mainloop, and a kernel that is compute-bound -*after* them would be made slower, not faster, by adding folds to save bytes it -is no longer waiting on. +is not. Contract 1 is what decides that: it removes ~37% of qwen3 up's traffic +without touching the mainloop, and a kernel that is compute-bound *after* it +would be made slower, not faster, by adding folds to save bytes it is no longer +waiting on. Contract 2 has since been measured and does not enter this +calculation — it regresses, so the ~45% figure an earlier draft used here was +counting bytes that cannot actually be removed at a profit. One caveat on how much weight to put on that precedent: the regression it reports is a measurement, but the header that replaced it is itself marked @@ -296,10 +300,11 @@ reports is a measurement, but the header that replaced it is itself marked env switch so it can be neutralized at runtime. It is a template for the mainloop structure, not evidence that the structure is faster here. -Hence the order: measure the contracts on B70 first, and only then decide -whether the packed-nibble mainloop is worth building. If a shape is still short -with both contracts on, this is the next change and the s4 header is the -template to copy. +Hence the order: measure contract 1 on B70 first, and only then decide whether +the packed-nibble mainloop is worth building. If a shape is still short with it +on, this is the next change and the s4 header is the template to copy — with the +`STATUS` caveat above meaning it has to be validated on hardware, not merely +ported. ## Cutting the prefill traffic: two optional call contracts @@ -423,13 +428,17 @@ traffic model: reachable number for those shapes is the `+ int8 in` column.) Both qwen3 shapes clear 100 TFLOPS: the up projection on contract 1 alone, the -down projection on **both together** — neither is sufficient by itself, at either -routing. `qwen3 down`'s asymptotic ceiling moves from 105 TFLOPS to 155 (int8 -in), 147 (fused) or 267 (both), which is the real statement: with the contracts -the shape stops being routing-limited. +down projection only on **both together** — at either routing. These are projections from a traffic model, not measurements — see -[Status](#status). +[Status](#status) — and the `fused reduce` / `Both` columns are now known to be +wrong. B70 measured contract 2 as a **regression**, because this model prices +the fused epilogue by the bytes it removes and it is in fact priced by the +~134M device-scope atomics it adds ([What is left](#what-is-left)). Read the +`+ int8 in` column as the live projection and the two fused columns as an upper +bound that measurement did not reach; the standing consequence is that +`qwen3 down` has no projected path past 100 TFLOPS left, since its only one ran +through contract 2. ## Decode: coalesced K-split mapping @@ -765,7 +774,10 @@ pytest -v -s test_moe_w4a8_perf.py -k "prequantized or fused_reduce" `test_perf_prefill_contract_sweep` and `test_perf_prefill_contracts_long_seq` time the four combinations of the two [call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) at the two -prefill routings. Because each contract changes what the call moves, every row's +prefill routings, and `test_perf_prefill_prequant_long_seq` isolates contract 1 +at the 8K routing — the one configuration a serving stack can adopt without +touching its layer code, and the one the earlier both-contracts runs could not +see past. Because each contract changes what the call moves, every row's `DRAM GB/s`, `BW@100T` and ceiling are computed from *that row's* traffic model, so the columns stay comparable across contracts. The fused rows are compared against a canonicalized baseline (the unfused output reduced by the harness) @@ -1205,21 +1217,53 @@ scheduling change moves a write-bound shape, which is exactly what the sweeps keep reporting. The stream is removed — not rescheduled — by the fused reduction contract, which -replaces the `[T, N]` write with a `[batch, N]` accumulate. **These contracts -are implemented and have never been measured on B70**, and they are the only -lever left that changes the byte count: +replaces the `[T, N]` write with a `[batch, N]` accumulate. That is the only +lever left that changes the byte count — and on B70 it **loses**: + +| shape | shipped | both contracts | TFLOPS | `DRAM GB/s` | +|---|---|---|---|---| +| qwen3 up | 3.034 ms | 3.254 ms (**0.93x**) | 135.91 → 126.72 | 376.1 → 195.9 | +| qwen3 down | 2.107 ms | 3.198 ms (**0.66x**) | 97.83 → 64.47 | 318.4 → 120.7 | + +Fewer bytes, more time. That combination is the whole finding: the effective +bandwidth column halves, which a bandwidth-bound kernel moving 44% less data +cannot do. Whatever the fused path costs, it is not paying for DRAM. + +It is paying for the epilogue. `store_fused` issues one device-scope +`atomic_add_f32` **per output element** — 100M of them for qwen3 up, 134M for +qwen3 down — and because a scatter cannot use a block store, enabling it also +gives up the 2D store worth a measured 1.12–1.35x. Solve both rows for the +atomic rate and they agree: ~1.3 ms of added time on up, ~1.6 ms on down, both +≈80 G atomics/s. Two shapes, two routings, one constant — the cost model is the +atomic count, and it is set by `T * N`, which no tuning parameter touches. + +So contract 2 is not a contract to take on this hardware. The `[T, N]` write it +deletes is real, but a coalesced 268 MB store beats 134M scattered read-modify- +writes by more than the bytes suggest. + +**Contract 1 has still never been measured on its own.** Both B70 runs so far +enabled the two together, so contract 2's ~1.5 ms swamped whatever the +activation round-trip saved and that run says nothing about the round-trip. Its +own arithmetic is unencumbered: it deletes `3 * T * K` bytes — 402 MB of qwen3 +up's 1141 MB — which at the 376 GB/s the shipped path already streams is +~1.97 ms, or **2.0x vs W4A16's 3.929 ms**. It is also the contract a serving +stack can adopt without touching layer code, since the previous op in most +quantized pipelines already produced int8, and unlike contract 2 it keeps the +result bit-identical: ```bash -pytest test_moe_w4a8_perf.py -k contracts_long_seq -v +pytest test_moe_w4a8_perf.py -k prequant_long_seq -v # contract 1 alone +pytest test_moe_w4a8_perf.py -k contracts_long_seq -v # both, for the contrast ``` -That sweep used to answer the question unfairly, and the bug ran against the -contract. It timed every configuration as a bare GEMM, so the fused row paid for -the reduction inside its epilogue while the unfused rows left a `[T, N]` tensor -their caller still had to reduce — work that was never on anyone's clock. The -sweep now charges each unfused row the reduction it owes and shows it in its own -`+reduce` column, so `vs default` ranks on the cost of *producing the routed -output* rather than of returning from the GEMM. +That second sweep used to answer the question unfairly, and the bug ran against +the contract. It timed every configuration as a bare GEMM, so the fused row paid +for the reduction inside its epilogue while the unfused rows left a `[T, N]` +tensor their caller still had to reduce — work that was never on anyone's clock. +The sweep now charges each unfused row the reduction it owes and shows it in its +own `+reduce` column, so `vs default` ranks on the cost of *producing the routed +output* rather than of returning from the GEMM. It is worth stressing that this +fix moved the accounting in contract 2's favour and contract 2 lost anyway. Read the two columns as bounds, because neither alone is the answer: @@ -1234,13 +1278,12 @@ For `qwen3 down` the gap between those bounds is not a detail: its unreduced writes 67 MB more. That is the single largest stream in the whole call, and the old accounting billed none of it. -The charge lands on the down-projection rows only. A MoE layer reduces just the -second GEMM's output; the up/gate result stays expanded, one row per routed -token, straight into SiLU. So the `up` rows show `+reduce` as `0.000` and the -fused contract shows up there as a small *regression* — that is the correct -reading, not a measurement artefact: the fused epilogue scatters where a plain -store would do, and on that projection nothing is saved in exchange. Contract 2 -is a down-projection contract. +The charge lands on the down-projection rows only, and so does the contract. A +MoE layer reduces just the second GEMM's output; the up/gate result stays +expanded, one row per routed token, straight into SiLU. Fusing there is not a +contract a caller can take, so `run_perf` no longer applies it to those rows — +the earlier table's `up` regression was measuring a configuration nobody can +ship. Contract 2 is a down-projection contract. Both contracts are free in a real MoE layer: `up`/`gate` share activations so the int8 copy is made once and handed to both, and `down`'s consumer is the @@ -1402,7 +1445,7 @@ traffic the call still moves and ceiling the routing sets: | Lead | What it would change | Where it shows | |---|---|---| | Caller-supplied int8 activations ([contract 1](#contract-1--caller-supplied-int8-activations)) | Deletes 3 of the 5 streams — 27% of the traffic at `K = 768`, 37% at `K = 2048`, 44% at `K = 3072` | Every shape; it is the largest single item, and the only one that gets `qwen3 up` past 100 on its own | -| The fused top-k reduction ([contract 2](#contract-2--the-top-k-reduction-fused-into-the-epilogue)) | Turns a `T × N` fp16 write into a `batch × N` fp32 read-modify-write, and deletes the separate reduction kernel | Second projections only; combined with contract 1 it is what gets `qwen3 down` past 100 | +| The fused top-k reduction ([contract 2](#contract-2--the-top-k-reduction-fused-into-the-epilogue)) | Turns a `T × N` fp16 write into a `batch × N` fp32 read-modify-write, and deletes the separate reduction kernel | Second projections only — and **measured slower on B70**, because the read-modify-write is one device-scope atomic per element and forfeits the 2D block store | | Routing more rows per expert | Nothing in the kernel — it *raises* the ceiling, because the weight stream is the only one that does not grow with the token count | Real but bounded: `qwen3 down`'s ceiling converges to 105 TFLOPS as rows/expert → ∞, so this lead alone can never reach the target on that shape | | Prefetch depth, scheduler tile order, non-temporal D stores | Pure mainloop/epilogue work against the 239–296 GB/s the four shapes currently stream | `qwen3 down` is the lowest of the four (239 GB/s): its D is a *write*, and 12 k-tiles is the shortest mainloop, so its prologue/epilogue amortize worst | | A single-pass activation quantizer for `K = 3072` | The second read of `[T, K]`, ~450 MB at the compute-bound batch | minimax up only; its row is 96 dwords per lane, past the 16-vector rung — and moot under contract 1, which deletes the pass entirely | diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 89e72176b7..0041ac3b2b 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -221,8 +221,10 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 也就是说四个形状里有三个已经在设备可达带宽的 8–9% 以内,而 `vs w4a16` 的差异跟随的 是算术强度 (K),不是内核质量:qwen3 落后于 minimax 是因为它的 K 更小,用来摊薄激活 数据流的权重项也就更小。tile、store、epilogue 或调度上的任何改动都撼动不了这一点—— -这些配置搬运的字节数完全相同。真正有效的两个杠杆是下面的[调用契约](#削减-prefill-流量两个可选的调用契约), -按这组数据推算可达 2.0–2.5x;`qwen3 down` 是唯一还留有内核余量的形状,这正是 +这些配置搬运的字节数完全相同。唯一有效的杠杆是下面的[调用契约](#削减-prefill-流量两个可选的调用契约)—— +而其中只有[契约 1](#契约-1--调用方直接提供-int8-激活) 经受住了 B70 的实测;契约 2 虽然删掉了 +字节,增加的时间却超过它节省的时间 (见[还剩下什么](#还剩下什么))。`qwen3 down` 是唯一还留有 +内核余量的形状,这正是 [预取深度与 K](#预取深度与-k--已实测两轮结论是否定的) 所讨论的内容。 ### int8 权重副本,以及树内反对它的先例 @@ -252,17 +254,19 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 尚未确定的是它在这里是否划算,因为 W4A8 并不等同于 s4 路径。AUTO_S8 换来的是一次全宽 int32 累加 (`blks == 1`);改读 int4 就把按组折算重新放回 K 循环,于是在 `group_size = 32` 下一次累加会变成 64 次部分累加。这是用带宽换计算——在 kernel 受带宽限制时方向是对的,而 -一旦不再受带宽限制,方向就是错的。上面的契约正是决定这一点的关键:它们在不触碰 mainloop -的前提下削减约 45% 的流量,而一个在启用契约*之后*已经受计算限制的 kernel,再增加折算去省 -它本来就不必等待的字节,只会更慢而不是更快。 +一旦不再受带宽限制,方向就是错的。契约 1 正是决定这一点的关键:它在不触碰 mainloop 的前提下 +削减 qwen3 up 约 37% 的流量,而一个在启用契约*之后*已经受计算限制的 kernel,再增加折算去省 +它本来就不必等待的字节,只会更慢而不是更快。契约 2 已经实测过,不进入这笔账——它是退化的, +所以本文早先草稿在这里用的约 45% 这个数字,统计的是实际上无法以正收益删掉的字节。 关于这个先例该采信到什么程度,有一点需要说明:它报告的那次退化是实测结果,但取代它的那个 头文件自身标注着 `STATUS: NEEDS-HARDWARE-VALIDATION -- untested single-pass port`,并由 env 开关控制、以便在运行时直接屏蔽。它提供的是 mainloop 结构的模板,而不是"这个结构在这里 更快"的证据。 -因此顺序是:先在 B70 上实测契约,然后再决定 packed-nibble mainloop 值不值得做。如果在两个 -契约都启用之后仍有形状不达标,那它就是下一项改动,而 s4 头文件就是可以照抄的模板。 +因此顺序是:先在 B70 上实测契约 1,然后再决定 packed-nibble mainloop 值不值得做。如果在它 +启用之后仍有形状不达标,那它就是下一项改动,而 s4 头文件就是可以照抄的模板——但上面那条 +`STATUS` 提醒意味着它必须在硬件上验证过,而不只是移植完成。 ## 削减 prefill 流量:两个可选的调用契约 @@ -366,12 +370,15 @@ weight 之后 `atomic_add` 到 `out[row_to_token[r]]`。`T × N × 2` 的写变 (「不适用」= 融合规约不适用于 up/gate 投影,所以这些形状能达到的数字看 `+ int8 输入` 那一列。) -两个 qwen3 形状都能越过 100 TFLOPS:up 投影只靠契约 1 就够,down 投影需要**两者一 -起** — 在两种路由下,单独任何一项都不够。`qwen3 down` 的渐近天花板从 105 TFLOPS 变 -为 155 (int8 输入)、147 (融合规约) 或 267 (两者),这才是真正的结论:有了这两个契 -约,这个形状不再受路由限制。 +两个 qwen3 形状都能越过 100 TFLOPS:up 投影只靠契约 1 就够,down 投影则只有在**两者 +一起**时才行 — 在两种路由下都是如此。 -以上是流量模型的推算,不是实测 — 见[状态](#状态)。 +以上是流量模型的推算,不是实测 — 见[状态](#状态) — 而且 `融合规约` 与 `两者` 这两列 +现在已知是错的。B70 实测契约 2 是**退化的**,因为这个模型按 fused epilogue 删掉的字节 +给它定价,而它实际上是按新增的约 1.34 亿次 device 作用域原子操作定价的 +([还剩下什么](#还剩下什么))。请把 `+ int8 输入` 一列当作仍然成立的推算,把两个融合列 +当作实测未能达到的上界;由此产生的直接后果是:`qwen3 down` 已经没有任何推算上能越过 +100 TFLOPS 的路径了,因为它唯一的那条路径要经过契约 2。 ## Decode:合并访存的 K-split 映射 @@ -669,7 +676,9 @@ pytest -v -s test_moe_w4a8_perf.py -k "prequantized or fused_reduce" `test_perf_prefill_contract_sweep` 和 `test_perf_prefill_contracts_long_seq` 会在 两种 prefill 路由下,对两个[调用契约](#削减-prefill-流量两个可选的调用契约)的四种 -组合分别计时。由于每个契约都会改变这次调用搬运的内容,每一行的 `DRAM GB/s`、 +组合分别计时,而 `test_perf_prefill_prequant_long_seq` 则在 8K 路由下单独测量契约 1—— +它是推理框架无需改动层代码就能采纳的那一种配置,也是先前"两个契约一起开"的测试无法 +看穿的那一种。由于每个契约都会改变这次调用搬运的内容,每一行的 `DRAM GB/s`、 `BW@100T` 和天花板都按**该行自己的**流量模型计算,因此各列在不同契约之间仍然可比。 融合规约的行是与规范化后的基线 (由 harness 对无融合输出做规约) 在 SNR 门槛下比较 的,而不是其它扫描使用的逐位相同。 @@ -1055,18 +1064,46 @@ qwen3 down 未规约的 `[T, N]` 输出单独就有 268 MB——占整个调用 各轮扫描反复给出的结果。 这条数据流不是靠重新调度、而是靠 fused 规约契约*直接消除*的:它把 `[T, N]` 的写入 -换成 `[batch, N]` 的累加。**这两个契约都已实现,但从未在 B70 上测过**,而且它们是 -唯一还能改变字节数的杠杆: +换成 `[batch, N]` 的累加。这是唯一还能改变字节数的杠杆——而在 B70 上它**输了**: + +| 形状 | 已发布契约 | 两个契约都开 | TFLOPS | `DRAM GB/s` | +|---|---|---|---|---| +| qwen3 up | 3.034 ms | 3.254 ms (**0.93x**) | 135.91 → 126.72 | 376.1 → 195.9 | +| qwen3 down | 2.107 ms | 3.198 ms (**0.66x**) | 97.83 → 64.47 | 318.4 → 120.7 | + +字节更少,时间更长。这个组合本身就是全部结论:有效带宽一列直接腰斩,而一个搬运 +量少了 44% 的受带宽限制 kernel 不可能出现这种情况。fused 路径的开销,无论是什么, +都不是花在 DRAM 上的。 + +它花在 epilogue 上。`store_fused` 对**每一个输出元素**都发一次 device 作用域的 +`atomic_add_f32`——qwen3 up 是 1 亿次,qwen3 down 是 1.34 亿次——而且由于 scatter +无法使用 block store,开启它同时也放弃了实测价值 1.12–1.35x 的 2D store。把两行 +数据分别解出原子操作速率,结果是一致的:up 多花约 1.3 ms、down 多花约 1.6 ms,都是 +约 800 亿次原子操作/秒。两个形状、两种路由、同一个常数——代价模型就是原子操作次数, +而它由 `T * N` 决定,任何调优参数都碰不到。 + +所以在这个硬件上,契约 2 不是一个值得采纳的契约。它删掉的那次 `[T, N]` 写入确实存在, +但一次合并的 268 MB store 胜过 1.34 亿次分散的读改写,其优势远超字节数所暗示的程度。 + +**契约 1 至今仍然没有被单独测过。** 目前 B70 上的两轮测试都是把两个契约一起打开的, +于是契约 2 那约 1.5 ms 的开销淹没了激活往返所节省的一切,那轮数据对激活往返本身 +什么都说明不了。而它自己的账是干净的:它删掉 `3 * T * K` 字节——qwen3 up 全部 +1141 MB 中的 402 MB——按已发布路径本就能跑出的 376 GB/s 折算约为 1.97 ms,也就是 +**相对 W4A16 的 3.929 ms 达到 2.0x**。它同时也是推理框架无需改动层代码就能采纳的 +契约,因为多数量化流水线里上一个算子本来就产出 int8;并且与契约 2 不同,它保持结果 +逐位一致: ```bash -pytest test_moe_w4a8_perf.py -k contracts_long_seq -v +pytest test_moe_w4a8_perf.py -k prequant_long_seq -v # 只开契约 1 +pytest test_moe_w4a8_perf.py -k contracts_long_seq -v # 两个都开,用于对照 ``` -这个扫描此前的对比方式并不公平,而且偏差的方向恰好不利于该契约:它把每个配置都只按 +后面这个扫描此前的对比方式并不公平,而且偏差的方向恰好不利于该契约:它把每个配置都只按 一次裸 GEMM 计时,于是 fused 那一行要在自己的 epilogue 里承担规约开销,而其余各行留下 一个 `[T, N]` 张量、把规约丢给调用方——那部分工作从来没有被计时。现在扫描会把每个未融合 配置本应承担的规约计入,并单列出一个 `+reduce` 列,因此 `vs default` 比较的是*产出已规约 -输出*的代价,而不是"从 GEMM 返回"的代价。 +输出*的代价,而不是"从 GEMM 返回"的代价。值得强调的是:这次修正把计费方式改成了对契约 2 +更有利的方向,而契约 2 依然输了。 这两列应当作为上下界来读,因为单看任何一列都不是答案: @@ -1078,11 +1115,10 @@ pytest test_moe_w4a8_perf.py -k contracts_long_seq -v 因此它交还给调用方的那次规约要读这 268 MB、再多写 67 MB。这是整个调用中最大的一条数据 流,而旧的计费方式一个字节都没算。 -这笔开销只会记在 down 投影那几行上。MoE 层只对第二个 GEMM 的输出做规约;up/gate 的结果 -仍然是展开状态、每个路由 token 一行,直接进入 SiLU。因此 `up` 那几行的 `+reduce` 显示为 -`0.000`,而 fused 契约在那里会表现为轻微的**退化**——这是正确的读数,而不是测量假象: -fused epilogue 在本可以直接 store 的地方做了 scatter,而在该投影上并没有换来任何节省。 -契约 2 是一个只属于 down 投影的契约。 +这笔开销只会记在 down 投影那几行上,这个契约本身也是如此。MoE 层只对第二个 GEMM 的输出 +做规约;up/gate 的结果仍然是展开状态、每个路由 token 一行,直接进入 SiLU。在那里做融合 +并不是调用方能够采纳的契约,因此 `run_perf` 不再把它应用到那几行——先前表格中 `up` 的 +退化,测的是一个谁都无法上线的配置。契约 2 是一个只属于 down 投影的契约。 在真实的 MoE 层里这两个契约都是免费的:`up`/`gate` 共享激活,因此 int8 副本只需做一次 就能同时喂给两者;而 `down` 的下游本来就是 epilogue 可以顺手完成的 unpermute + 加权 @@ -1222,7 +1258,7 @@ int32 累加器做 2D 写出的。事实证明那个参考对象选错了:`reo | 方向 | 会改变什么 | 体现在哪里 | |---|---|---| | 调用方直接提供 int8 激活 ([契约 1](#契约-1--调用方直接提供-int8-激活)) | 消掉 5 条数据流中的 3 条 — `K = 768` 时占 27%、`K = 2048` 时 37%、`K = 3072` 时 44% | 所有形状;这是最大的一项,也是唯一能单独把 `qwen3 up` 送过 100 的一项 | -| 融合 top-k 规约 ([契约 2](#契约-2--把-top-k-规约折进-epilogue)) | 把 `T × N` 的 fp16 写变成 `batch × N` 的 fp32 读改写,并删掉独立的规约 kernel | 仅第二个投影;与契约 1 合起来才是把 `qwen3 down` 送过 100 的那一步 | +| 融合 top-k 规约 ([契约 2](#契约-2--把-top-k-规约折进-epilogue)) | 把 `T × N` 的 fp16 写变成 `batch × N` 的 fp32 读改写,并删掉独立的规约 kernel | 仅第二个投影 — 而且在 B70 上**实测更慢**,因为那次读改写是每个元素一次 device 作用域原子操作,还放弃了 2D block store | | 让每个专家分到更多行 | kernel 里什么都不用改 — 它*抬高*的是天花板,因为只有权重这一条流不随 token 数增长 | 有效但有上限:每专家行数趋于无穷时 `qwen3 down` 的天花板收敛到 105 TFLOPS,因此单靠这一项在该形状上永远到不了目标 | | 预取深度、调度器 tile 顺序、非临时 (non-temporal) 的 D store | 纯主循环/epilogue 侧的工作,对手是四个形状当前 239–296 GB/s 的实际带宽 | `qwen3 down` 是四者中最低的 (239 GB/s):它的 D 是**写**,而 12 个 k-tile 是最短的主循环,因此 prologue/epilogue 摊得最差 | | `K = 3072` 的单遍激活量化 | 省掉对 `[T, K]` 的第二次读,在受算力约束的 batch 下约 450 MB | 仅 minimax up;它的一行是每 lane 96 个 dword,超过了 16 向量那一档 — 而且在契约 1 之下已无意义,因为那一遍整个被删掉了 | diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index e6729ab7ef..abd9748d95 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1197,6 +1197,12 @@ def run_perf( contracts (see :func:`_w4a8`). They change what the call moves, so they are also fed to the traffic model: the printed ``DRAM GB/s``, ``BW@100T`` and the ceiling that decides the verdict all follow the contract in force. + + ``fused_reduce`` applies only to the projection whose output is actually + top-k reduced (:func:`_reduces_topk`). Fusing the up/gate projection is not + a contract a layer can take -- that output stays expanded into SiLU -- so + enabling it there measures a configuration nobody can ship and charges the + contract for it. The header says which shapes it reached. """ rows = [] # Probed before anything large is allocated (and cached across sweeps). @@ -1205,7 +1211,7 @@ def run_perf( contract = "".join( [ ", A=int8-in" if prequantized else "", - ", fused top-k reduce" if fused_reduce else "", + ", fused top-k reduce (down-proj only)" if fused_reduce else "", ] ) if verbose: @@ -1224,6 +1230,8 @@ def run_perf( for nk_label, N, K, spec, batch in shapes: E, topk, group_size = spec["E"], spec["topk"], spec["group_size"] total_tokens = batch * topk + # Only the reduced projection can take the fused contract. + fused_here = fused_reduce and _reduces_topk(nk_label) case = _build_case( N, K, @@ -1233,7 +1241,7 @@ def run_perf( dtype, need_reference=False, need_dequant=torch_baseline, - topk=topk if fused_reduce else None, + topk=topk if fused_here else None, ) # One-shot int4 -> int8 AUTO_S8 conversion. Timed separately: it @@ -1268,7 +1276,7 @@ def run_perf( for _ in range(SWEEP_ROUNDS): w4a8_samples.append( _xpu_time_ms( - lambda: _w4a8(case, weights_s8, wscales, block, phase, prequant=prequantized, fused=fused_reduce), + lambda: _w4a8(case, weights_s8, wscales, block, phase, prequant=prequantized, fused=fused_here), iters=iters, ) ) @@ -1294,7 +1302,7 @@ def run_perf( # of A, the int8 copy it writes, the GEMM's read of that copy and the # output. On the small-K shapes the weights are under half of it. act_bytes = _dtype_bytes(dtype) - fused_rows = case["batch"] if fused_reduce else None + fused_rows = case["batch"] if fused_here else None traffic = _traffic_bytes( total_tokens, active_experts, @@ -1354,7 +1362,7 @@ def run_perf( ), "device_bw_gbps": device_bw, "prequantized": prequantized, - "fused_reduce": fused_reduce, + "fused_reduce": fused_here, } rows.append(row) if verbose: @@ -2197,15 +2205,48 @@ def test_perf_prefill_contract_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"call contract {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_prequant_long_seq(self, request): + """Prefill throughput for one 8K prompt with the int8-in contract alone. + + The two contracts were only ever measured together, and together + they regress on B70 (see the README): the fused epilogue's cost + swamps whatever the activation round-trip saves, so that run says + nothing about the round-trip itself. This isolates it. + + It is also the contract a serving stack can actually adopt without + touching its layer code -- the previous op already produced int8 in + most quantized pipelines -- whereas the fused reduction requires the + caller to hand over the routing map and give up a bit-identical + result. Removing the round-trip drops ``3 * T * K`` bytes, a third + of the up projection's traffic, and the traffic model here is told + about it so the printed ceiling matches what the call moves. + """ + rows = run_perf( + "prefill", + _long_seq_batches(), + torch_baseline=False, + models=_models_option(request), + prequantized=True, + ) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + _assert_targets(request, "prefill", rows) + def test_perf_prefill_contracts_long_seq(self, request): """Prefill throughput for one 8K prompt with both contracts enabled. ``test_perf_prefill_long_seq`` measures the shipped contract, where the qwen3 shapes are bandwidth-bound below the target. This runs - the same prompt with the activation round-trip and the unreduced - output removed, which is the configuration the target is reachable - in; the ceiling printed next to it is computed from the same - reduced traffic model, so the verdict is against the right roof. + the same prompt with the activation round-trip removed, and the + output reduction folded into the epilogue on the one projection + that is actually reduced; the ceiling printed next to it is + computed from the same reduced traffic model, so the verdict is + against the right roof. + + On B70 this is *slower* than the shipped contract even though it + moves fewer bytes -- the fused epilogue trades a coalesced 2D block + store for one device-scope atomic per output element. Compare + against ``test_perf_prefill_prequant_long_seq`` to separate the two + contracts' contributions. """ rows = run_perf( "prefill", From 69b44e26796bb160df818c38b4a65f96ad231333 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:15:57 +0000 Subject: [PATCH 097/112] fix: make the bandwidth probe sound and record the measured contract-1 win Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 110 +++++++++++++----- .../ark/test/README_MOE_W4A8_CN.md | 87 ++++++++++---- .../ark/test/test_moe_w4a8_perf.py | 66 ++++++++++- 3 files changed, 203 insertions(+), 60 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 4ebe8d8ed2..f8a2130448 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -248,12 +248,21 @@ So three of the four are within 8–9% of what the part can stream, and the trails minimax because its K is smaller, so the weight term it amortizes the activation streams against is smaller too. No tile, store, epilogue or scheduling change moves any of it — every one of those configurations moves the -same bytes. The only levers that do are the [call contracts](#cutting-the-prefill-traffic-two-optional-call-contracts) -below — and of the two, only [contract 1](#contract-1--caller-supplied-int8-activations) -survives measurement on B70; contract 2 removes bytes but adds more time than it -saves (see [What is left](#what-is-left)). `qwen3 down` is the one shape with -kernel headroom left, which is what -[Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no) is about. +same bytes. + +The only lever that does is [contract 1](#contract-1--caller-supplied-int8-activations), +and on B70 it is worth more than every tuning knob in this document combined: + +| shape | w4a16 | shipped | contract 1 | TFLOPS | vs w4a16 | +|---|---|---|---|---|---| +| qwen3 up | 3.679 ms | 3.034 ms | **2.090 ms** | 135.91 → **197.31** | 1.30x → **1.76x** | +| qwen3 down | 2.596 ms | 2.107 ms | **1.796 ms** | 97.83 → **114.78** | 1.16x → **1.45x** | + +Both shapes clear the 100 TFLOPS target, and `qwen3 up` at 197 TFLOPS is 1.45x +the *shipped* W4A8 path. This is the whole of the gap: not the mainloop, not the +tiles — the activation round-trip, deleted by having the caller pass int8 it +already had. The other contract goes the other way; see +[What is left](#what-is-left). ### The int8 weight copy, and the in-tree precedent against it @@ -427,18 +436,20 @@ traffic model: (`n/a` = the fused reduction does not apply to an up/gate projection, so the reachable number for those shapes is the `+ int8 in` column.) -Both qwen3 shapes clear 100 TFLOPS: the up projection on contract 1 alone, the -down projection only on **both together** — at either routing. - -These are projections from a traffic model, not measurements — see -[Status](#status) — and the `fused reduce` / `Both` columns are now known to be -wrong. B70 measured contract 2 as a **regression**, because this model prices -the fused epilogue by the bytes it removes and it is in fact priced by the -~134M device-scope atomics it adds ([What is left](#what-is-left)). Read the -`+ int8 in` column as the live projection and the two fused columns as an upper -bound that measurement did not reach; the standing consequence is that -`qwen3 down` has no projected path past 100 TFLOPS left, since its only one ran -through contract 2. +B70 has now measured the 8K-prompt rows of the `+ int8 in` column, and the +model was close: it projected **152** for `qwen3 up` and **91.0** for +`qwen3 down`; the device returned **197.31** and **114.78**. Both beat the +projection — the model prices the shipped path's activation round-trip at the +sweep's average bandwidth, and deleting it also improves the locality of what +remains, which a pure byte count cannot see. Both shapes clear 100 TFLOPS on +contract 1 **alone**, which the table said only `qwen3 up` would. + +The `fused reduce` / `Both` columns, by contrast, are now known to be wrong in +the other direction. B70 measured contract 2 as a **regression**, because this +model prices the fused epilogue by the bytes it removes and it is in fact priced +by the ~134M device-scope atomics it adds ([What is left](#what-is-left)). Read +the `+ int8 in` column as validated at the 8K routing and the two fused columns +as an upper bound measurement did not reach. ## Decode: coalesced K-split mapping @@ -1241,15 +1252,20 @@ So contract 2 is not a contract to take on this hardware. The `[T, N]` write it deletes is real, but a coalesced 268 MB store beats 134M scattered read-modify- writes by more than the bytes suggest. -**Contract 1 has still never been measured on its own.** Both B70 runs so far -enabled the two together, so contract 2's ~1.5 ms swamped whatever the -activation round-trip saved and that run says nothing about the round-trip. Its -own arithmetic is unencumbered: it deletes `3 * T * K` bytes — 402 MB of qwen3 -up's 1141 MB — which at the 376 GB/s the shipped path already streams is -~1.97 ms, or **2.0x vs W4A16's 3.929 ms**. It is also the contract a serving -stack can adopt without touching layer code, since the previous op in most -quantized pipelines already produced int8, and unlike contract 2 it keeps the -result bit-identical: +**Contract 1, measured on its own, is the answer.** The two earlier B70 runs +had enabled the contracts together, so contract 2's ~1.5 ms swamped whatever +the activation round-trip saved and those runs said nothing about it. Isolated: + +| shape | w4a16 | shipped | contract 1 | TFLOPS | vs w4a16 | +|---|---|---|---|---|---| +| qwen3 up | 3.679 ms | 3.034 ms | **2.090 ms** | 135.91 → **197.31** | 1.30x → **1.76x** | +| qwen3 down | 2.596 ms | 2.107 ms | **1.796 ms** | 97.83 → **114.78** | 1.16x → **1.45x** | + +It deletes `3 * T * K` bytes — 402 MB of qwen3 up's 1141 MB — and the predicted +~1.97 ms landed within 6% of the measured 2.090 ms. It is also the contract a +serving stack can adopt without touching layer code, since the previous op in +most quantized pipelines already produced int8, and unlike contract 2 it keeps +the result bit-identical: ```bash pytest test_moe_w4a8_perf.py -k prequant_long_seq -v # contract 1 alone @@ -1285,10 +1301,42 @@ contract a caller can take, so `run_perf` no longer applies it to those rows — the earlier table's `up` regression was measuring a configuration nobody can ship. Contract 2 is a down-projection contract. -Both contracts are free in a real MoE layer: `up`/`gate` share activations so -the int8 copy is made once and handed to both, and `down`'s consumer is the -unpermute + weighted sum the epilogue would be doing anyway. Treat them as the -calling convention rather than an optimization. +Contract 1 is free in a real MoE layer: `up`/`gate` share activations, so the +int8 copy is made once and handed to both. Treat it as the calling convention +rather than an optimization. Contract 2's premise — that `down`'s consumer is +the unpermute + weighted sum the epilogue would be doing anyway — is sound, but +on this hardware the epilogue does it worse than a separate pass does. + +### The bandwidth probe was lying, and it mattered + +The contract 1 run printed `118% of the 167 TFLOPS bandwidth ceiling`, which is +not a thing a roofline can do. The ceilings come from a device copy probe, and +that probe reported **439, 373 and 299 GB/s** on three consecutive runs of the +same suite — a 47% swing in a number the verdicts treat as a hardware constant. +At 299 GB/s it sat *below* the 353 GB/s the kernel itself was streaming. + +That was never only cosmetic. `_assert_targets` waives the target for any row +whose ceiling is under it, on the theory that no kernel change can reach it — so +an under-measured probe hands the "bandwidth bound, unreachable" excuse to rows +that are merely slow, and `--enforce-targets` stops enforcing. + +Two fixes. The probe is now the best of several rounds rather than one burst's +median, for the same reason every other measurement here min-filters: the +fastest copy is the one least contaminated by throttling. And a row that moved +its own traffic faster than the probe is direct evidence the device sustains at +least that much, so the ceilings are rescaled by it — the probe is only ever a +lower bound. This can only raise ceilings, i.e. only make verdicts stricter. + +The corrected reading of that same run changes the conclusion: + +| shape | printed | corrected ceiling | corrected | +|---|---|---|---| +| qwen3 up | 118% of 167 | 197.3 | **100%** — at the roofline | +| qwen3 down | 97% of 119 | 140.6 | **82%** — 18% of headroom | + +So `qwen3 down` is *not* finished, as the bad probe's "97%" implied. It is the +one shape with kernel headroom left under contract 1, and the write-heavy +analysis above is why. ## Environment variables diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 0041ac3b2b..12c4ff87d7 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -221,11 +221,19 @@ padding 上被测量;512 是 256 的整数倍,是套件里唯一满足 也就是说四个形状里有三个已经在设备可达带宽的 8–9% 以内,而 `vs w4a16` 的差异跟随的 是算术强度 (K),不是内核质量:qwen3 落后于 minimax 是因为它的 K 更小,用来摊薄激活 数据流的权重项也就更小。tile、store、epilogue 或调度上的任何改动都撼动不了这一点—— -这些配置搬运的字节数完全相同。唯一有效的杠杆是下面的[调用契约](#削减-prefill-流量两个可选的调用契约)—— -而其中只有[契约 1](#契约-1--调用方直接提供-int8-激活) 经受住了 B70 的实测;契约 2 虽然删掉了 -字节,增加的时间却超过它节省的时间 (见[还剩下什么](#还剩下什么))。`qwen3 down` 是唯一还留有 -内核余量的形状,这正是 -[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的) 所讨论的内容。 +这些配置搬运的字节数完全相同。 + +唯一有效的杠杆是[契约 1](#契约-1--调用方直接提供-int8-激活),而且在 B70 上它的价值超过 +本文档里所有调优旋钮的总和: + +| 形状 | w4a16 | 已发布 | 契约 1 | TFLOPS | vs w4a16 | +|---|---|---|---|---|---| +| qwen3 up | 3.679 ms | 3.034 ms | **2.090 ms** | 135.91 → **197.31** | 1.30x → **1.76x** | +| qwen3 down | 2.596 ms | 2.107 ms | **1.796 ms** | 97.83 → **114.78** | 1.16x → **1.45x** | + +两个形状都越过了 100 TFLOPS 目标,而 `qwen3 up` 的 197 TFLOPS 是*已发布* W4A8 路径的 +1.45 倍。差距的全部就在这里:不是 mainloop,也不是 tile——而是那次激活往返,只要让调用方 +把它本来就持有的 int8 传进来就能删掉。另一个契约的方向则相反,见[还剩下什么](#还剩下什么)。 ### int8 权重副本,以及树内反对它的先例 @@ -370,15 +378,16 @@ weight 之后 `atomic_add` 到 `out[row_to_token[r]]`。`T × N × 2` 的写变 (「不适用」= 融合规约不适用于 up/gate 投影,所以这些形状能达到的数字看 `+ int8 输入` 那一列。) -两个 qwen3 形状都能越过 100 TFLOPS:up 投影只靠契约 1 就够,down 投影则只有在**两者 -一起**时才行 — 在两种路由下都是如此。 +B70 现在已经实测了 `+ int8 输入` 一列中 8K 提示词的那两行,模型算得相当接近:它推算 +`qwen3 up` 为 **152**、`qwen3 down` 为 **91.0**,而设备实际给出 **197.31** 和 +**114.78**。两者都超出了推算——模型是按整轮扫描的平均带宽给已发布路径的激活往返定价的, +而删掉它同时还改善了剩余部分的局部性,这是单纯数字节看不到的。两个形状都**只靠契约 1** +就越过了 100 TFLOPS,而这张表原本认为只有 `qwen3 up` 能做到。 -以上是流量模型的推算,不是实测 — 见[状态](#状态) — 而且 `融合规约` 与 `两者` 这两列 -现在已知是错的。B70 实测契约 2 是**退化的**,因为这个模型按 fused epilogue 删掉的字节 -给它定价,而它实际上是按新增的约 1.34 亿次 device 作用域原子操作定价的 -([还剩下什么](#还剩下什么))。请把 `+ int8 输入` 一列当作仍然成立的推算,把两个融合列 -当作实测未能达到的上界;由此产生的直接后果是:`qwen3 down` 已经没有任何推算上能越过 -100 TFLOPS 的路径了,因为它唯一的那条路径要经过契约 2。 +相比之下,`融合规约` 与 `两者` 这两列现在已知在另一个方向上是错的。B70 实测契约 2 是 +**退化的**,因为这个模型按 fused epilogue 删掉的字节给它定价,而它实际上是按新增的约 +1.34 亿次 device 作用域原子操作定价的 ([还剩下什么](#还剩下什么))。请把 `+ int8 输入` +一列当作已在 8K 路由下得到验证,把两个融合列当作实测未能达到的上界。 ## Decode:合并访存的 K-split 映射 @@ -1085,13 +1094,18 @@ qwen3 down 未规约的 `[T, N]` 输出单独就有 268 MB——占整个调用 所以在这个硬件上,契约 2 不是一个值得采纳的契约。它删掉的那次 `[T, N]` 写入确实存在, 但一次合并的 268 MB store 胜过 1.34 亿次分散的读改写,其优势远超字节数所暗示的程度。 -**契约 1 至今仍然没有被单独测过。** 目前 B70 上的两轮测试都是把两个契约一起打开的, -于是契约 2 那约 1.5 ms 的开销淹没了激活往返所节省的一切,那轮数据对激活往返本身 -什么都说明不了。而它自己的账是干净的:它删掉 `3 * T * K` 字节——qwen3 up 全部 -1141 MB 中的 402 MB——按已发布路径本就能跑出的 376 GB/s 折算约为 1.97 ms,也就是 -**相对 W4A16 的 3.929 ms 达到 2.0x**。它同时也是推理框架无需改动层代码就能采纳的 -契约,因为多数量化流水线里上一个算子本来就产出 int8;并且与契约 2 不同,它保持结果 -逐位一致: +**契约 1 单独测下来,就是答案。** 此前 B70 上的两轮测试都是把两个契约一起打开的, +于是契约 2 那约 1.5 ms 的开销淹没了激活往返所节省的一切,那两轮数据对它什么都说明不了。 +单独隔离出来之后: + +| 形状 | w4a16 | 已发布 | 契约 1 | TFLOPS | vs w4a16 | +|---|---|---|---|---|---| +| qwen3 up | 3.679 ms | 3.034 ms | **2.090 ms** | 135.91 → **197.31** | 1.30x → **1.76x** | +| qwen3 down | 2.596 ms | 2.107 ms | **1.796 ms** | 97.83 → **114.78** | 1.16x → **1.45x** | + +它删掉 `3 * T * K` 字节——qwen3 up 全部 1141 MB 中的 402 MB——而此前推算的约 1.97 ms +与实测的 2.090 ms 相差在 6% 以内。它同时也是推理框架无需改动层代码就能采纳的契约, +因为多数量化流水线里上一个算子本来就产出 int8;并且与契约 2 不同,它保持结果逐位一致: ```bash pytest test_moe_w4a8_perf.py -k prequant_long_seq -v # 只开契约 1 @@ -1120,9 +1134,36 @@ pytest test_moe_w4a8_perf.py -k contracts_long_seq -v # 两个都开,用于 并不是调用方能够采纳的契约,因此 `run_perf` 不再把它应用到那几行——先前表格中 `up` 的 退化,测的是一个谁都无法上线的配置。契约 2 是一个只属于 down 投影的契约。 -在真实的 MoE 层里这两个契约都是免费的:`up`/`gate` 共享激活,因此 int8 副本只需做一次 -就能同时喂给两者;而 `down` 的下游本来就是 epilogue 可以顺手完成的 unpermute + 加权 -求和。应当把它们当作调用约定,而不是一项优化。 +在真实的 MoE 层里契约 1 是免费的:`up`/`gate` 共享激活,因此 int8 副本只需做一次就能 +同时喂给两者。应当把它当作调用约定,而不是一项优化。契约 2 的前提——`down` 的下游本来 +就是 epilogue 可以顺手完成的 unpermute + 加权求和——本身是成立的,但在这个硬件上, +epilogue 做这件事比单独一遍做得更差。 + +### 带宽探针在说谎,而且这是有后果的 + +契约 1 那一轮打印出了 `118% of the 167 TFLOPS bandwidth ceiling`,而这是 roofline +不可能出现的情况。天花板来自一个设备拷贝探针,而该探针在同一套测试的三次连续运行中分别 +报出 **439、373 和 299 GB/s**——对一个被各处判定当作硬件常数的数字来说,这是 47% 的摆幅。 +在 299 GB/s 时,它甚至*低于* kernel 自身正在跑出的 353 GB/s。 + +这从来就不只是显示问题。`_assert_targets` 会对任何天花板低于目标的行免除目标校验,理由是 +再改 kernel 也达不到——于是一个测低了的探针,会把"受带宽限制、不可达"这个免死金牌发给 +其实只是慢的行,而 `--enforce-targets` 也就不再真正生效。 + +两处修复。探针现在取多轮中的最优值,而不是单次突发的中位数,理由和本文其它所有测量都做 +最小值过滤是同一个:最快的那次拷贝受降频污染最小。另外,如果某一行搬运自身流量的速率高于 +探针,那就是设备至少能维持该速率的直接证据,因此天花板会按它重新标定——探针永远只是一个 +下界。这个修正只会抬高天花板,也就是只会让判定更严格。 + +对同一轮数据重新解读之后,结论就变了: + +| 形状 | 原打印 | 修正后天花板 | 修正后 | +|---|---|---|---| +| qwen3 up | 167 的 118% | 197.3 | **100%** — 已在 roofline 上 | +| qwen3 down | 119 的 97% | 140.6 | **82%** — 还有 18% 余量 | + +所以 `qwen3 down` **并没有**像那个坏探针的"97%"所暗示的那样已经到头。在契约 1 之下, +它是唯一还留有内核余量的形状,而上面关于写入占比的分析正是原因所在。 ## 环境变量 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index abd9748d95..0a1bc1b242 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -919,10 +919,18 @@ def _device_bandwidth_gbps(): independent probe. The copy counts one read plus one write, which slightly *understates* - read-only weight streaming -- a conservative choice: it can only make the - ceiling smaller and therefore never turns a genuinely slow kernel into an - excused row. Cached after the first call; returns ``None`` if XPU is - unavailable or the probe fails. + read-only weight streaming. That conservatism is not free: a ceiling that + is too low reads as "bandwidth bound", and :func:`_assert_targets` skips + enforcement on exactly those rows, so an under-measured probe silently + excuses a slow kernel. :func:`_apply_bandwidth_evidence` is the guard. + + Taken as the *best* of several rounds rather than one burst's median. A + single burst samples whatever clock state the device happens to be in, and + on B70 that spread three consecutive runs across 439 / 373 / 299 GB/s -- + a 47% swing in a number the ceilings treat as a hardware constant. The + fastest copy is the one least contaminated by throttling, which is the same + reason every other measurement here min-filters. Cached after the first + call; returns ``None`` if XPU is unavailable or the probe fails. """ global _DEVICE_BW_GBPS if _DEVICE_BW_GBPS is not None: @@ -933,7 +941,7 @@ def _device_bandwidth_gbps(): try: src = torch.empty(nbytes, dtype=torch.int8, device="xpu") dst = torch.empty_like(src) - ms = _xpu_time_ms(lambda: dst.copy_(src), warmup=3, iters=10) + ms = min(_xpu_time_ms(lambda: dst.copy_(src), warmup=2, iters=5) for _ in range(3)) _DEVICE_BW_GBPS = 2.0 * nbytes / (ms * 1e-3) / 1e9 except Exception as exc: # pragma: no cover - depends on device/runtime print(f"[moe-w4a8-perf] device bandwidth probe unavailable: {exc}") @@ -944,6 +952,41 @@ def _device_bandwidth_gbps(): return _DEVICE_BW_GBPS +def _apply_bandwidth_evidence(rows) -> None: + """Raise the ceilings when a row proved the probe under-measured. + + The ceilings are derived from an independent copy probe, but the probe is + only ever a *lower* bound on what the part can stream: it counts a read + plus a write, and it samples one moment of one clock state. A kernel row + that moved its own traffic at a higher rate is direct evidence the device + sustains at least that much -- so the probe, not the row, is what was + wrong. + + Without this a run can print "118% of the bandwidth ceiling", which is not + a thing a roofline can do; worse, :func:`_assert_targets` waives the target + for any row sitting below its ceiling, so a cold probe hands out the + "bandwidth bound, unreachable" excuse to rows that are merely slow. + + Ceilings are linear in bandwidth, so each is rescaled by the same ratio. + This only ever raises them, which only ever makes the verdict stricter. + """ + if not rows: + return + probe = rows[0].get("device_bw_gbps") + if not probe: + return + best = max((r.get("dram_gbps") or 0.0) for r in rows) + if best <= probe: + return + winner = max(rows, key=lambda r: r.get("dram_gbps") or 0.0) + scale = best / probe + for row in rows: + if row.get("tflops_ceiling") is not None: + row["tflops_ceiling"] *= scale + row["bw_evidence_gbps"] = best + row["bw_evidence_label"] = winner["label"] + + # --------------------------------------------------------------------------- # Printing # --------------------------------------------------------------------------- @@ -1076,10 +1119,17 @@ def _print_targets(phase: str, rows) -> None: target = _TARGET_PREFILL_TFLOPS if is_prefill else _TARGET_DECODE_GBPS unit = "TFLOPS" if is_prefill else "GB/s" device_bw = rows[0].get("device_bw_gbps") + evidence = rows[0].get("bw_evidence_gbps") print() print(f"targets [{phase}]: {'prefill compute' if is_prefill else 'decode weight bandwidth'} > {target:g} {unit}") if device_bw: - print(f" device copy bandwidth probe: {device_bw:.0f} GB/s") + note = "" + if evidence: + note = ( + f" (under-measured: {rows[0]['bw_evidence_label']} streamed {evidence:.0f} GB/s, " + f"so the ceilings below use that)" + ) + print(f" device copy bandwidth probe: {device_bw:.0f} GB/s{note}") for row in rows: measured = row["tflops"] if is_prefill else row["gbps"] ceiling = row.get("tflops_ceiling") @@ -1388,6 +1438,10 @@ def run_perf( ark.clear_moe_w4a8_prepack_cache() ark.moe_w4a8_release_scratch() _release_xpu_memory() + # Before any verdict is drawn from the ceilings -- including the one + # `_assert_targets` draws from the returned rows, which is why this is not + # gated on `verbose`. + _apply_bandwidth_evidence(rows) if verbose: _print_targets(phase, rows) return rows From a4dbb54088f00a2a633d2fe16a2a638e605e7a4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:45:58 +0000 Subject: [PATCH 098/112] perf: deduplicate MoE prefill activation quantization for up/gate The dynamic quantization is a per-row absmax, so it has no expert dependence. The up/gate projection is handed batch * top_k rows that are top_k copies of batch distinct tokens, so the in-call pass does the work 8x over at the shipped top_k. Hoisting it above the permute reaches the already-supported int8 contract with no kernel change and no int8 producer upstream, and halves the permute as a side effect: 1409 MB -> 923 MB end to end on qwen3 up. Adds run_dedup_quant, which times both paths with the caller's permute included -- the only comparison that distinguishes a real saving from a relocated one -- plus the pytest entry point and a --dedup-quant flag. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 66 +++++++ .../ark/test/README_MOE_W4A8_CN.md | 55 ++++++ .../ark/test/test_moe_w4a8_perf.py | 184 ++++++++++++++++++ 3 files changed, 305 insertions(+) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index f8a2130448..9b7394f440 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -383,6 +383,72 @@ arise. The two tests above avoid it by construction, quantizing rows that lie on the int8 grid (`a = q × 2^-e`, `|q| ≤ 127`), where every product is an integer and neither quantizer has a tie to break. +### Reaching contract 1 with no int8 upstream: deduplicate the quantization + +The contract above assumes a producer that can emit int8. Most callers do not +have one — the previous operator emits bf16, and the dynamic quantization has +to happen somewhere. That does **not** put contract 1 out of reach, because of +where the quantization happens rather than whether it happens. + +The quantizer is a per-row absmax: `a.abs().amax(dim=1)`, then +`round(x × 127 / absmax)`. Nothing in it depends on the expert. So the int8 +bytes and the scale for a token are a property of *the token*, identical on +every routed row that token lands on. + +Now count the rows. The up/gate projection is handed `[T, K]` sorted by expert, +where `T = batch × top_k` — and those rows are `top_k` **copies** of `batch` +distinct tokens. At the shipped `top_k = 8`, the in-call pass therefore reads +each token's row 8 times, computes its absmax 8 times, and writes 8 identical +int8 rows. Seven eighths of that is redundant. + +Hoist the same quantization above the permute and it disappears. The caller +quantizes the `batch` rows it actually has, then permutes int8 instead of bf16: + +```python +qact, ascale = quantize_rows(hidden_states) # [batch, K] -> int8 + [batch] fp32 +out = ark.moe_gemm_w4a8( + qact.index_select(0, row_to_token), # [T, K] int8, sorted by expert + weights_s8, + wscales, + num_tokens_per_expert, + activation_scale=ascale.index_select(0, row_to_token), + out_dtype=torch.bfloat16, +) +``` + +**This is where the honesty check matters.** Contract 1 measured on its own +looks spectacular partly because it *moves* the quantization out of the timed +region. A caller who quantized the already-permuted `[T, K]` rows themselves +would hand back the entire gain — same bytes, different clock. Deduplication is +what makes the work genuinely smaller, and it also halves the permute, because +the permute now moves 1 byte per element instead of 2. Counting both sides at +the qwen3 up-projection shape: + +| | kernel | caller's permute | end to end | +|---|---|---|---| +| in-call quant | 1141 MB | 268 MB (bf16) | **1409 MB** | +| deduplicated | 738 MB | 185 MB (quantize 8192 rows + int8 permute) | **923 MB** | + +1.53x less traffic end to end, and the kernel half is byte-for-byte the call +that measured 2.090 ms. `test_perf_prefill_dedup_quant_long_seq` times both +paths *with the permute included* — the only comparison that can tell a real +saving from a relocated one — and asserts the outputs agree. + +Two limits worth stating plainly: + +* **Up/gate only.** The down projection's `T` rows are the SiLU output: one + distinct row per routed row, nothing to deduplicate. Its route to the same + contract is to fold the quantization into the SiLU epilogue, which already + writes that tensor and already holds the row in registers — free, in the + sense contract 1 describes, and measured at 1.45x. +* **The `[T, K]` int8 is still materialized.** Removing it too means gathering + A inside the mainloop, so the deduplicated `[batch, K]` int8 (16.8 MB, small + enough to stay cache-resident) is read directly. That would delete a further + 134 MB write plus most of the read, but it turns the A-side 2D block load + into a per-row gather — the same class of change that made contract 2 lose. + It is a kernel change, not a calling change, and it should not be attempted + without the hardware to measure it. + ### Contract 2 — the top-k reduction fused into the epilogue ```python diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 12c4ff87d7..367d05f564 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -331,6 +331,61 @@ fp16 读、int8 写、int8 读回 — 外加一次 kernel launch。GEMM 本身 上面那两个测试则是从构造上回避它 — 量化的行落在 int8 网格上 (`a = q × 2^-e`、 `|q| ≤ 127`),每个乘积都是整数,两边的量化器都没有分界要打破。 +### 上游没有 int8 时如何用上契约 1:去重量化 + +上面的契约假设生产者能直接输出 int8。多数调用方并没有这样的算子——上一个算子输出 +bf16,动态量化总得有人做。但这并不意味着契约 1 用不上,关键不在于量化做不做,而在于 +它在**哪里**做。 + +量化器是逐行 absmax:`a.abs().amax(dim=1)`,然后 `round(x × 127 / absmax)`。其中 +没有任何一项依赖专家。所以一个 token 的 int8 字节和 scale 是**这个 token 自身**的属性, +在它被路由到的每一行上都完全相同。 + +再数一下行数。up/gate 投影拿到的是按专家排序的 `[T, K]`,其中 `T = batch × top_k` +——而这些行正是 `batch` 个不同 token 的 `top_k` 份**副本**。在实际使用的 +`top_k = 8` 下,调用内的量化因此把每个 token 的行读了 8 遍、absmax 算了 8 遍、 +写出 8 行完全相同的 int8。其中八分之七是冗余的。 + +把同一个量化提到 permute 之上,这部分就消失了。调用方量化自己手里真正拥有的 +`batch` 行,然后 permute int8 而不是 bf16: + +```python +qact, ascale = quantize_rows(hidden_states) # [batch, K] -> int8 + [batch] fp32 +out = ark.moe_gemm_w4a8( + qact.index_select(0, row_to_token), # [T, K] int8,按专家排序 + weights_s8, + wscales, + num_tokens_per_expert, + activation_scale=ascale.index_select(0, row_to_token), + out_dtype=torch.bfloat16, +) +``` + +**这里必须诚实地做一次核对。**契约 1 单独测出来的数字之所以亮眼,有一部分原因是它把 +量化**挪出**了计时区间。如果调用方自己去量化已经 permute 过的 `[T, K]`,收益会被 +原样还回去——字节数没变,只是换了个计时的人。真正让工作量变小的是去重;而且它同时把 +permute 减半,因为 permute 现在每个元素只搬 1 字节而不是 2 字节。把两侧一起算, +以 qwen3 up 投影的形状为例: + +| | kernel | 调用方的 permute | 端到端 | +|---|---|---|---| +| 调用内量化 | 1141 MB | 268 MB(bf16) | **1409 MB** | +| 去重后 | 738 MB | 185 MB(量化 8192 行 + int8 permute) | **923 MB** | + +端到端流量降低 1.53x,而 kernel 那一半逐字节就是测得 2.090 ms 的那次调用。 +`test_perf_prefill_dedup_quant_long_seq` 会把两条路径**连同 permute 一起**计时 +——只有这样的对比才能区分真正的节省和被挪走的开销——并断言两者输出一致。 + +有两条边界需要讲清楚: + +* **只适用于 up/gate。**down 投影的 `T` 行是 SiLU 的输出:每一条路由行对应一行不同的 + 数据,没有可去重的东西。它走向同一契约的路径是把量化折进 SiLU 的 epilogue——那里 + 本来就在写这个张量、行也本来就在寄存器里,属于契约 1 所说的“免费”,实测 1.45x。 +* **`[T, K]` 的 int8 仍然会被物化。**要把它也去掉,就得在 mainloop 内部 gather A, + 直接读去重后的 `[batch, K]` int8(16.8 MB,小到可以常驻 cache)。那会再省下 + 134 MB 的写以及大部分读,但也会把 A 侧的 2D block load 变成逐行 gather——正是让 + 契约 2 失利的那一类改动。这是 kernel 改动而非调用方改动,没有硬件实测就不该动。 + ### 契约 2 — 把 top-k 规约折进 epilogue ```python diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 0a1bc1b242..ba42fddc36 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1447,6 +1447,154 @@ def run_perf( return rows +def run_dedup_quant(batches=None, dtype=torch.bfloat16, models=None, verbose=True): + """End-to-end permute + GEMM for the up/gate projection, two ways. + + The kernel quantizes its activations per row, and the row absmax is a + property of the *token*: ``a.abs().amax(dim=1)`` does not depend on which + expert the row was routed to. On the up/gate projection the ``T`` rows the + kernel is handed are ``top_k`` copies of ``batch`` distinct tokens, so the + in-call pass computes every scale ``top_k`` times and writes ``top_k`` + copies of every int8 row -- 8x redundant at the shipped ``top_k = 8``. + + Hoisting the same quantization above the permute removes that redundancy + and lets a caller reach the pre-quantized contract *without* an int8 + producer upstream: the dynamic quantization still happens, just on + ``batch`` rows instead of ``batch * top_k``, and the permute then moves + int8 instead of 16-bit. + + Both paths are timed **including the caller's permute**, which is what + makes this an honest comparison rather than an accounting shift. The + pre-quantized contract on its own only moves the quantization across the + call boundary; it is the deduplication -- and the halved permute that comes + with moving int8 instead of bf16 -- that makes the work actually smaller. + A caller who quantized the *permuted* rows themselves would see the + contract's gain cancel almost exactly. + + Up/gate only. The down projection's ``T`` rows are the SiLU output, one + distinct row per routed row, so there is nothing to deduplicate; its route + to the same contract is folding the quantization into the SiLU epilogue + that already writes that tensor, which is what + ``test_perf_prefill_prequant_long_seq`` measures. + """ + batches = _long_seq_batches() if batches is None else batches + resolved = _models(models) + if verbose: + print() + print("=" * _PERF_WIDTH) + print( + f"W4A8 end-to-end [prefill] (models={'+'.join(n for n, _ in resolved)}, up/gate only) " + f"-- caller permute + moe_gemm_w4a8, in-call quant vs deduplicated quant" + ) + print( + f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'batch':>8}{'topk':>6}" + f"{'path':>22}{'permute(ms)':>13}{'gemm(ms)':>11}{'total(ms)':>12}{'vs in-call':>12}" + ) + print("-" * _PERF_WIDTH) + rows = [] + shapes = [ + (nk_label, N, K, spec, batch) + for _, spec in resolved + for nk_label, N, K in spec["nk"] + if not _reduces_topk(nk_label) + for batch in batches + ] + for nk_label, N, K, spec, batch in shapes: + E, topk, group_size = spec["E"], spec["topk"], spec["group_size"] + total_tokens = batch * topk + case = _build_case( + N, K, E, total_tokens, group_size, dtype, need_reference=False, need_dequant=False, topk=topk + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=group_size, rescale_group_size=-1 + ) + # The unpermuted hidden states a real layer holds, and the map the + # router produces. `index_select` wants a 64-bit index; converting it + # is one-off setup a caller does once per layer, not per call, so it + # stays outside both timed paths. + n_tokens = case["batch"] + hidden = case["activations"][:n_tokens].contiguous() + index = case["row_to_token"].long() + + def _call(act, ascale=None): + kwargs = {"rescale_block_size": block, "phase": "prefill"} + if ascale is not None: + kwargs["activation_scale"] = ascale + kwargs["out_dtype"] = dtype + return ark.moe_gemm_w4a8(act, weights_s8, wscales, case["ntpe"], **kwargs) + + def _today(): + return _call(hidden.index_select(0, index)) + + def _dedup(): + qact, ascale = _quantize_rows(hidden) + return _call(qact.index_select(0, index), ascale.index_select(0, index)) + + # Round-robin, min-filtered: the same guard against clock droop the + # other sweeps use, since the two paths are being compared to each + # other rather than reported in isolation. + today, dedup, perm_bf16, perm_int8 = [], [], [], [] + iters = max(1, ITERS // SWEEP_ROUNDS) + qact_ref = _quantize_rows(hidden)[0] + for _ in range(SWEEP_ROUNDS): + today.append(_xpu_time_ms(_today, iters=iters)) + dedup.append(_xpu_time_ms(_dedup, iters=iters)) + perm_bf16.append(_xpu_time_ms(lambda: hidden.index_select(0, index), iters=iters)) + perm_int8.append(_xpu_time_ms(lambda: qact_ref.index_select(0, index), iters=iters)) + today_ms, dedup_ms = min(today), min(dedup) + perm_bf16_ms, perm_int8_ms = min(perm_bf16), min(perm_int8) + + # The deduplicated path must not change the result: the row absmax is + # expert-independent, so quantizing before the permute and permuting + # the int8 is the same arithmetic on the same row values. + snr_db = _snr_db(_today().to(torch.float32), _dedup().to(torch.float32)) + + rows.append( + { + "label": nk_label, + "E": E, + "N": N, + "K": K, + "tokens": total_tokens, + "batch": n_tokens, + "topk": topk, + "today_ms": today_ms, + "dedup_ms": dedup_ms, + "permute_bf16_ms": perm_bf16_ms, + "permute_int8_ms": perm_int8_ms, + "speedup": (today_ms / dedup_ms) if dedup_ms else None, + "snr_db": snr_db, + } + ) + if verbose: + for path, total_ms, perm_ms, speedup in ( + ("in-call quant", today_ms, perm_bf16_ms, None), + ("dedup quant", dedup_ms, perm_int8_ms, today_ms / dedup_ms if dedup_ms else None), + ): + print( + f"{nk_label:<14}{E:>5}{N:>7}{K:>7}{total_tokens:>8}{n_tokens:>8}{topk:>6}" + f"{path:>22}{perm_ms:>13.3f}{max(total_ms - perm_ms, 0.0):>11.3f}{total_ms:>12.3f}" + f"{(f'{speedup:.2f}x' if speedup else '--'):>12}" + ) + + case = weights_s8 = wscales = hidden = qact_ref = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + + if verbose and rows: + print() + print("deduplicated dynamic quantization [prefill] (up/gate only; the down projection has no duplicate rows):") + for row in rows: + print( + f" {row['label']:<12} {row['today_ms']:.3f} ms -> {row['dedup_ms']:.3f} ms " + f"{row['speedup']:.2f}x end-to-end (quantizes {row['batch']} rows instead of " + f"{row['tokens']}, permutes int8 instead of {str(dtype).split('.')[-1]}) " + f"SNR {row['snr_db']:.1f} dB" + ) + return rows + + # --------------------------------------------------------------------------- # Kernel-configuration sweeps # @@ -2259,6 +2407,27 @@ def test_perf_prefill_contract_sweep(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"call contract {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_dedup_quant_long_seq(self, request): + """End-to-end win from deduplicating the dynamic quantization. + + The answer for a caller whose upstream operator has no int8 to + hand over. The quantization still has to happen -- it just does not + have to happen ``top_k`` times per token. See :func:`run_dedup_quant` + for why the row absmax makes that safe, and why both paths are + timed with the caller's permute included. + + Asserts the two paths agree: the deduplicated path is the same + arithmetic on the same row values, so the only permitted difference + is the few-ulp fp32 division noise :func:`_quantize_rows` documents. + """ + rows = run_dedup_quant(models=_models_option(request)) + assert rows and all(r["today_ms"] > 0 and r["dedup_ms"] > 0 for r in rows) + for row in rows: + assert row["snr_db"] >= _SWEEP_MIN_SNR_DB, ( + f"deduplicated quantization changed {row['label']}: " + f"SNR {row['snr_db']:.2f} dB below {_SWEEP_MIN_SNR_DB}" + ) + def test_perf_prefill_prequant_long_seq(self, request): """Prefill throughput for one 8K prompt with the int8-in contract alone. @@ -2829,6 +2998,15 @@ def _parse_args(argv): "prefill tile sweep is repeated there." ), ) + parser.add_argument( + "--dedup-quant", + action="store_true", + help=( + "Also run the end-to-end up/gate comparison that deduplicates the dynamic quantization: " + "quantize the batch distinct tokens once and permute int8, instead of permuting 16-bit and " + "letting the kernel quantize every routed row. Both paths are timed with the permute included." + ), + ) parser.add_argument( "--contracts", action="store_true", @@ -2922,6 +3100,12 @@ def main(argv=None) -> int: prequantized=True, fused_reduce=True, ) + if phase == "prefill" and args.dedup_quant: + run_dedup_quant( + _long_seq_batches() if args.long_seq else None, + dtype=dtype, + models=models, + ) if args.sweep_configs: configs = _PREFILL_TILE_CONFIGS if phase == "prefill" else _DECODE_CONFIGS run_config_sweep(phase, configs, dtype=dtype, models=models) From f2d1b0b352f7e4f99883830f252688e312346488 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:33:57 +0000 Subject: [PATCH 099/112] fix: attribute the dedup benchmark's stages and measure the fused quantizer The 0.94x regression was the quantizer, not the deduplication. The deduplicated path called _quantize_rows -- the eager-torch reference, which upcasts to fp32 and walks the tensor once per operator -- while the baseline used the fused SYCL quantizer. That is ~15x worse per row, enough to eat an 8x reduction in rows. Two defects made this invisible. The gemm column was derived as total - permute, so it silently absorbed the quantization cost under a label that said "gemm". And there was no measurement of what the fused quantizer costs, so there was nothing to compare against. Times each stage separately now, and measures the fused quantizer by differencing the same GEMM with 16-bit and int8 input on the same shape and weights -- the only differing work is the in-kernel quantization of exactly those rows. Done at both T and batch rows, which cross-checks linearity in rows; the test asserts the ratio is within 2x of top_k so a noise difference cannot become a headline number. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 35 ++++ .../ark/test/README_MOE_W4A8_CN.md | 26 +++ .../ark/test/test_moe_w4a8_perf.py | 188 ++++++++++++++---- 3 files changed, 213 insertions(+), 36 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 9b7394f440..96597e2ee8 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -434,6 +434,41 @@ that measured 2.090 ms. `test_perf_prefill_dedup_quant_long_seq` times both paths *with the permute included* — the only comparison that can tell a real saving from a relocated one — and asserts the outputs agree. +#### Which quantizer does the deduplicated work decides whether it wins + +Fewer rows is not automatically less time, and the first measurement of this +said so: **0.94x**, a regression. The permute halved exactly as predicted +(1.011 → 0.466 ms), but the quantization of 8192 rows cost *more* than the +in-kernel quantization of 65536. + +The cause was the quantizer, not the deduplication. `_quantize_rows` is the +eager-torch reference — it upcasts to fp32 and walks the tensor about seven +times, once per operator, materializing a full-size intermediate each time. +The in-call path uses the fused SYCL quantizer, which reads each row once and +keeps the absmax in registers. Per row it is roughly fifteen times cheaper, +which is more than enough to eat an 8x reduction in rows: + +| path | permute | quant | GEMM | total | vs in-call | +|---|---|---|---|---|---| +| in-call quant | 1.011 (bf16) | 0.897 (fused, 65536 rows) | 2.090 | 3.998 | — | +| dedup, torch quant | 0.466 (int8) | ~1.68 (torch, 8192 rows) | 2.090 | ~4.24 | **0.94x** | +| dedup, fused quant | 0.466 (int8) | ~0.11 (fused, 8192 rows) | 2.090 | ~2.67 | **~1.50x** | + +The fused quantizer has no standalone Python entry point, so the benchmark +does not assume its cost: it times the same GEMM with 16-bit input and with +int8 input, on the same shape and the same weights, and takes the difference — +the only work that differs is the in-kernel quantization of exactly those +rows. Doing that at both `T` and `batch` rows also cross-checks that the cost +is linear in rows, which it must be for a streaming pass; the test asserts the +ratio lands within 2x of `top_k`, so a difference that is really measurement +noise cannot quietly become a headline number. + +The practical consequence: **do not deduplicate with an eager-torch +quantizer.** The version worth shipping folds the quantization into the +epilogue of whatever produces `hidden_states` (the norm ahead of the MoE), +where the row is already in registers and the absmax is free — which is the +same "upstream this is free" the contract above describes. + Two limits worth stating plainly: * **Up/gate only.** The down projection's `T` rows are the SiLU output: one diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 367d05f564..496e23dcfb 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -376,6 +376,32 @@ permute 减半,因为 permute 现在每个元素只搬 1 字节而不是 2 字 `test_perf_prefill_dedup_quant_long_seq` 会把两条路径**连同 permute 一起**计时 ——只有这样的对比才能区分真正的节省和被挪走的开销——并断言两者输出一致。 +#### 去重那部分由谁来量化,决定了它是赢还是输 + +行数变少并不自动等于时间变短,第一次实测就是这么说的:**0.94x**,是倒退。permute +如预期减半(1.011 → 0.466 ms),但量化 8192 行反而比在算子内量化 65536 行还贵。 + +原因在量化器,不在去重。`_quantize_rows` 是 eager-torch 参考实现——它先升到 fp32, +然后每个算子走一遍张量,一共约七遍,每遍都物化一个全尺寸中间结果。而调用内路径用的是 +融合的 SYCL 量化器,每行只读一次、absmax 一直留在寄存器里。按每行算大约便宜十五倍, +这足以把 8 倍的行数削减吃掉: + +| 路径 | permute | quant | GEMM | 合计 | vs 调用内 | +|---|---|---|---|---|---| +| 调用内量化 | 1.011(bf16) | 0.897(融合,65536 行) | 2.090 | 3.998 | — | +| 去重 + torch 量化 | 0.466(int8) | ~1.68(torch,8192 行) | 2.090 | ~4.24 | **0.94x** | +| 去重 + 融合量化 | 0.466(int8) | ~0.11(融合,8192 行) | 2.090 | ~2.67 | **~1.50x** | + +融合量化器没有独立的 Python 入口,所以 benchmark 不去假设它的开销:它在同一形状、 +同一份权重上分别计时 16-bit 输入和 int8 输入的同一个 GEMM,取差值——两者唯一不同的 +工作就是对这些行做的算子内量化。在 `T` 行和 `batch` 行上各做一次,还能交叉验证这个 +开销确实与行数成正比(流式 pass 必然如此);测试会断言这个比值落在 `top_k` 的 2 倍 +以内,这样一个其实只是测量噪声的差值就不会悄悄变成一个醒目的结论数字。 + +实际结论是:**不要用 eager-torch 量化器去做去重。**值得上线的版本是把量化折进产出 +`hidden_states` 的那个算子的 epilogue(MoE 之前的 norm),那里行本来就在寄存器里、 +absmax 是免费的——也就是上面契约里说的“上游做这件事是免费的”。 + 有两条边界需要讲清楚: * **只适用于 up/gate。**down 投影的 `T` 行是 SiLU 的输出:每一条路由行对应一行不同的 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index ba42fddc36..b17346fd19 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1463,13 +1463,39 @@ def run_dedup_quant(batches=None, dtype=torch.bfloat16, models=None, verbose=Tru ``batch`` rows instead of ``batch * top_k``, and the permute then moves int8 instead of 16-bit. - Both paths are timed **including the caller's permute**, which is what - makes this an honest comparison rather than an accounting shift. The - pre-quantized contract on its own only moves the quantization across the - call boundary; it is the deduplication -- and the halved permute that comes - with moving int8 instead of bf16 -- that makes the work actually smaller. - A caller who quantized the *permuted* rows themselves would see the - contract's gain cancel almost exactly. + Every path is timed **including the caller's permute and its + quantization**, which is what makes this an honest comparison rather than + an accounting shift. The pre-quantized contract on its own only moves the + quantization across the call boundary; it is the deduplication -- and the + halved permute that comes with moving int8 instead of bf16 -- that makes + the work actually smaller. A caller who quantized the *permuted* rows + themselves would see the contract's gain cancel almost exactly. + + **Which quantizer does the deduplicated work matters more than the + deduplication.** :func:`_quantize_rows` is the eager-torch reference: it + upcasts to fp32 and walks the tensor about seven times, once per operator, + materializing a full-size intermediate each time. The in-call path uses the + fused SYCL quantizer, which reads each row once and keeps the absmax in + registers. Comparing them directly charges the deduplicated path a constant + factor roughly fifteen times worse per row, which is more than enough to + eat an 8x reduction in rows -- so this reports all three points and lets + the columns say which effect is which: + + * ``in-call quant`` -- what ships today: permute 16-bit, kernel quantizes + ``T`` rows with the fused quantizer. + * ``dedup, torch quant`` -- the deduplication done with the reference + quantizer. Not a shipping configuration; it is here because it is the + obvious way to try this and it *loses*, and that is worth recording. + * ``dedup, fused quant`` -- the deduplication with a quantizer of the same + quality as the one already in the kernel. + + The fused quantizer has no standalone Python entry point, so its cost is + measured rather than assumed: the same GEMM is timed with 16-bit input and + with int8 input, on the same shape and the same weights, and the difference + is the in-kernel quantization of exactly those rows. Doing that at both + ``T`` and ``batch`` rows also cross-checks that the cost is linear in rows + (it should divide by ``top_k``), which is reported as ``fused quant + T/batch`` so a bad measurement cannot pass silently. Up/gate only. The down projection's ``T`` rows are the SiLU output, one distinct row per routed row, so there is nothing to deduplicate; its route @@ -1488,7 +1514,8 @@ def run_dedup_quant(batches=None, dtype=torch.bfloat16, models=None, verbose=Tru ) print( f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'batch':>8}{'topk':>6}" - f"{'path':>22}{'permute(ms)':>13}{'gemm(ms)':>11}{'total(ms)':>12}{'vs in-call':>12}" + f"{'path':>21}{'permute(ms)':>13}{'quant(ms)':>11}{'gemm(ms)':>11}{'total(ms)':>12}" + f"{'vs in-call':>12}" ) print("-" * _PERF_WIDTH) rows = [] @@ -1513,41 +1540,94 @@ def run_dedup_quant(batches=None, dtype=torch.bfloat16, models=None, verbose=Tru # is one-off setup a caller does once per layer, not per call, so it # stays outside both timed paths. n_tokens = case["batch"] - hidden = case["activations"][:n_tokens].contiguous() + # `clone`, not `contiguous`: a leading slice of a contiguous tensor is + # already contiguous, so `contiguous()` would hand back a view and keep + # the whole [T, K] allocation alive through it. This function holds + # several large tensors live at once -- both permuted forms are + # materialized so each stage can be timed on its own -- so the 268 MB + # the full activations occupy at this shape is worth actually + # releasing. + hidden = case["activations"][:n_tokens].clone() index = case["row_to_token"].long() + ntpe_full = case["ntpe"] + case["activations"] = case["packed"] = case["scales"] = None + _release_xpu_memory() + + # Routing histogram for a `batch`-row call: same weights, same experts, + # one row per distinct token. Only used to isolate the fused + # quantizer's cost at that row count. + ntpe_batch = torch.tensor(_spread_tokens(n_tokens, E), dtype=torch.int32, device=hidden.device) - def _call(act, ascale=None): + def _call(act, ascale=None, ntpe=None): kwargs = {"rescale_block_size": block, "phase": "prefill"} if ascale is not None: kwargs["activation_scale"] = ascale kwargs["out_dtype"] = dtype - return ark.moe_gemm_w4a8(act, weights_s8, wscales, case["ntpe"], **kwargs) - - def _today(): - return _call(hidden.index_select(0, index)) - - def _dedup(): - qact, ascale = _quantize_rows(hidden) - return _call(qact.index_select(0, index), ascale.index_select(0, index)) + return ark.moe_gemm_w4a8(act, weights_s8, wscales, ntpe_full if ntpe is None else ntpe, **kwargs) + + # Materialized once, outside every timed region: each stage is timed on + # its own so the reported columns are attributable, and the three + # stages form a true dependency chain (permute -> quantize -> GEMM), + # which is why summing separately-timed stages is a faithful model of + # running them back to back. + qact_batch, ascale_batch = _quantize_rows(hidden) + permuted_bf16 = hidden.index_select(0, index) + permuted_int8 = qact_batch.index_select(0, index) + permuted_scale = ascale_batch.index_select(0, index) # Round-robin, min-filtered: the same guard against clock droop the - # other sweeps use, since the two paths are being compared to each - # other rather than reported in isolation. - today, dedup, perm_bf16, perm_int8 = [], [], [], [] + # other sweeps use, since these points are compared to each other + # rather than reported in isolation. + stages = { + k: [] + for k in ( + "perm_bf16", + "perm_int8", + "quant_torch", + "gemm_bf16_t", + "gemm_int8_t", + "gemm_bf16_b", + "gemm_int8_b", + ) + } iters = max(1, ITERS // SWEEP_ROUNDS) - qact_ref = _quantize_rows(hidden)[0] for _ in range(SWEEP_ROUNDS): - today.append(_xpu_time_ms(_today, iters=iters)) - dedup.append(_xpu_time_ms(_dedup, iters=iters)) - perm_bf16.append(_xpu_time_ms(lambda: hidden.index_select(0, index), iters=iters)) - perm_int8.append(_xpu_time_ms(lambda: qact_ref.index_select(0, index), iters=iters)) - today_ms, dedup_ms = min(today), min(dedup) - perm_bf16_ms, perm_int8_ms = min(perm_bf16), min(perm_int8) + stages["perm_bf16"].append(_xpu_time_ms(lambda: hidden.index_select(0, index), iters=iters)) + stages["perm_int8"].append( + _xpu_time_ms( + lambda: (qact_batch.index_select(0, index), ascale_batch.index_select(0, index)), iters=iters + ) + ) + stages["quant_torch"].append(_xpu_time_ms(lambda: _quantize_rows(hidden), iters=iters)) + stages["gemm_bf16_t"].append(_xpu_time_ms(lambda: _call(permuted_bf16), iters=iters)) + stages["gemm_int8_t"].append(_xpu_time_ms(lambda: _call(permuted_int8, permuted_scale), iters=iters)) + stages["gemm_bf16_b"].append(_xpu_time_ms(lambda: _call(hidden, ntpe=ntpe_batch), iters=iters)) + stages["gemm_int8_b"].append( + _xpu_time_ms(lambda: _call(qact_batch, ascale_batch, ntpe=ntpe_batch), iters=iters) + ) + t = {k: min(v) for k, v in stages.items()} + + # The fused quantizer has no standalone entry point, so difference the + # same GEMM with 16-bit and int8 input: identical shape, identical + # weights, identical output -- the only work that differs is the + # in-kernel quantization of exactly those rows. + quant_fused_t = max(t["gemm_bf16_t"] - t["gemm_int8_t"], 0.0) + quant_fused_b = max(t["gemm_bf16_b"] - t["gemm_int8_b"], 0.0) + # Should be ~top_k: the quantizer is a pure streaming pass, so its cost + # is linear in rows. A ratio far from top_k means one of the two + # differences is noise rather than signal. + fused_ratio = (quant_fused_t / quant_fused_b) if quant_fused_b > 0 else None + + perm_bf16_ms, perm_int8_ms = t["perm_bf16"], t["perm_int8"] + gemm_ms = t["gemm_int8_t"] + today_ms = perm_bf16_ms + t["gemm_bf16_t"] + dedup_torch_ms = perm_int8_ms + t["quant_torch"] + gemm_ms + dedup_fused_ms = perm_int8_ms + quant_fused_b + gemm_ms # The deduplicated path must not change the result: the row absmax is # expert-independent, so quantizing before the permute and permuting # the int8 is the same arithmetic on the same row values. - snr_db = _snr_db(_today().to(torch.float32), _dedup().to(torch.float32)) + snr_db = _snr_db(_call(permuted_bf16).to(torch.float32), _call(permuted_int8, permuted_scale).to(torch.float32)) rows.append( { @@ -1559,25 +1639,35 @@ def _dedup(): "batch": n_tokens, "topk": topk, "today_ms": today_ms, - "dedup_ms": dedup_ms, + "dedup_ms": dedup_fused_ms, + "dedup_torch_ms": dedup_torch_ms, "permute_bf16_ms": perm_bf16_ms, "permute_int8_ms": perm_int8_ms, - "speedup": (today_ms / dedup_ms) if dedup_ms else None, + "gemm_int8_ms": gemm_ms, + "quant_torch_ms": t["quant_torch"], + "quant_fused_batch_ms": quant_fused_b, + "quant_fused_tokens_ms": quant_fused_t, + "fused_ratio": fused_ratio, + "speedup": (today_ms / dedup_fused_ms) if dedup_fused_ms else None, + "speedup_torch": (today_ms / dedup_torch_ms) if dedup_torch_ms else None, "snr_db": snr_db, } ) if verbose: - for path, total_ms, perm_ms, speedup in ( - ("in-call quant", today_ms, perm_bf16_ms, None), - ("dedup quant", dedup_ms, perm_int8_ms, today_ms / dedup_ms if dedup_ms else None), + for path, perm_ms, quant_ms, total_ms in ( + ("in-call quant", perm_bf16_ms, quant_fused_t, today_ms), + ("dedup, torch quant", perm_int8_ms, t["quant_torch"], dedup_torch_ms), + ("dedup, fused quant", perm_int8_ms, quant_fused_b, dedup_fused_ms), ): + speedup = None if total_ms == today_ms else (today_ms / total_ms if total_ms else None) print( f"{nk_label:<14}{E:>5}{N:>7}{K:>7}{total_tokens:>8}{n_tokens:>8}{topk:>6}" - f"{path:>22}{perm_ms:>13.3f}{max(total_ms - perm_ms, 0.0):>11.3f}{total_ms:>12.3f}" + f"{path:>21}{perm_ms:>13.3f}{quant_ms:>11.3f}{gemm_ms:>11.3f}{total_ms:>12.3f}" f"{(f'{speedup:.2f}x' if speedup else '--'):>12}" ) - case = weights_s8 = wscales = hidden = qact_ref = None + case = weights_s8 = wscales = hidden = None + qact_batch = ascale_batch = permuted_bf16 = permuted_int8 = permuted_scale = None ark.clear_moe_w4a8_prepack_cache() ark.moe_w4a8_release_scratch() _release_xpu_memory() @@ -1586,12 +1676,24 @@ def _dedup(): print() print("deduplicated dynamic quantization [prefill] (up/gate only; the down projection has no duplicate rows):") for row in rows: + ratio = row["fused_ratio"] print( f" {row['label']:<12} {row['today_ms']:.3f} ms -> {row['dedup_ms']:.3f} ms " f"{row['speedup']:.2f}x end-to-end (quantizes {row['batch']} rows instead of " f"{row['tokens']}, permutes int8 instead of {str(dtype).split('.')[-1]}) " f"SNR {row['snr_db']:.1f} dB" ) + print( + f" {'':<12} with the eager-torch quantizer instead: {row['dedup_torch_ms']:.3f} ms " + f"({row['speedup_torch']:.2f}x) -- {row['quant_torch_ms']:.3f} ms to quantize " + f"{row['batch']} rows, vs {row['quant_fused_batch_ms']:.3f} ms fused" + ) + print( + f" {'':<12} fused quant measured by differencing: {row['quant_fused_tokens_ms']:.3f} ms at " + f"{row['tokens']} rows / {row['quant_fused_batch_ms']:.3f} ms at {row['batch']} rows = " + f"{(f'{ratio:.1f}x' if ratio else 'n/a')} for {row['topk']}x the rows" + f"{'' if ratio and 0.5 * row['topk'] <= ratio <= 2.0 * row['topk'] else ' <- NOT linear, treat as noise'}" + ) return rows @@ -2427,6 +2529,20 @@ def test_perf_prefill_dedup_quant_long_seq(self, request): f"deduplicated quantization changed {row['label']}: " f"SNR {row['snr_db']:.2f} dB below {_SWEEP_MIN_SNR_DB}" ) + # The fused quantizer's cost is obtained by differencing two + # GEMM timings, so it is only meaningful if the difference is + # signal. A pure streaming pass must scale with rows: if + # quantizing top_k times as many rows does not cost roughly + # top_k times as much, the differences are measurement noise + # and every number derived from them is meaningless. + ratio, topk = row["fused_ratio"], row["topk"] + assert ratio is not None and 0.5 * topk <= ratio <= 2.0 * topk, ( + f"fused activation quantization does not scale with rows on {row['label']}: " + f"{row['quant_fused_tokens_ms']:.3f} ms at {row['tokens']} rows vs " + f"{row['quant_fused_batch_ms']:.3f} ms at {row['batch']} rows " + f"({'n/a' if ratio is None else f'{ratio:.2f}x'} for {topk}x the rows) -- " + f"the GEMM difference is noise, not the quantizer" + ) def test_perf_prefill_prequant_long_seq(self, request): """Prefill throughput for one 8K prompt with the int8-in contract alone. From 271c77e51996b4c97c0ad76cc5e65da75e906ada Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:46:26 +0000 Subject: [PATCH 100/112] docs: record the measured 1.46x for deduplicated activation quantization B70 confirms the diagnosis and the fix. With the fused quantizer the deduplicated path is 3.862 -> 2.637 ms, 1.46x end to end including the caller's permute, against a traffic model that predicted 1.53x. With the eager-torch quantizer it is 0.95x, which is the earlier regression reproduced now that the stages are attributable. Replaces the projected numbers with measured ones, records the fused quantizer at 0.818 ms / 65536 rows and 0.136 ms / 8192 rows (6.0x for 8x the rows, sublinear in the direction fixed launch cost predicts), and corrects the claim in "What is left" that contract 1 needs an int8 producer upstream -- it does not, which is the whole point of the dedup. Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 50 +++++++++++++------ .../ark/test/README_MOE_W4A8_CN.md | 39 ++++++++++----- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 96597e2ee8..2d5bae263e 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -437,22 +437,28 @@ saving from a relocated one — and asserts the outputs agree. #### Which quantizer does the deduplicated work decides whether it wins Fewer rows is not automatically less time, and the first measurement of this -said so: **0.94x**, a regression. The permute halved exactly as predicted -(1.011 → 0.466 ms), but the quantization of 8192 rows cost *more* than the +said so: **0.95x**, a regression. The permute halved exactly as predicted +(1.011 → 0.468 ms), but the quantization of 8192 rows cost *more* than the in-kernel quantization of 65536. The cause was the quantizer, not the deduplication. `_quantize_rows` is the eager-torch reference — it upcasts to fp32 and walks the tensor about seven times, once per operator, materializing a full-size intermediate each time. The in-call path uses the fused SYCL quantizer, which reads each row once and -keeps the absmax in registers. Per row it is roughly fifteen times cheaper, -which is more than enough to eat an 8x reduction in rows: +keeps the absmax in registers. Per row it is about eleven times cheaper, which +is more than enough to eat an 8x reduction in rows. All three points, measured +on B70 at the qwen3 up-projection shape with an 8K prompt: | path | permute | quant | GEMM | total | vs in-call | |---|---|---|---|---|---| -| in-call quant | 1.011 (bf16) | 0.897 (fused, 65536 rows) | 2.090 | 3.998 | — | -| dedup, torch quant | 0.466 (int8) | ~1.68 (torch, 8192 rows) | 2.090 | ~4.24 | **0.94x** | -| dedup, fused quant | 0.466 (int8) | ~0.11 (fused, 8192 rows) | 2.090 | ~2.67 | **~1.50x** | +| in-call quant | 1.011 (bf16) | 0.818 (fused, 65536 rows) | 2.033 | 3.862 | — | +| dedup, torch quant | 0.468 (int8) | 1.558 (torch, 8192 rows) | 2.033 | 4.059 | **0.95x** | +| dedup, fused quant | 0.468 (int8) | 0.136 (fused, 8192 rows) | 2.033 | **2.637** | **1.46x** | + +The traffic model above predicted 1.53x and the device returned 1.46x, so the +byte count is what is driving this. Against W4A16 on the same end-to-end basis +(its GEMM measured 3.679 ms, and it permutes 16-bit) the deduplicated path is +**1.78x**. The fused quantizer has no standalone Python entry point, so the benchmark does not assume its cost: it times the same GEMM with 16-bit input and with @@ -461,13 +467,19 @@ the only work that differs is the in-kernel quantization of exactly those rows. Doing that at both `T` and `batch` rows also cross-checks that the cost is linear in rows, which it must be for a streaming pass; the test asserts the ratio lands within 2x of `top_k`, so a difference that is really measurement -noise cannot quietly become a headline number. +noise cannot quietly become a headline number. Measured, it is **6.0x for 8x +the rows** — mildly sublinear, in the direction fixed per-launch cost predicts +(33% more expensive per row at the smaller size), which is why the check is a +band rather than an equality. The practical consequence: **do not deduplicate with an eager-torch quantizer.** The version worth shipping folds the quantization into the epilogue of whatever produces `hidden_states` (the norm ahead of the MoE), where the row is already in registers and the absmax is free — which is the -same "upstream this is free" the contract above describes. +same "upstream this is free" the contract above describes. Failing that, a +single fused quantization kernel over the `[batch, K]` hidden states is what +the 0.136 ms column represents; the 1.558 ms column is what calling eager +torch costs instead. Two limits worth stating plainly: @@ -1363,14 +1375,24 @@ the activation round-trip saved and those runs said nothing about it. Isolated: | qwen3 down | 2.596 ms | 2.107 ms | **1.796 ms** | 97.83 → **114.78** | 1.16x → **1.45x** | It deletes `3 * T * K` bytes — 402 MB of qwen3 up's 1141 MB — and the predicted -~1.97 ms landed within 6% of the measured 2.090 ms. It is also the contract a -serving stack can adopt without touching layer code, since the previous op in -most quantized pipelines already produced int8, and unlike contract 2 it keeps -the result bit-identical: +~1.97 ms landed within 6% of the measured 2.090 ms. Unlike contract 2 it keeps +the result bit-identical. + +The obvious objection is that it needs an int8 producer upstream, and most +pipelines do not have one — the previous operator emits bf16 and the dynamic +quantization has to happen somewhere. It is reachable anyway, because the row +absmax does not depend on the expert, so on the up/gate projection the in-call +pass quantizes `top_k` identical copies of every token. Quantizing the `batch` +distinct rows once and permuting int8 gets to the same call, and measures +**1.46x end to end** including the caller's permute — the whole story is in +[deduplicate the quantization](#reaching-contract-1-with-no-int8-upstream-deduplicate-the-quantization), +including the way it is a *regression* if the deduplicated rows go through an +eager-torch quantizer. ```bash pytest test_moe_w4a8_perf.py -k prequant_long_seq -v # contract 1 alone -pytest test_moe_w4a8_perf.py -k contracts_long_seq -v # both, for the contrast +pytest test_moe_w4a8_perf.py -k dedup_quant -v # reaching it without int8 upstream +pytest test_moe_w4a8_perf.py -k contracts_long_seq -v # both contracts, for the contrast ``` That second sweep used to answer the question unfairly, and the bug ran against diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 496e23dcfb..14793280fe 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -378,29 +378,37 @@ permute 减半,因为 permute 现在每个元素只搬 1 字节而不是 2 字 #### 去重那部分由谁来量化,决定了它是赢还是输 -行数变少并不自动等于时间变短,第一次实测就是这么说的:**0.94x**,是倒退。permute -如预期减半(1.011 → 0.466 ms),但量化 8192 行反而比在算子内量化 65536 行还贵。 +行数变少并不自动等于时间变短,第一次实测就是这么说的:**0.95x**,是倒退。permute +如预期减半(1.011 → 0.468 ms),但量化 8192 行反而比在算子内量化 65536 行还贵。 原因在量化器,不在去重。`_quantize_rows` 是 eager-torch 参考实现——它先升到 fp32, 然后每个算子走一遍张量,一共约七遍,每遍都物化一个全尺寸中间结果。而调用内路径用的是 -融合的 SYCL 量化器,每行只读一次、absmax 一直留在寄存器里。按每行算大约便宜十五倍, -这足以把 8 倍的行数削减吃掉: +融合的 SYCL 量化器,每行只读一次、absmax 一直留在寄存器里。按每行算大约便宜十一倍, +这足以把 8 倍的行数削减吃掉。三个点在 B70 上、qwen3 up 投影形状、8K prompt 的实测: | 路径 | permute | quant | GEMM | 合计 | vs 调用内 | |---|---|---|---|---|---| -| 调用内量化 | 1.011(bf16) | 0.897(融合,65536 行) | 2.090 | 3.998 | — | -| 去重 + torch 量化 | 0.466(int8) | ~1.68(torch,8192 行) | 2.090 | ~4.24 | **0.94x** | -| 去重 + 融合量化 | 0.466(int8) | ~0.11(融合,8192 行) | 2.090 | ~2.67 | **~1.50x** | +| 调用内量化 | 1.011(bf16) | 0.818(融合,65536 行) | 2.033 | 3.862 | — | +| 去重 + torch 量化 | 0.468(int8) | 1.558(torch,8192 行) | 2.033 | 4.059 | **0.95x** | +| 去重 + 融合量化 | 0.468(int8) | 0.136(融合,8192 行) | 2.033 | **2.637** | **1.46x** | + +上面的流量模型预测 1.53x,设备返回 1.46x,说明推动这个结果的确实是字节数。以同样的 +端到端口径对比 W4A16(其 GEMM 实测 3.679 ms,且它 permute 的是 16-bit),去重路径是 +**1.78x**。 融合量化器没有独立的 Python 入口,所以 benchmark 不去假设它的开销:它在同一形状、 同一份权重上分别计时 16-bit 输入和 int8 输入的同一个 GEMM,取差值——两者唯一不同的 工作就是对这些行做的算子内量化。在 `T` 行和 `batch` 行上各做一次,还能交叉验证这个 开销确实与行数成正比(流式 pass 必然如此);测试会断言这个比值落在 `top_k` 的 2 倍 -以内,这样一个其实只是测量噪声的差值就不会悄悄变成一个醒目的结论数字。 +以内,这样一个其实只是测量噪声的差值就不会悄悄变成一个醒目的结论数字。实测是 +**8 倍行数对应 6.0x**——略低于线性,方向正是固定的每次 launch 开销所预期的(较小规模 +下每行贵 33%),这也是为什么这个检查用的是一个区间而不是相等。 实际结论是:**不要用 eager-torch 量化器去做去重。**值得上线的版本是把量化折进产出 `hidden_states` 的那个算子的 epilogue(MoE 之前的 norm),那里行本来就在寄存器里、 -absmax 是免费的——也就是上面契约里说的“上游做这件事是免费的”。 +absmax 是免费的——也就是上面契约里说的“上游做这件事是免费的”。退一步,用一个融合的 +量化 kernel 处理 `[batch, K]` 的 hidden states,就是 0.136 ms 那一列所代表的;而 +1.558 ms 那一列是改用 eager torch 的代价。 有两条边界需要讲清楚: @@ -1185,12 +1193,19 @@ qwen3 down 未规约的 `[T, N]` 输出单独就有 268 MB——占整个调用 | qwen3 down | 2.596 ms | 2.107 ms | **1.796 ms** | 97.83 → **114.78** | 1.16x → **1.45x** | 它删掉 `3 * T * K` 字节——qwen3 up 全部 1141 MB 中的 402 MB——而此前推算的约 1.97 ms -与实测的 2.090 ms 相差在 6% 以内。它同时也是推理框架无需改动层代码就能采纳的契约, -因为多数量化流水线里上一个算子本来就产出 int8;并且与契约 2 不同,它保持结果逐位一致: +与实测的 2.090 ms 相差在 6% 以内。与契约 2 不同,它保持结果逐位一致。 + +一个显而易见的反驳是:它需要上游能产出 int8,而多数流水线并没有——上一个算子输出 bf16, +动态量化总得有人做。但它依然是可达的,因为逐行 absmax 不依赖专家,所以在 up/gate 投影上, +调用内的那一遍量化处理的是每个 token 的 `top_k` 份相同副本。把 `batch` 个不同的行量化 +一次、再 permute int8,就能到达同一个调用,实测**端到端 1.46x**(已包含调用方的 +permute)——完整过程见[去重量化](#上游没有-int8-时如何用上契约-1去重量化),其中也包括: +如果去重后的行交给 eager-torch 量化器来做,它反而是**倒退**。 ```bash pytest test_moe_w4a8_perf.py -k prequant_long_seq -v # 只开契约 1 -pytest test_moe_w4a8_perf.py -k contracts_long_seq -v # 两个都开,用于对照 +pytest test_moe_w4a8_perf.py -k dedup_quant -v # 上游没有 int8 时如何达到它 +pytest test_moe_w4a8_perf.py -k contracts_long_seq -v # 两个契约都开,用于对照 ``` 后面这个扫描此前的对比方式并不公平,而且偏差的方向恰好不利于该契约:它把每个配置都只按 From 3318b69f1c98b52a67d312393e564b79226dcb31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:15:00 +0000 Subject: [PATCH 101/112] perf: issue the w4a8 prefill tile claim before the GEMM instead of after it Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../include/sycl_tla_moe_prefill_fp8_dpas.hpp | 7 + .../wrapper/include/sycl_tla_moe_w4a8.hpp | 85 +++++++++-- .../ark/test/test_moe_w4a8_perf.py | 141 ++++++++++++++++++ 3 files changed, 219 insertions(+), 14 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp index ed53dd6c36..a2046be6bb 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_prefill_fp8_dpas.hpp @@ -192,6 +192,13 @@ using cute_scalar_t = typename cute_scalar::type; // entirely on the in-kernel `atm.store(0)` above. That was already true with // `sycl::malloc_device`, whose contents are equally undefined -- the reuse // changes what the pre-store garbage looks like, not whether it matters. +// +// The W4A8 launcher is the exception: it zeroes the slot on the host and makes +// its kernel depend on that fill (see `MoEGEMMLauncher_w4a8`), because it +// claims the next tile *before* computing the current one and so cannot rely on +// group 0 winning a race it would otherwise have won by microseconds. Sharing +// the slot with the self-initialising paths stays safe -- every launcher is +// synchronous, so only one of them is ever in flight. // --------------------------------------------------------------------------- // Scratch-pool slot dedicated to the work-group counter. Slots 0-7 are already diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 565f6857d9..37185a1186 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -757,8 +757,8 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const int* rows_per_expert, const int32_t num_experts, const int32_t gemm_n, const int32_t gemm_k, const int32_t blocksize, const int32_t blks, const bool allow_full_tile, const bool allow_block_2d_store, - const int32_t prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer, - const sycl::local_accessor& slm_mem_const) { + const int32_t prefetch_dist, const bool claim_early, MoEFusedReduce reduce, + int32_t* atomic_buffer, const sycl::local_accessor& slm_mem_const) { auto item = sycl::ext::oneapi::this_work_item::get_nd_item<3>(); auto wg_tile = mma.tile_mnk(); auto wg_tile_m = get<0>(wg_tile); @@ -770,15 +770,25 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, int group_range = item.get_group_range(1); int local_id = item.get_local_linear_id(); - if (group_id == 0 && local_id == 0) { - auto atm = sycl::atomic_ref(atomic_buffer[0]); - atm.store(0); - } + // The work-stealing counter arrives already zeroed: `MoEGEMMLauncher_w4a8` + // fills it on the host and makes the kernel depend on that fill. It used to + // be reset here by group 0 / lane 0, which is unordered against every other + // work-group's `atomicAdd` on the same dword -- it held only because a + // work-group's first claim came after a whole GEMM tile, microseconds after + // group 0's store. `claim_early` moves the first claim to within a few + // instructions of kernel entry, which would make that window real, so the + // reset moved to the one place where it is ordered by construction. int pre_rows = 0; int pre_tiles = 0; + // Ping-pong slot for the claimed tile index. One SLM dword would need a + // second barrier after every read to stop lane 0 overwriting it while a + // slower sub-group is still reading; alternating between two makes the + // write of iteration `i + 1` target a different address from the read of + // iteration `i`, so the single barrier below is enough. + int slot = 0; + int32_t* slm_mem = static_cast(slm_mem_const.template get_multi_ptr().get()); @@ -818,17 +828,39 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, const int n_coord = (group_id * wg_tile_n) % gemm_n_pad / wg_tile_n; const int m_coord = (group_m_id - pre_tiles); + // Claiming the next tile is a device-scope atomic on a single dword that + // every resident work-group hits. Issued in the gap between two tiles it + // is a fully exposed L2 round trip; issued here, the message is in flight + // across the whole GEMM and only the *use* of its result -- the SLM store + // below -- waits on it. Keeping the result in a private register rather + // than storing it straight to SLM is what defers that wait: an SLM store + // right after the atomic would stall lane 0 immediately, and with it the + // whole work-group at the mainloop's first barrier. The atomic itself + // cannot sink past those barriers, so it stays where it is written. + // + // The claim is otherwise untouched -- one `atomicAdd` per tile, one tile + // index per claim, handed out in the same order -- so both orders compute + // the same tiles from the same inputs and write the same bytes; only when + // the request is issued changes. It is worth most where the mainloop is + // shortest (12 k-tiles at K = 768) and there is least work per tile to + // hide the stall behind. + int claimed = 0; + if (claim_early && local_id == 0) { + claimed = cutlass::atomicAdd(atomic_buffer, 1); + } + xe_gemm_w4a8(ptr_A_curr_batch, ptr_B_curr_batch, ptr_D_curr_batch, ptr_SA_curr_batch, ptr_SB_curr_batch, gemm_m, gemm_n, gemm_k, blocksize, blks, m_coord, n_coord, allow_full_tile, allow_block_2d_store, prefetch_dist, expert_reduce, mma); if (local_id == 0) { - slm_mem[0] = cutlass::atomicAdd(atomic_buffer, 1); + slm_mem[slot] = claim_early ? claimed : cutlass::atomicAdd(atomic_buffer, 1); } item.barrier(sycl::access::fence_space::local_space); - group_id = group_range + slm_mem[0]; + group_id = group_range + slm_mem[slot]; group_m_id = (group_id * wg_tile_n) / gemm_n_pad; + slot ^= 1; } pre_rows = cumsum_rows_for_experts; pre_tiles = cumsum_tiles_for_experts; @@ -844,7 +876,8 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, const int blks, const bool allow_full_tile, const bool allow_block_2d_store, - const int prefetch_dist, MoEFusedReduce reduce, int32_t* atomic_buffer) { + const int prefetch_dist, const bool claim_early, MoEFusedReduce reduce, + int32_t* atomic_buffer) { using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; using WGTile = typename Policy::WGTile; using SGLayout = typename Policy::SGLayout; @@ -870,14 +903,25 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const using GmemTiledCopyA = typename Policy::GmemTiledCopyA; using GmemTiledCopyB = typename Policy::GmemTiledCopyB; + // Zero the work-stealing counter on the host rather than from group 0 at + // kernel entry. The slot is pool-cached and shared across dispatches, so it + // arrives holding the previous call's final count; the in-kernel reset was + // unordered against the other work-groups' `atomicAdd` on the same dword, + // and `claim_early` shrinks the window it relied on to a few instructions. + // `depends_on` orders the fill ahead of the kernel on in-order and + // out-of-order queues alike, and the fill is one dword -- it costs a command + // rather than a synchronization. + auto fill = stream.memset(atomic_buffer, 0, sizeof(int32_t)); + auto event = stream.submit([&](sycl::handler& cgh) { - sycl::local_accessor local_mem(sycl::range<1>(1), cgh); + cgh.depends_on(fill); + sycl::local_accessor local_mem(sycl::range<1>(2), cgh); cgh.parallel_for>( sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, blks, allow_full_tile, allow_block_2d_store, prefetch_dist, - reduce, atomic_buffer, local_mem); + claim_early, reduce, atomic_buffer, local_mem); }); }); @@ -940,6 +984,18 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // tensor base. Every shipped N (1536 / 2048 / 3072 with 16-bit D) clears it; // anything that does not keeps the scalar store rather than risking a // misaligned descriptor. +// +// `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` puts the work-stealing claim back after +// the GEMM instead of before it, the A/B baseline for that change. The kernel +// is persistent -- the grid is sized to the device and tiles are handed out by +// a device-scope `atomicAdd` on one dword -- so between two tiles a work-group +// used to sit on a fully exposed L2 round trip. Issuing the claim ahead of the +// tile puts that round trip in flight across the DPAS mainloop instead. Which +// tiles get computed does not change (see `MoEGEMM_w4a8`), so the two settings +// are bit-identical and `test_prefill_claim_early_matches` asserts it; the +// difference is timing, and it is largest on the short-K down projections where +// a tile is only 12 k-tiles of work. +// `test_perf_prefill_claim_early_sweep` times the pair. // --------------------------------------------------------------------------- template void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { @@ -963,12 +1019,13 @@ void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { const bool allow_block_2d_store = store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); const int prefetch_dist = moe_w4a8_prefill_prefetch_dist(); + const bool claim_early = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_CLAIM_EARLY", true); int32_t* atomic_buffer = moe_dpas_fp8::get_atomic_scratch_buffer(p.q); MoEGEMMLauncher_w4a8(*p.q, p.qact, p.weights, p.ascale, p.wscale, outputs, p.N, p.K, p.num_tokens_per_expert, p.num_experts, p.blocksize, p.blks, - allow_full_tile, allow_block_2d_store, prefetch_dist, reduce, - atomic_buffer); + allow_full_tile, allow_block_2d_store, prefetch_dist, claim_early, + reduce, atomic_buffer); } } // namespace moe_w4a8 diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index b17346fd19..998ba01c05 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1816,6 +1816,27 @@ def _call(act, ascale=None, ntpe=None): (f"prefetch {dist}", {"ARK_MOE_W4A8_PREFILL_PREFETCH": str(dist)}) for dist in (1, 2, 3, 4, 6, 8) ] +# Prefill: when the persistent kernel asks for its next tile. +# +# The grid is sized to the device, not to the problem -- `sm_count` times the +# work-groups that fit on an Xe core -- and the tiles are handed out by a +# device-scope `atomicAdd` on a single dword that every resident work-group +# hits. Claiming *after* the GEMM puts that L2 round trip in the gap between +# two tiles, where the work-group has nothing to overlap it with; claiming +# before puts the message in flight across the DPAS mainloop and only the use +# of its result waits. +# +# The size of that stall is bounded by how much work a tile has to hide it +# behind, so this should show on the down projections and not much on the up +# ones: at K = 768 a tile is 12 k-tiles against 32 at K = 2048, and the down +# projection is where the measured TFLOPS sits furthest below the up +# projection's on the same tile shape and the same kernel. If the two rows tie +# on every shape, the claim was never the stall and this sweep says so. +_PREFILL_CLAIM_CONFIGS = [ + ("claim after gemm", {"ARK_MOE_W4A8_PREFILL_CLAIM_EARLY": "0"}), + ("claim before gemm", {"ARK_MOE_W4A8_PREFILL_CLAIM_EARLY": "1"}), +] + # Prefill: the two call contracts that cut traffic instead of cycles. # # Neither changes the GEMM. They change what crosses the call boundary, which @@ -2485,6 +2506,56 @@ def test_perf_prefill_prefetch_sweep_long_seq(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"prefetch depth {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_claim_early_sweep(self, request): + """Time when the persistent kernel claims its next tile. + + The grid is sized to the device, so a work-group does not own one + tile -- it loops, taking the next index from a device-scope + ``atomicAdd`` on a single dword that every resident work-group + hits. Claimed after the GEMM, that L2 round trip lands in the gap + between two tiles with nothing to overlap it; claimed before, the + message is in flight across the whole DPAS mainloop and only the + store of its result waits on it. + + Which tiles are computed does not change -- one claim per tile, + same indices, same order -- so every row must be bit-identical to + the first and only the timing is a measurement. + """ + rows = run_config_sweep("prefill", _PREFILL_CLAIM_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"claim order {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + + def test_perf_prefill_claim_early_sweep_long_seq(self, request): + """Time the tile-claim order at the 8K-prompt routing. + + Same sweep at the other prefill point, and the one that should + decide it. How much a claim stall costs depends on how much work + the tile it precedes has to hide it behind, so the effect is + bounded by the mainloop length: 12 k-tiles on the qwen3 down + projection against 32 on qwen3 up, at the same tile shape and the + same kernel. That is also the shape the shipped-contract numbers + leave furthest below its own bandwidth ceiling, so it is where a + per-tile fixed cost should be visible at all. + + A tie on every shape is a real result: it says the claim was not + the stall, and the down projection's gap is the prologue, the + epilogue or the D write instead. + """ + rows = run_config_sweep( + "prefill", + _PREFILL_CLAIM_CONFIGS, + models=_models_option(request), + batches=_long_seq_batches(), + ) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"claim order {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_contract_sweep(self, request): """Time the two traffic-cutting call contracts at the compute-bound batch. @@ -2972,6 +3043,55 @@ def test_prefill_2d_store_matches_scalar(self): f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}" ) + def test_prefill_claim_early_matches(self): + """Claiming the next tile early must not change which tiles run. + + The persistent kernel hands tiles out with a device-scope + ``atomicAdd`` on one dword. Moving that claim from after the GEMM + to before it changes only when the message is issued: the loop + still performs exactly one claim per tile and still consumes the + indices in the order the counter produces them, so the two orders + must agree bit for bit, not merely to an SNR. + + The failure this guards against is a work-stealing bug, and those + are silent: a tile computed twice or skipped leaves a band of the + output stale or doubled rather than raising. Two things could + cause it here -- the claimed index being consumed a tile late, and + the counter's reset racing the first claims now that they are + issued within a few instructions of kernel entry (which is why the + reset moved to the host). Both show up as a mismatch against the + claim-after-GEMM baseline. + + The batch is the ragged one: 300 rows on every expert against the + ladder's 128-row tile gives each expert two interior tiles and one + partial, so the expert boundaries the tile walk has to respect are + in the comparison, and every expert contributes more tiles than a + single work-group processes in one pass. + """ + rows_per_expert = _RAGGED_TILE_ROWS_PER_EXPERT + case = _build_case( + _QWEN3_NK[1][1], + _QWEN3_NK[1][2], + _QWEN3_E, + rows_per_expert * _QWEN3_E, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for flag in ("0", "1"): + with _env_override(ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=flag): + outs[flag] = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + assert torch.equal(outs["0"], outs["1"]), ( + "claiming the next tile before the GEMM changed the result: " + f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}, " + f"{(outs['0'] != outs['1']).sum().item()} of {outs['0'].numel()} elements differ" + ) + def test_decode_ksplit_matches_legacy(self): """The K-split decode mapping must agree with the legacy one. @@ -3142,6 +3262,16 @@ def _parse_args(argv): "different fraction of the mainloop on each. Pair with --long-seq for the 8K-prompt routing." ), ) + parser.add_argument( + "--claim-early", + action="store_true", + help=( + "Also sweep when the persistent prefill kernel claims its next tile: after the GEMM (the " + "old order, a device-scope atomic round trip fully exposed between two tiles) or before it, " + "in flight across the mainloop. Bounded by how much work a tile has to hide it behind, so " + "pair with --long-seq for the 12-k-tile down projections where it should show first." + ), + ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") parser.add_argument( @@ -3240,6 +3370,17 @@ def main(argv=None) -> int: models=models, batches=_long_seq_batches(), ) + if phase == "prefill" and args.claim_early: + if not args.long_seq or args.compute_bound: + run_config_sweep(phase, _PREFILL_CLAIM_CONFIGS, dtype=dtype, models=models) + if args.long_seq: + run_config_sweep( + phase, + _PREFILL_CLAIM_CONFIGS, + dtype=dtype, + models=models, + batches=_long_seq_batches(), + ) if failures: print() From a7a56787e4a00decbbe91e8a82785878731819ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:17:17 +0000 Subject: [PATCH 102/112] docs: record the early tile claim in both READMEs Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 67 +++++++++++++++++++ .../ark/test/README_MOE_W4A8_CN.md | 60 +++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 2d5bae263e..8540dff1e7 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1321,6 +1321,71 @@ pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq --rounds 5 ``` +### When the persistent kernel asks for its next tile + +The prefill GEMM is a **persistent** kernel: the grid is sized to the device +(`sm_count` × the work-groups that fit on an Xe core), not to the problem, so a +work-group does not own one tile — it loops, and takes the next tile index from +a device-scope `atomicAdd` on a single dword that every resident work-group +hits. + +That claim used to be issued *after* the tile it precedes: + +``` + [ GEMM tile ] -> atomicAdd -> wait -> [ GEMM tile ] -> atomicAdd -> wait ... + \___________________/ + nothing overlaps this +``` + +An L2 round trip on a contended dword, in the one place in the loop where the +work-group has no work in flight to hide it behind. It is now issued *before*: + +``` + atomicAdd -> [ GEMM tile ] -> use result -> atomicAdd -> [ GEMM tile ] -> ... + \____________/ + the message is in flight across the whole mainloop +``` + +Two details make that work rather than just move the stall: + +* **The result is kept in a private register, not stored straight to SLM.** An + SLM store immediately after the atomic would make lane 0 wait on the result + right there, and with it the whole work-group at the mainloop's first barrier. + Storing it after the GEMM is what defers the wait; the atomic itself cannot + sink past the mainloop's barriers, so it stays where it is written. +* **The SLM slot ping-pongs between two dwords.** With one dword, lane 0's write + for tile `i + 1` could overtake a slower sub-group still reading tile `i`'s + value, which would need a second barrier per tile to prevent. Two slots make + the write and the read target different addresses instead. + +Which tiles get computed does not change — one claim per tile, the same indices, +consumed in the same order — so the two orders are **bit-identical**, and +`test_prefill_claim_early_matches` asserts `torch.equal` on the ragged batch +(300 rows/expert, so every expert has interior *and* partial tiles and the +expert boundaries the tile walk has to respect are in the comparison). + +This also moved the work-stealing counter's reset to the host. It used to be +zeroed on the device by group 0 / lane 0 at kernel entry, which is unordered +against every other work-group's `atomicAdd` on the same dword — a race that +held only because a work-group's first claim came after a whole GEMM tile, +microseconds later. Claiming early shrinks that window to a few instructions, so +`MoEGEMMLauncher_w4a8` now fills the dword with `queue::memset` and makes the +kernel `depends_on` that fill. One dword, one extra command, no synchronization. + +How much this is worth is bounded by how much work a tile has to hide the stall +behind, so it should show on the **down** projections and barely on the up ones: +at `K = 768` a tile is 12 k-tiles against 32 at `K = 2048`, on the same tile +shape and the same kernel. qwen3 down is also the shape that sits furthest below +its own bandwidth ceiling. A tie on every shape is a real result — it would say +the claim was never the stall, and that the down projection's gap is the +prologue, the epilogue or the D write instead (see +[What is left](#what-is-left)). + +```bash +pytest test_moe_w4a8_perf.py -k "claim_early" -v +python test_moe_w4a8_perf.py --skip-accuracy --claim-early --long-seq --rounds 5 +``` + ### What is left With the prologue ruled out by measurement and the tile ladder already swept, @@ -1477,6 +1542,7 @@ analysis above is why. | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no). | +| `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | When the persistent prefill kernel claims its next tile from the device-scope work counter: **`1` (default)** issues the `atomicAdd` before the GEMM, so the L2 round trip is in flight across the mainloop; `0` restores the old order, where it sat fully exposed in the gap between two tiles. Which tiles run does not change, so the two are bit-identical (`test_prefill_claim_early_matches` asserts `torch.equal`); `test_perf_prefill_claim_early_sweep{,_long_seq}` times the pair. Worth most where a tile is short on work to hide the stall behind — see [When the persistent kernel asks for its next tile](#when-the-persistent-kernel-asks-for-its-next-tile). | ## Shape constraints @@ -1551,6 +1617,7 @@ have now been timed twice, and all three kept their default: | Activation quantizer's batched loads — `UNROLL` vectors in flight instead of one | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 1.02–1.03× at `UNROLL = 2` or `4` on the only shape that exercises it; 2 vs 4 is inside the noise | | Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×; the register-resident row does not spill | | 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×, the largest single prefill win | +| Tile claim issued before the GEMM instead of after it, so the work counter's device-scope atomic overlaps the mainloop | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | Not yet measured on B70 — bounded by the per-tile stall, so expected on the 12-k-tile down projections and near-nil on the up ones | The 2D store was previously listed as needing a device rather than a flag, on the grounds that the sibling MoE kernels reach it through diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 14793280fe..d40e861a82 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -1144,6 +1144,64 @@ pytest test_moe_w4a8_perf.py -k "prefetch_sweep" -v python test_moe_w4a8_perf.py --skip-accuracy --prefetch --long-seq --rounds 5 ``` +### 常驻 kernel 何时去领下一个 tile + +Prefill GEMM 是一个**常驻 (persistent)** kernel:grid 是按设备规模来定的 +(`sm_count` × 单个 Xe core 能容纳的 work-group 数),而不是按问题规模,所以一个 +work-group 并不只负责一个 tile——它会循环取活,下一个 tile 的编号来自一次 +device-scope 的 `atomicAdd`,落在同一个 dword 上,所有常驻 work-group 都会撞上它。 + +这次领取过去是发在它所对应的那个 tile **之后**的: + +``` + [ GEMM tile ] -> atomicAdd -> 等待 -> [ GEMM tile ] -> atomicAdd -> 等待 ... + \___________________/ + 这一段没有任何东西可以重叠 +``` + +一次打在竞争 dword 上的 L2 往返,而且恰好落在循环中 work-group 手里没有任何 +在途工作可以掩盖它的位置。现在它发在**之前**: + +``` + atomicAdd -> [ GEMM tile ] -> 使用结果 -> atomicAdd -> [ GEMM tile ] -> ... + \____________/ + 这条消息在整个 mainloop 期间都在途 +``` + +有两个细节决定了这是真的隐藏了延迟,而不只是把停顿挪了个位置: + +* **结果先留在私有寄存器里,而不是直接写进 SLM。** 紧跟 atomic 之后的 SLM store + 会让 lane 0 当场等这个结果,进而让整个 work-group 卡在 mainloop 的第一个 + barrier 上。把 store 放到 GEMM 之后才真正推迟了这次等待;而 atomic 本身不可能 + 被下沉到 mainloop 的 barrier 之后,所以它会留在写下的位置。 +* **SLM 槽位在两个 dword 之间乒乓。** 只用一个 dword 时,lane 0 为 tile `i + 1` + 写入的值可能抢在某个较慢的 sub-group 读 tile `i` 的值之前,那就需要每个 tile + 再加一个 barrier 来防止;用两个槽位则让写和读落在不同地址上。 + +计算哪些 tile 完全没变——每个 tile 一次领取、同样的编号、同样的消费顺序——所以两种 +顺序是**逐位相同**的,`test_prefill_claim_early_matches` 用 ragged batch 上的 +`torch.equal` 来断言这一点 (每专家 300 行,于是每个专家同时有完整 tile 和不完整 +tile,tile 遍历必须遵守的专家边界也就进入了比较范围)。 + +这项改动同时把工作计数器的清零挪到了主机侧。它过去是由 group 0 / lane 0 在 kernel +入口处在设备上清零的,而这与其他所有 work-group 打在同一个 dword 上的 `atomicAdd` +之间没有任何顺序保证——这个竞态之所以一直成立,只是因为一个 work-group 的首次领取 +要等一整个 GEMM tile 之后、也就是几微秒之后才会发生。提前领取把这个窗口压缩到了 +几条指令,所以 `MoEGEMMLauncher_w4a8` 现在用 `queue::memset` 填这个 dword,并让 +kernel `depends_on` 这次填充。一个 dword、一条额外命令,没有任何同步。 + +这项改动值多少,上界取决于一个 tile 有多少工作可以用来掩盖这次停顿,所以它应该在 +**down** 投影上显现、而在 up 投影上几乎看不到:在同样的 tile 形状、同一个 kernel 下, +`K = 768` 的一个 tile 只有 12 个 k-tile,而 `K = 2048` 有 32 个。qwen3 down 同时也是 +距离自身带宽上限最远的那个形状。如果所有形状都打平,那也是一个真实的结论——它会说明 +这次领取从来就不是瓶颈,down 投影的差距在 prologue、epilogue 或者 D 的写上,参见 +[还剩下什么](#还剩下什么)。 + +```bash +pytest test_moe_w4a8_perf.py -k "claim_early" -v +python test_moe_w4a8_perf.py --skip-accuracy --claim-early --long-seq --rounds 5 +``` + ### 还剩下什么 在 prologue 已被实测排除、tile 阶梯也已扫描过之后,剩下的差距在于流量,而且它的 @@ -1277,6 +1335,7 @@ epilogue 做这件事比单独一遍做得更差。 | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的)。 | +| `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | 常驻 prefill kernel 何时从 device-scope 工作计数器领取下一个 tile:**`1` (默认)** 把 `atomicAdd` 发在 GEMM 之前,于是这次 L2 往返在整个 mainloop 期间都在途;`0` 恢复旧顺序,即它完全暴露在两个 tile 之间的空档里。计算哪些 tile 完全没变,因此两者逐位相同 (`test_prefill_claim_early_matches` 断言 `torch.equal`);`test_perf_prefill_claim_early_sweep{,_long_seq}` 会对这一对计时。tile 本身可用于掩盖停顿的工作越少收益越大——参见[常驻 kernel 何时去领下一个 tile](#常驻-kernel-何时去领下一个-tile)。 | ## 形状约束 @@ -1345,6 +1404,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 | 激活量化的批量 load——同时挂起 `UNROLL` 个请求而不是一个 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 在唯一真正走这条路径的形状上,`UNROLL = 2` 或 `4` 快 1.02–1.03×;2 与 4 之间的差异在噪声内 | | 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×;留在寄存器里的行并未溢出 | | D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×,prefill 单项收益最大 | +| tile 的领取改到 GEMM 之前而不是之后,让工作计数器的 device-scope atomic 与 mainloop 重叠 | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | 尚未在 B70 上实测——上界取决于每个 tile 的停顿,因此预期在 12 个 k-tile 的 down 投影上显现、在 up 投影上接近于零 | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** From 93b84d8403194c43e4e35bdf2c13e0ab424d4d21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:57:49 +0000 Subject: [PATCH 103/112] docs: claim_early measured flat on B70, and the per-tile cost model that explains it Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 13 +++- .../ark/test/README_MOE_W4A8.md | 74 ++++++++++++++++--- .../ark/test/README_MOE_W4A8_CN.md | 64 ++++++++++++++-- 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 37185a1186..7f2ef1fa4d 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -992,10 +992,15 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // used to sit on a fully exposed L2 round trip. Issuing the claim ahead of the // tile puts that round trip in flight across the DPAS mainloop instead. Which // tiles get computed does not change (see `MoEGEMM_w4a8`), so the two settings -// are bit-identical and `test_prefill_claim_early_matches` asserts it; the -// difference is timing, and it is largest on the short-K down projections where -// a tile is only 12 k-tiles of work. -// `test_perf_prefill_claim_early_sweep` times the pair. +// are bit-identical and `test_prefill_claim_early_matches` asserts it. +// `test_perf_prefill_claim_early_sweep` times the pair, and on B70 it is a tie +// at both shapes and both batches: the claim was never the stall. Half of the +// short-K down projection's time is per-tile cost that does not scale with `K`, +// and ~78% of *that* is the 64 KB of D each tile writes -- bytes, not latency, +// which is why this and the prefetch-depth sweep both come back flat. The early +// claim stays on because it is free and counter contention grows with the +// number of resident work-groups. The host-side counter reset it forced is a +// correctness fix and applies to both settings. // --------------------------------------------------------------------------- template void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 8540dff1e7..c8200fe12d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1372,14 +1372,68 @@ microseconds later. Claiming early shrinks that window to a few instructions, so `MoEGEMMLauncher_w4a8` now fills the dword with `queue::memset` and makes the kernel `depends_on` that fill. One dword, one extra command, no synchronization. -How much this is worth is bounded by how much work a tile has to hide the stall -behind, so it should show on the **down** projections and barely on the up ones: -at `K = 768` a tile is 12 k-tiles against 32 at `K = 2048`, on the same tile -shape and the same kernel. qwen3 down is also the shape that sits furthest below -its own bandwidth ceiling. A tie on every shape is a real result — it would say -the claim was never the stall, and that the down projection's gap is the -prologue, the epilogue or the D write instead (see -[What is left](#what-is-left)). +**Measured on B70: it is a tie, at every shape and both batches.** + +| shape | tokens | claim after gemm | claim before gemm | drift | +|---|---|---|---|---| +| qwen3 up | 49152 | 2.521 ms | 2.517 ms (1.00×) | 1.2% | +| qwen3 down | 49152 | 1.817 ms | 1.819 ms (1.00×) | 0.4% | +| qwen3 up | 65536 | 2.993 ms | 3.027 ms (0.99×) | 1.4% | +| qwen3 down | 65536 | 2.206 ms | 2.202 ms (1.00×) | 0.8% | + +The prediction above was that it would show on the short-K down projections. It +does not show anywhere: every row is inside its own round-to-round drift, and +the sign flips between batches. The claim was never the stall. + +The default stays on the early claim anyway — the two are bit-identical, the +reorder is free, and contention on the counter is proportional to how many +work-groups are resident, which on B70 is only `sm_count × 2`. A part with +several times the Xe cores contends several times as hard on the same dword. +The host-side counter reset it forced is kept regardless of the setting: that +one is a correctness fix, not a performance change. + +### Why that tie was predictable, and what it closes + +The sweep prints eight numbers — two shapes × two batches — and that is enough +to separate a tile's cost into the part that scales with `K` and the part that +does not. Both shapes run the same `128 × 256 × 64` tile, so a tile is `K / 64` +k-tiles of mainloop plus a fixed prologue, epilogue and `128 × 256` D write: + +``` + T_tile = F + (K / 64) · c up: K = 2048 -> 32 k-tiles + down: K = 768 -> 12 k-tiles +``` + +Two shapes, two unknowns, and the tile counts are known exactly +(`experts × ceil(rows_per_expert / 128) × N / 256`). Solving it at each batch: + +| tokens | `c` (per k-tile) | `F` (per tile) | `F` as % of qwen3 up | `F` as % of qwen3 down | +|---|---|---|---|---| +| 49152 | 25.1 ns | 290 ns | 26.5% | **49.0%** | +| 65536 | 21.8 ns | 276 ns | 28.4% | **51.5%** | + +The two batches are independent fits and they agree to within 5%, so this is a +property of the kernel and not of one run. **Half of qwen3 down's GEMM time is +per-tile cost that no amount of mainloop tuning touches** — and about a quarter +of qwen3 up's. + +And `F` is not mostly latency. Each tile writes `128 × 256` fp16 = 64 KB of D, +so at 4096 tiles that is 268 MB, which at the harness's own 299 GB/s copy probe +is 0.898 ms — **79% of the 1.135 ms that `F` accounts for** (76% at the smaller +batch). The per-tile cost is the D write, plus a fifth or so of prologue, +epilogue and descriptor setup. + +That single number explains all three flat sweeps at once. Prefetch depth, tile +order and now the tile claim are all *latency* knobs, and they were all aimed at +a kernel whose dominant per-tile cost is a stream of bytes to DRAM. The two +things that can still move a shape in this state are fewer bytes — which on the +down projection means the fused reduction, and that +[measured slower](#what-is-left) for reasons of its own — or more requests in +flight, which is occupancy, and occupancy here is pinned at 2 work-groups per Xe +core by the `grf_size<256>` the `128 × 256` tile needs for its 128 accumulator +registers per lane. The smaller tiles in the ladder were swept at that same +large-GRF setting, so they have never been measured with the higher occupancy +that is their entire reason to exist. ```bash pytest test_moe_w4a8_perf.py -k "claim_early" -v @@ -1542,7 +1596,7 @@ analysis above is why. | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no). | -| `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | When the persistent prefill kernel claims its next tile from the device-scope work counter: **`1` (default)** issues the `atomicAdd` before the GEMM, so the L2 round trip is in flight across the mainloop; `0` restores the old order, where it sat fully exposed in the gap between two tiles. Which tiles run does not change, so the two are bit-identical (`test_prefill_claim_early_matches` asserts `torch.equal`); `test_perf_prefill_claim_early_sweep{,_long_seq}` times the pair. Worth most where a tile is short on work to hide the stall behind — see [When the persistent kernel asks for its next tile](#when-the-persistent-kernel-asks-for-its-next-tile). | +| `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | When the persistent prefill kernel claims its next tile from the device-scope work counter: **`1` (default)** issues the `atomicAdd` before the GEMM, so the L2 round trip is in flight across the mainloop; `0` restores the old order, where it sat fully exposed in the gap between two tiles. Which tiles run does not change, so the two are bit-identical (`test_prefill_claim_early_matches` asserts `torch.equal`); `test_perf_prefill_claim_early_sweep{,_long_seq}` times the pair. **Measured a tie on B70** at both shapes and both batches — kept on because it is free and counter contention scales with the number of resident work-groups, which B70 has few of. See [When the persistent kernel asks for its next tile](#when-the-persistent-kernel-asks-for-its-next-tile). | ## Shape constraints @@ -1617,7 +1671,7 @@ have now been timed twice, and all three kept their default: | Activation quantizer's batched loads — `UNROLL` vectors in flight instead of one | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 1.02–1.03× at `UNROLL = 2` or `4` on the only shape that exercises it; 2 vs 4 is inside the noise | | Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×; the register-resident row does not spill | | 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×, the largest single prefill win | -| Tile claim issued before the GEMM instead of after it, so the work counter's device-scope atomic overlaps the mainloop | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | Not yet measured on B70 — bounded by the per-tile stall, so expected on the 12-k-tile down projections and near-nil on the up ones | +| Tile claim issued before the GEMM instead of after it, so the work counter's device-scope atomic overlaps the mainloop | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **A tie** — 1.00× on both shapes at both batches, inside drift, sign flips between runs; kept because it is free and contention scales with Xe-core count | The 2D store was previously listed as needing a device rather than a flag, on the grounds that the sibling MoE kernels reach it through diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index d40e861a82..a53519712d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -1190,12 +1190,60 @@ tile,tile 遍历必须遵守的专家边界也就进入了比较范围)。 几条指令,所以 `MoEGEMMLauncher_w4a8` 现在用 `queue::memset` 填这个 dword,并让 kernel `depends_on` 这次填充。一个 dword、一条额外命令,没有任何同步。 -这项改动值多少,上界取决于一个 tile 有多少工作可以用来掩盖这次停顿,所以它应该在 -**down** 投影上显现、而在 up 投影上几乎看不到:在同样的 tile 形状、同一个 kernel 下, -`K = 768` 的一个 tile 只有 12 个 k-tile,而 `K = 2048` 有 32 个。qwen3 down 同时也是 -距离自身带宽上限最远的那个形状。如果所有形状都打平,那也是一个真实的结论——它会说明 -这次领取从来就不是瓶颈,down 投影的差距在 prologue、epilogue 或者 D 的写上,参见 -[还剩下什么](#还剩下什么)。 +**B70 实测:打平,所有形状、两个 batch 都是。** + +| 形状 | tokens | claim after gemm | claim before gemm | drift | +|---|---|---|---|---| +| qwen3 up | 49152 | 2.521 ms | 2.517 ms (1.00×) | 1.2% | +| qwen3 down | 49152 | 1.817 ms | 1.819 ms (1.00×) | 0.4% | +| qwen3 up | 65536 | 2.993 ms | 3.027 ms (0.99×) | 1.4% | +| qwen3 down | 65536 | 2.206 ms | 2.202 ms (1.00×) | 0.8% | + +上面预测它会在短 K 的 down 投影上显现。结果哪里都没显现:每一行都落在各自的 +round-to-round drift 之内,而且两个 batch 之间符号还翻转了。这次领取从来就不是瓶颈。 + +默认值仍然保持提前领取——两者逐位相同,这个重排是免费的,而计数器上的竞争程度与 +常驻 work-group 数量成正比,B70 上只有 `sm_count × 2`。Xe core 数量高出几倍的部件, +在同一个 dword 上的竞争也会高出几倍。它顺带带来的主机侧计数器清零无论开关如何都会 +保留:那一项是正确性修复,不是性能改动。 + +### 为什么这次打平是可以预判的,以及它关掉了什么 + +这次 sweep 打印了八个数——两个形状 × 两个 batch——这已经足够把一个 tile 的开销拆成 +随 `K` 变化的部分和不随 `K` 变化的部分。两个形状跑的是同一个 `128 × 256 × 64` tile, +所以一个 tile 就是 `K / 64` 个 k-tile 的 mainloop,加上固定的 prologue、epilogue 和 +一次 `128 × 256` 的 D 写: + +``` + T_tile = F + (K / 64) · c up: K = 2048 -> 32 个 k-tile + down: K = 768 -> 12 个 k-tile +``` + +两个形状、两个未知数,而 tile 数量是精确已知的 +(`experts × ceil(rows_per_expert / 128) × N / 256`)。在每个 batch 上求解: + +| tokens | `c` (每个 k-tile) | `F` (每个 tile) | `F` 占 qwen3 up | `F` 占 qwen3 down | +|---|---|---|---|---| +| 49152 | 25.1 ns | 290 ns | 26.5% | **49.0%** | +| 65536 | 21.8 ns | 276 ns | 28.4% | **51.5%** | + +两个 batch 是各自独立拟合的,结果相差不到 5%,所以这是 kernel 的性质而不是某一次运行 +的偶然。**qwen3 down 的 GEMM 时间里有一半是每个 tile 的固定开销,任何 mainloop 调优 +都碰不到它**——qwen3 up 上大约是四分之一。 + +而且 `F` 的主体并不是延迟。每个 tile 要写 `128 × 256` 个 fp16 = 64 KB 的 D,4096 个 +tile 就是 268 MB,按测试脚本自己测出的 299 GB/s 拷贝带宽算是 0.898 ms——**占 `F` 所 +对应的 1.135 ms 的 79%**(较小的 batch 上是 76%)。每个 tile 的固定开销就是这次 D 写, +再加上五分之一左右的 prologue、epilogue 和描述符准备。 + +这一个数字同时解释了三次打平的 sweep。预取深度、tile 顺序,以及现在的 tile 领取,全 +都是**延迟**类的旋钮,而它们针对的 kernel,其每个 tile 的主要开销是往 DRAM 送的一串 +字节。在这种状态下还能推动一个形状的只有两条路:更少的字节——在 down 投影上就是融合 +规约,而那个因为自身的原因[实测更慢](#还剩下什么)——或者更多的在途请求,也就是 +occupancy;而这里的 occupancy 被钉死在每个 Xe core 2 个 work-group,因为 +`128 × 256` 的 tile 每个 lane 需要 128 个累加器寄存器,必须用 `grf_size<256>`。tile +阶梯里较小的那几档也是在同样的大 GRF 设置下扫的,所以它们从来没有在真正属于自己的 +更高 occupancy 下被测量过。 ```bash pytest test_moe_w4a8_perf.py -k "claim_early" -v @@ -1335,7 +1383,7 @@ epilogue 做这件事比单独一遍做得更差。 | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的)。 | -| `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | 常驻 prefill kernel 何时从 device-scope 工作计数器领取下一个 tile:**`1` (默认)** 把 `atomicAdd` 发在 GEMM 之前,于是这次 L2 往返在整个 mainloop 期间都在途;`0` 恢复旧顺序,即它完全暴露在两个 tile 之间的空档里。计算哪些 tile 完全没变,因此两者逐位相同 (`test_prefill_claim_early_matches` 断言 `torch.equal`);`test_perf_prefill_claim_early_sweep{,_long_seq}` 会对这一对计时。tile 本身可用于掩盖停顿的工作越少收益越大——参见[常驻 kernel 何时去领下一个 tile](#常驻-kernel-何时去领下一个-tile)。 | +| `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | 常驻 prefill kernel 何时从 device-scope 工作计数器领取下一个 tile:**`1` (默认)** 把 `atomicAdd` 发在 GEMM 之前,于是这次 L2 往返在整个 mainloop 期间都在途;`0` 恢复旧顺序,即它完全暴露在两个 tile 之间的空档里。计算哪些 tile 完全没变,因此两者逐位相同 (`test_prefill_claim_early_matches` 断言 `torch.equal`);`test_perf_prefill_claim_early_sweep{,_long_seq}` 会对这一对计时。**B70 上实测打平**——两个形状、两个 batch 都是;保持开启是因为它免费,而计数器竞争随常驻 work-group 数量增长,而 B70 的常驻数量很少。参见[常驻 kernel 何时去领下一个 tile](#常驻-kernel-何时去领下一个-tile)。 | ## 形状约束 @@ -1404,7 +1452,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 | 激活量化的批量 load——同时挂起 `UNROLL` 个请求而不是一个 | `ARK_MOE_W4A8_ACT_QUANT_UNROLL=1` | 在唯一真正走这条路径的形状上,`UNROLL = 2` 或 `4` 快 1.02–1.03×;2 与 4 之间的差异在噪声内 | | 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×;留在寄存器里的行并未溢出 | | D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×,prefill 单项收益最大 | -| tile 的领取改到 GEMM 之前而不是之后,让工作计数器的 device-scope atomic 与 mainloop 重叠 | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | 尚未在 B70 上实测——上界取决于每个 tile 的停顿,因此预期在 12 个 k-tile 的 down 投影上显现、在 up 投影上接近于零 | +| tile 的领取改到 GEMM 之前而不是之后,让工作计数器的 device-scope atomic 与 mainloop 重叠 | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **打平**——两个形状、两个 batch 全是 1.00×,落在 drift 之内,两次运行之间符号还翻转;保留是因为它免费,且竞争程度随 Xe core 数量增长 | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** From b70ac5ebccf4054cbd0deaca377d1772d3453b0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:45:27 +0000 Subject: [PATCH 104/112] perf: make the w4a8 prefill GRF budget a property of the tile policy Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 187 +++++++++++++++--- .../include/sycl_tla_moe_w4a8_helpers.hpp | 8 +- 2 files changed, 164 insertions(+), 31 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index 7f2ef1fa4d..ee55db608f 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -152,8 +152,10 @@ namespace moe_w4a8 { using namespace cute; // Kernel name tag for the grouped prefill GEMM (the other tags live in -// `sycl_tla_moe_w4a8_kernels.hpp`). -template +// `sycl_tla_moe_w4a8_kernels.hpp`). The GRF budget is part of the name: the two +// budgets are two distinct kernels built from the same source, and a SYCL +// kernel name may only name one of them. +template class MoEW4A8GemmName; // --------------------------------------------------------------------------- @@ -236,41 +238,80 @@ class MoEW4A8GemmName; // // Every policy stays reachable through `ARK_MOE_W4A8_PREFILL_TILE` for a // re-sweep on a device with a different register budget. +// +// `kSmallGrfOk`: the register budget is a property of the tile +// ------------------------------------------------------------ +// The ladder above was swept with `grf_size<256>` hardwired into the launcher, +// which pins *every* rung at 4 hardware threads per vector engine -- 512 +// work-items per Xe-core, which is exactly the `MaxThreadsPerSM` the persistent +// grid is sized from. The small tiles are in the ladder because they ask for +// fewer registers, and the payoff for asking for fewer registers is that more +// work-groups fit on an Xe-core; at a fixed `grf_size<256>` they paid the cost +// and never collected. So none of them has ever been measured at the occupancy +// that is their reason to exist. +// +// What a lane actually holds for the whole mainloop is the C fragment, which is +// the WG tile divided by the sub-group count (`blks == 1` keeps only the int32 +// accumulator -- see the register-file argument on `xe_gemm_w4a8` below): +// +// policy WG tile SGs SG C fragment int32 regs/lane +// m_8 8x128 4 8 x 32 16 +// m_64 64x128 8 32 x 32 64 +// m_128 128x128 16 32 x 32 64 +// m_128_n256 128x256 16 32 x 64 128 +// m_256_n128 256x128 32 32 x 32 64 +// large 256x256 32 32 x 64 128 +// +// At `grf_size<128>` a lane has 128 registers, so the 64-register tiles keep +// half the file for the staged A/B tiles and their addresses, and the 128- +// register ones would have *nothing* left -- their accumulator alone is the +// whole file. Hence the flag splits the ladder exactly along the N dimension: +// 128-wide N can double its occupancy, 256-wide N cannot be asked to. +// +// This is a request, not a guarantee: if the halved budget does not hold the +// staged tiles the compiler spills, which costs time but not correctness. The +// flag says "this tile may be asked", the sweep says whether it should be. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: using WGTile = Shape<_8, _128, _64>; using SGLayout = Layout, Stride<_0, _1, _0>>; + static constexpr bool kSmallGrfOk = true; }; class w4a8_policy_m_64 : public moe_dpas_fp8::dpas_policy_base { public: using WGTile = Shape<_64, _128, _64>; using SGLayout = Layout, Stride<_4, _1, _0>>; + static constexpr bool kSmallGrfOk = true; }; class w4a8_policy_m_128 : public moe_dpas_fp8::dpas_policy_base { public: using WGTile = Shape<_128, _128, _64>; using SGLayout = Layout, Stride<_4, _1, _0>>; + static constexpr bool kSmallGrfOk = true; }; class w4a8_policy_m_128_n256 : public moe_dpas_fp8::dpas_policy_base { public: using WGTile = Shape<_128, _256, _64>; using SGLayout = Layout, Stride<_4, _1, _0>>; + static constexpr bool kSmallGrfOk = false; }; class w4a8_policy_m_256_n128 : public moe_dpas_fp8::dpas_policy_base { public: using WGTile = Shape<_256, _128, _64>; using SGLayout = Layout, Stride<_4, _1, _0>>; + static constexpr bool kSmallGrfOk = true; }; class w4a8_policy_large : public moe_dpas_fp8::dpas_policy_base { public: using WGTile = Shape<_256, _256, _64>; using SGLayout = Layout, Stride<_4, _1, _0>>; + static constexpr bool kSmallGrfOk = false; }; // --------------------------------------------------------------------------- @@ -867,6 +908,68 @@ CUTE_DEVICE void MoEGEMM_w4a8(const int8_t* Activations, const int8_t* Weights, } } +// --------------------------------------------------------------------------- +// GRF budget -> occupancy. +// +// `grf_size` is a request for how many registers each lane gets, and on this +// architecture a vector engine's register file is fixed: it holds either 4 +// threads of 256 registers or 8 threads of 128. With SIMD16 sub-groups and 8 +// vector engines per Xe-core that is 8*4*16 = 512 work-items resident at the +// large budget and 8*8*16 = 1024 at the small one. +// +// The kernel is persistent -- `MoEGEMM_w4a8` walks a device-scope counter until +// the tiles run out, and the grid is sized to fill the device exactly once -- +// so this number is not a hint, it *is* the grid. Halving the register request +// without doubling `kMaxThreadsPerSM` would launch the same work-groups as +// before and change nothing at all; the two constants have to move together, +// which is why they live in one place. +// --------------------------------------------------------------------------- +template +struct W4A8GrfBudget; + +template <> +struct W4A8GrfBudget<256> { + static constexpr int kMaxThreadsPerSM = 512; + static auto props() { + return sycl::ext::oneapi::experimental::properties{sycl::ext::oneapi::experimental::sub_group_size<16>, + sycl::ext::intel::experimental::grf_size<256>}; + } +}; + +template <> +struct W4A8GrfBudget<128> { + static constexpr int kMaxThreadsPerSM = 1024; + static auto props() { + return sycl::ext::oneapi::experimental::properties{sycl::ext::oneapi::experimental::sub_group_size<16>, + sycl::ext::intel::experimental::grf_size<128>}; + } +}; + +// Submit the persistent grid at one GRF budget. `GrfSize` fixes the kernel +// properties, the grid, and (through `KernelName`) the kernel identity; `body` +// is the same device code either way and receives the SLM slot pair. +template +sycl::event moe_w4a8_submit_grf(sycl::queue& stream, sycl::event fill, int sm_count, int threads_per_wg, Body body) { + using Budget = W4A8GrfBudget; + + static constexpr int MaxThreadsPerSM = Budget::kMaxThreadsPerSM; + if (threads_per_wg <= 0 || MaxThreadsPerSM % threads_per_wg != 0) { + throw std::runtime_error("moe_gemm_w4a8: MaxThreadsPerSM must be divisible by MaxThreadsPerWorkgroup"); + } + + sycl::range<3> local(1, 1, threads_per_wg); + sycl::range<3> global(1, sm_count * MaxThreadsPerSM / threads_per_wg, 1); + + auto kernel_props = Budget::props(); + + return stream.submit([&](sycl::handler& cgh) { + cgh.depends_on(fill); + sycl::local_accessor local_mem(sycl::range<1>(2), cgh); + cgh.parallel_for(sycl::nd_range<3>{global * local, local}, kernel_props, + [=](auto) { body(local_mem); }); + }); +} + // --------------------------------------------------------------------------- // Grouped-GEMM launcher (fork of `moe_dpas_int::MoEGEMMLauncher_int`, with the // int8 DPAS atom of `sycl_tla_s8_gemm.hpp`). @@ -876,8 +979,8 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const const float* scale_a, const float* scale_b, ElementD* outputs, const int gemm_n, const int gemm_k, const int* rows_per_expert, const int num_experts, const int blocksize, const int blks, const bool allow_full_tile, const bool allow_block_2d_store, - const int prefetch_dist, const bool claim_early, MoEFusedReduce reduce, - int32_t* atomic_buffer) { + const int prefetch_dist, const bool claim_early, const bool small_grf, + MoEFusedReduce reduce, int32_t* atomic_buffer) { using Op = XE_DPAS_TT<8, int32_t, int8_t, int8_t>; using WGTile = typename Policy::WGTile; using SGLayout = typename Policy::SGLayout; @@ -885,20 +988,7 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const auto mma = MMA{}; int sm_count = cutlass::KernelHardwareInfo::query_device_multiprocessor_count(0); - auto MaxThreadsPerWorkgroup = size(mma); - - static constexpr int MaxThreadsPerSM = 512; - if (MaxThreadsPerSM % MaxThreadsPerWorkgroup != 0) { - throw std::runtime_error("moe_gemm_w4a8: MaxThreadsPerSM must be divisible by MaxThreadsPerWorkgroup"); - } - - sycl::range<3> local(1, 1, MaxThreadsPerWorkgroup); - sycl::range<3> global(1, sm_count * MaxThreadsPerSM / MaxThreadsPerWorkgroup, 1); - - namespace syclex = sycl::ext::oneapi::experimental; - namespace intelex = sycl::ext::intel::experimental; - - syclex::properties kernel_props{syclex::sub_group_size<16>, intelex::grf_size<256>}; + const int threads_per_wg = static_cast(size(mma)); using GmemTiledCopyA = typename Policy::GmemTiledCopyA; using GmemTiledCopyB = typename Policy::GmemTiledCopyB; @@ -913,17 +1003,33 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // rather than a synchronization. auto fill = stream.memset(atomic_buffer, 0, sizeof(int32_t)); - auto event = stream.submit([&](sycl::handler& cgh) { - cgh.depends_on(fill); - sycl::local_accessor local_mem(sycl::range<1>(2), cgh); - cgh.parallel_for>( - sycl::nd_range<3>{global * local, local}, kernel_props, [=](auto) { - MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, - rows_per_expert, num_experts, gemm_n, gemm_k, blocksize, - blks, allow_full_tile, allow_block_2d_store, prefetch_dist, - claim_early, reduce, atomic_buffer, local_mem); - }); - }); + auto body = [=](sycl::local_accessor local_mem) { + MoEGEMM_w4a8(activations, weights, scale_a, scale_b, outputs, mma, rows_per_expert, + num_experts, gemm_n, gemm_k, blocksize, blks, allow_full_tile, + allow_block_2d_store, prefetch_dist, claim_early, reduce, + atomic_buffer, local_mem); + }; + + // The halved register budget is only offered to tiles whose accumulator + // leaves room for the staged A/B fragments, and only for `blks == 1`: a + // blocked re-scale carries the float shadow `tFrC` alongside the int32 + // accumulator for the whole mainloop (see `xe_gemm_w4a8`), which doubles the + // live fragment and takes even a 64-register tile to the full 128. The + // `if constexpr` keeps the second kernel out of the 256-wide-N translation + // units entirely rather than leaving it dead. + sycl::event event; + bool launched = false; + if constexpr (Policy::kSmallGrfOk) { + if (small_grf && blks == 1) { + event = moe_w4a8_submit_grf<128, MoEW4A8GemmName>(stream, fill, sm_count, threads_per_wg, + body); + launched = true; + } + } + if (!launched) { + event = moe_w4a8_submit_grf<256, MoEW4A8GemmName>(stream, fill, sm_count, threads_per_wg, + body); + } EventManager::getInstance().addEvent(event); event.wait(); @@ -1001,6 +1107,26 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // claim stays on because it is free and counter contention grows with the // number of resident work-groups. The host-side counter reset it forced is a // correctness fix and applies to both settings. +// `ARK_MOE_W4A8_PREFILL_SMALL_GRF=0` keeps every tile at `grf_size<256>` and the +// 512-work-item grid, the A/B baseline for the per-policy register budget. The +// GRF request used to be hardwired here, which pinned all six tiles at 2 +// work-groups per Xe-core -- so the small tiles in the ladder paid for their +// smaller accumulator and never collected the occupancy that is the reason to +// have them. It is now a property of the policy (`kSmallGrfOk`, see the tile +// table above): 128-wide-N tiles ask for half the registers and get twice the +// resident work-groups, 256-wide-N tiles cannot because their accumulator alone +// is a whole 128-register file. `blks > 1` opts out at runtime -- the blocked +// re-scale carries a second, float fragment for the whole mainloop. +// +// The default ladder sends every shipped qwen3 shape to `128x256`, which is not +// eligible, so this changes nothing until a tile is forced: +// `ARK_MOE_W4A8_PREFILL_TILE=128x128` with and without this flag separates the +// tile from the register budget, which a policy-only knob could not do. Both +// settings compute the same tiles in the same order and are bit-identical; +// `test_prefill_small_grf_matches` asserts it. Occupancy is a latency-hiding +// lever, so the shape to watch is the down projection: half its time is +// per-tile cost, ~78% of that the 64 KB of D each tile writes, and covering a +// store stream is what more resident work-groups are for. // --------------------------------------------------------------------------- template void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { @@ -1025,12 +1151,13 @@ void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); const int prefetch_dist = moe_w4a8_prefill_prefetch_dist(); const bool claim_early = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_CLAIM_EARLY", true); + const bool small_grf = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_SMALL_GRF", true); int32_t* atomic_buffer = moe_dpas_fp8::get_atomic_scratch_buffer(p.q); MoEGEMMLauncher_w4a8(*p.q, p.qact, p.weights, p.ascale, p.wscale, outputs, p.N, p.K, p.num_tokens_per_expert, p.num_experts, p.blocksize, p.blks, allow_full_tile, allow_block_2d_store, prefetch_dist, claim_early, - reduce, atomic_buffer); + small_grf, reduce, atomic_buffer); } } // namespace moe_w4a8 diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp index 8047d2c5e8..d25f8afeef 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp @@ -158,12 +158,18 @@ inline W4A8PrefillTile moe_w4a8_prefill_select_tile(int A_avg_M, int N) { // (dtype x tile) for the DPAS prefill, which is where the compile cost is, and // coarser grouping for the plain-SYCL kernels. Kernel counts per TU: // -// prefill_{f16,bf16}_* : 1 DPAS kernel (12 TUs) +// prefill_{f16,bf16}_* : 1-2 DPAS kernels (12 TUs) // decode_{f16,bf16} : 7 GEMV kernels (2 TUs) // quant_{f16,bf16} : 11 quant kernels (2 TUs) // prepack_{f16,bf16} : 2 rescale kernels (2 TUs) // // versus 52 kernels -- 12 of them DPAS -- in the single TU this replaced. +// +// The prefill TUs whose policy sets `kSmallGrfOk` build their DPAS kernel twice, +// once per GRF budget (see `W4A8GrfBudget` in `sycl_tla_moe_w4a8.hpp`) -- the +// register request is a compile-time kernel property, so the runtime choice +// needs both to exist. That is the 128-wide-N tiles only; the 256-wide-N ones, +// which is where the shipped shapes land, still build exactly one. // --------------------------------------------------------------------------- namespace moe_w4a8_detail { From c289f7bbdf3d176729d7718c077f111b28b88e7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:48:41 +0000 Subject: [PATCH 105/112] test: sweep the prefill register budget and assert it is bit-identical Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/test_moe_w4a8_perf.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 998ba01c05..7d034f8f8f 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1837,6 +1837,42 @@ def _call(act, ascale=None, ntpe=None): ("claim before gemm", {"ARK_MOE_W4A8_PREFILL_CLAIM_EARLY": "1"}), ] +# Prefill: the register budget, and therefore how many work-groups are resident. +# +# `grf_size` used to be hardwired at 256 in the launcher, which pinned every +# tile in the ladder at 4 hardware threads per vector engine -- 512 work-items +# per Xe-core, which is exactly the `MaxThreadsPerSM` the persistent grid is +# sized from. Asking for 128 registers instead doubles both. The small tiles are +# in the ladder *because* they ask for fewer registers, so every one of them has +# been swept so far paying that cost without ever collecting the occupancy that +# is their reason to exist. +# +# The flag alone would sweep nothing: the ladder sends every shipped qwen3 shape +# to `128x256`, whose 32x64 sub-group accumulator is 128 int32 registers -- the +# entire small-GRF file, nothing left for the staged A/B tiles -- so that policy +# declines the halved budget and both settings launch the identical kernel. The +# tile therefore has to be forced for the knob to be observable at all, and the +# first two rows do exactly that: same 128x128 tile, same everything else, one +# register budget each. That pair is the measurement. +# +# The third row is the shipped default, and it is what the pair is *for*. The +# question is not whether 128 registers beat 256 on the same tile -- it is +# whether the smaller tile at double occupancy finally catches the bigger tile +# that has beaten it in every sweep so far. If row 2 beats row 1 but neither +# reaches row 3, the ladder stays as it is and the answer is that the 256-wide N +# tile wins on A re-reads, not on occupancy. +# +# Expect the effect, if any, on the down projection: occupancy hides latency, +# half of qwen3 down's time is per-tile cost that does not scale with K, and +# ~78% of that is the 64 KB of D each tile writes. Covering a store stream with +# other work-groups is what more residency buys. The up projection is at 86% of +# the DPAS peak with the quantizer subtracted and has nothing to hide. +_PREFILL_GRF_CONFIGS = [ + ("128x128, grf 256", {"ARK_MOE_W4A8_PREFILL_TILE": "128x128", "ARK_MOE_W4A8_PREFILL_SMALL_GRF": "0"}), + ("128x128, grf 128", {"ARK_MOE_W4A8_PREFILL_TILE": "128x128", "ARK_MOE_W4A8_PREFILL_SMALL_GRF": "1"}), + ("128x256 (default)", {"ARK_MOE_W4A8_PREFILL_TILE": "128x256"}), +] + # Prefill: the two call contracts that cut traffic instead of cycles. # # Neither changes the GEMM. They change what crosses the call boundary, which @@ -2556,6 +2592,55 @@ def test_perf_prefill_claim_early_sweep_long_seq(self, request): row["snr_db"] >= _SWEEP_MIN_SNR_DB ), f"claim order {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_grf_sweep(self, request): + """Time the register budget, which is really a test of occupancy. + + The kernel is persistent and its grid is sized to fill the device + exactly once, so the number of work-groups launched *is* the + residency. That number comes from a threads-per-Xe-core constant + that only holds at ``grf_size<256>``; halving the register request + doubles it, and the two have to move together or nothing changes. + + The first two rows are the measurement -- one tile, two budgets -- + and the third is the question they answer: whether 128x128 at + double occupancy catches the 128x256 tile that has beaten it in + every sweep so far. Every row computes the same tiles from the same + inputs, so all three must be bit-identical and only the timing is a + measurement. + """ + rows = run_config_sweep("prefill", _PREFILL_GRF_CONFIGS, models=_models_option(request)) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"GRF budget {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + + def test_perf_prefill_grf_sweep_long_seq(self, request): + """Time the register budget at the 8K-prompt routing. + + The other prefill point, and the one where the occupancy argument + is weakest for the small tile: 512 rows/expert divides evenly by + both 128 and 256, so the padding penalty that decides the ladder + elsewhere is absent here and the tiles compete on their merits. + + A tie across all three rows would say the down projection's gap is + not a latency that more resident work-groups can cover, which -- + after prefetch depth, tile order and claim order all came back + flat -- would close the last of the latency knobs and leave the D + write as the only remaining prefill lever. + """ + rows = run_config_sweep( + "prefill", + _PREFILL_GRF_CONFIGS, + models=_models_option(request), + batches=_long_seq_batches(), + ) + assert rows and all(r["w4a8_ms"] > 0 for r in rows) + for row in rows: + assert ( + row["snr_db"] >= _SWEEP_MIN_SNR_DB + ), f"GRF budget {row['config']} disagrees with {rows[0]['config']}: SNR {row['snr_db']:.2f} dB" + def test_perf_prefill_contract_sweep(self, request): """Time the two traffic-cutting call contracts at the compute-bound batch. @@ -3092,6 +3177,58 @@ def test_prefill_claim_early_matches(self): f"{(outs['0'] != outs['1']).sum().item()} of {outs['0'].numel()} elements differ" ) + def test_prefill_small_grf_matches(self): + """The halved register budget must not change the result. + + ``grf_size`` asks the compiler for a register allocation; it does + not change what the kernel computes. But it is not only a compiler + hint here: the request also sets how many work-groups are resident, + and the persistent grid is sized from that number, so the two + budgets launch *different grids* over the same tiles. That is the + part worth testing. Tiles are handed out by a device-scope counter + rather than owned by a work-group, so a grid of a different size + must still consume every index exactly once. + + The failure mode is silent in the same way the claim-order one is: + too few work-groups leave the last tiles uncomputed, too many leave + a band recomputed, and neither raises. Both show up as a mismatch + against the large-GRF baseline. + + The tile is forced to 128x128 because that is the only rung the + halved budget applies to -- the ladder's own choice for these + shapes is 128x256, which declines it and would make this test + compare a kernel against itself. The batch is the ragged one, so + every expert contributes two interior tiles and one partial and the + expert boundaries the tile walk has to respect are in the + comparison. + """ + rows_per_expert = _RAGGED_TILE_ROWS_PER_EXPERT + case = _build_case( + _QWEN3_NK[1][1], + _QWEN3_NK[1][2], + _QWEN3_E, + rows_per_expert * _QWEN3_E, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + outs = {} + for flag in ("0", "1"): + with _env_override( + ARK_MOE_W4A8_PREFILL_TILE="128x128", + ARK_MOE_W4A8_PREFILL_SMALL_GRF=flag, + ): + outs[flag] = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + assert torch.equal(outs["0"], outs["1"]), ( + "halving the register budget changed the result: " + f"max |diff| {(outs['0'].float() - outs['1'].float()).abs().max().item():.6g}, " + f"{(outs['0'] != outs['1']).sum().item()} of {outs['0'].numel()} elements differ" + ) + def test_decode_ksplit_matches_legacy(self): """The K-split decode mapping must agree with the legacy one. @@ -3272,6 +3409,17 @@ def _parse_args(argv): "pair with --long-seq for the 12-k-tile down projections where it should show first." ), ) + parser.add_argument( + "--grf", + action="store_true", + help=( + "Also sweep the prefill register budget: the same 128x128 tile at grf_size 256 and 128, " + "plus the shipped 128x256 tile for reference. Halving the request doubles how many " + "work-groups are resident, which the persistent grid is sized from, so this is the " + "occupancy sweep the small tiles in the ladder have never had -- they were all measured " + "at the large budget their smaller accumulator was supposed to avoid." + ), + ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") parser.add_argument( @@ -3381,6 +3529,17 @@ def main(argv=None) -> int: models=models, batches=_long_seq_batches(), ) + if phase == "prefill" and args.grf: + if not args.long_seq or args.compute_bound: + run_config_sweep(phase, _PREFILL_GRF_CONFIGS, dtype=dtype, models=models) + if args.long_seq: + run_config_sweep( + phase, + _PREFILL_GRF_CONFIGS, + dtype=dtype, + models=models, + batches=_long_seq_batches(), + ) if failures: print() From b239108dc6a5d58ec9f7205462f9eeba5be4cd55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:52:22 +0000 Subject: [PATCH 106/112] docs: record the per-policy GRF budget and the occupancy sweep in both READMEs Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 80 +++++++++++++++++++ .../ark/test/README_MOE_W4A8_CN.md | 70 ++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index c8200fe12d..2612ef169a 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1440,6 +1440,84 @@ pytest test_moe_w4a8_perf.py -k "claim_early" -v python test_moe_w4a8_perf.py --skip-accuracy --claim-early --long-seq --rounds 5 ``` +### The register budget, which is really the occupancy knob + +That last paragraph names the one lever the sweeps had not touched, so it is now +wired up. `grf_size` used to be a constant in the launcher, and it was not +alone: the grid is sized from a `MaxThreadsPerSM = 512` right above it, and 512 +is not a generic number — it is `8 vector engines × 4 threads × SIMD16`, which +is exactly what an Xe core holds *when each lane asks for 256 registers*. Ask +for 128 instead and the same register file holds 8 threads per engine, i.e. +1024 work-items. The two constants are one fact written twice, so they now live +in one place: + +| `grf_size` | threads/Xe core | work-groups resident (at 256 threads/WG) | +|---|---|---| +| 256 | 512 | 2 | +| 128 | 1024 | **4** | + +This matters because the kernel is persistent: the grid is sized to fill the +device exactly once and then work-groups take tiles from a device-scope counter +until the tiles run out. **The launched grid *is* the residency**, so halving the +register request without doubling `MaxThreadsPerSM` would have launched the same +work-groups as before and measured nothing at all. + +Which tiles can ask is decided by what a lane holds for the whole mainloop — the +C fragment, which is the WG tile divided by the sub-group count: + +| policy | WG tile | SGs | SG C fragment | int32 regs/lane | may ask for 128 | +|---|---|---|---|---|---| +| `m_8` | 8 × 128 | 4 | 8 × 32 | 16 | yes | +| `m_64` | 64 × 128 | 8 | 32 × 32 | 64 | yes | +| `m_128` | 128 × 128 | 16 | 32 × 32 | 64 | yes | +| `m_128_n256` | 128 × 256 | 16 | 32 × 64 | **128** | no | +| `m_256_n128` | 256 × 128 | 32 | 32 × 32 | 64 | yes | +| `large` | 256 × 256 | 32 | 32 × 64 | **128** | no | + +The split falls exactly along N. A 256-wide N tile's accumulator *is* a whole +128-register file, leaving nothing for the staged A/B tiles; a 128-wide one +keeps half the file. `blks > 1` opts out at runtime as well, because a blocked +re-scale carries the float shadow `tFrC` alongside the int32 accumulator for the +entire mainloop and doubles the live fragment — which is the same register-file +argument that made `blks == 1` a specialized path in the first place. + +**The measured configurations are unchanged.** The ladder sends every shipped +qwen3 shape to `128 × 256`, which declines the halved budget, so both settings +launch the identical kernel there. The rungs that do pick a 128-wide tile — +under 128 rows/expert, or `N % 256 != 0` — take the halved budget by default, +and those have not been measured. That is also why the sweep has to force a +tile: + +| row | tile | `grf_size` | what it is for | +|---|---|---|---| +| 1 | 128 × 128 | 256 | the tile as previously swept — isolates the budget | +| 2 | 128 × 128 | 128 | the same tile at double occupancy | +| 3 | 128 × 256 | 256 | the shipped default, for reference | + +Rows 1 and 2 are the measurement; row 3 is the question they answer. It is not +"do 128 registers beat 256 on the same tile" but "**does the small tile at +double occupancy finally catch the big tile that has beaten it in every sweep so +far**". If row 2 beats row 1 and still loses to row 3, the ladder stays as it is +and the answer is that the 256-wide N tile wins on A re-reads, not on occupancy. + +The shape to watch is the down projection, for the reason the cost model gives: +half its time is per-tile cost, ~78% of that is the D write, and covering a +store stream with other work-groups is precisely what residency buys. qwen3 up +has nothing to hide — with the quantizer subtracted its GEMM is already at 86% +of the device's int8 peak. A tie on all three rows is a real result too: it +would close the last latency knob and leave the D write as the only prefill +lever standing. + +Every row computes the same tiles from the same inputs, so all three must be +bit-identical; `test_prefill_small_grf_matches` asserts it against a ragged +batch, where a differently-sized grid walking the same work-stealing counter is +the thing most likely to skip or repeat a tile. + +```bash +pytest test_moe_w4a8_perf.py -k "grf" -v +python test_moe_w4a8_perf.py --skip-accuracy --grf --long-seq --rounds 5 +``` + ### What is left With the prologue ruled out by measurement and the tile ladder already swept, @@ -1597,6 +1675,7 @@ analysis above is why. | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no). | | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | When the persistent prefill kernel claims its next tile from the device-scope work counter: **`1` (default)** issues the `atomicAdd` before the GEMM, so the L2 round trip is in flight across the mainloop; `0` restores the old order, where it sat fully exposed in the gap between two tiles. Which tiles run does not change, so the two are bit-identical (`test_prefill_claim_early_matches` asserts `torch.equal`); `test_perf_prefill_claim_early_sweep{,_long_seq}` times the pair. **Measured a tie on B70** at both shapes and both batches — kept on because it is free and counter contention scales with the number of resident work-groups, which B70 has few of. See [When the persistent kernel asks for its next tile](#when-the-persistent-kernel-asks-for-its-next-tile). | +| `ARK_MOE_W4A8_PREFILL_SMALL_GRF` | Whether a tile policy that can afford it asks for `grf_size<128>` instead of `<256>`, which doubles the resident work-groups per Xe core (512 -> 1024 threads) and, because the prefill kernel is persistent, doubles the launched grid with it. **`1` (default)**; `0` keeps every tile at the large budget. Only the 128-wide-N policies are eligible -- a 256-wide N tile's 32x64 accumulator is 128 registers, the whole small file -- and `blks > 1` opts out at runtime because the blocked re-scale carries a second, float fragment. **Every shipped qwen3 shape lands on `128x256`, which declines it, so the measured configurations are unchanged; the ladder rungs that do pick a 128-wide tile -- under 128 rows/expert, or `N % 256 != 0` -- take the halved budget by default and have not been measured there.** Bit-identical either way (`test_prefill_small_grf_matches`); `test_perf_prefill_grf_sweep{,_long_seq}` times it. See [The register budget, which is really the occupancy knob](#the-register-budget-which-is-really-the-occupancy-knob). | ## Shape constraints @@ -1672,6 +1751,7 @@ have now been timed twice, and all three kept their default: | Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×; the register-resident row does not spill | | 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×, the largest single prefill win | | Tile claim issued before the GEMM instead of after it, so the work counter's device-scope atomic overlaps the mainloop | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **A tie** — 1.00× on both shapes at both batches, inside drift, sign flips between runs; kept because it is free and contention scales with Xe-core count | +| Per-policy register budget: 128-wide-N tiles ask for `grf_size<128>` and get 4 resident work-groups per Xe core instead of 2 | `ARK_MOE_W4A8_PREFILL_SMALL_GRF=0` | **Not yet measured** — no-op on the shipped `128x256` tile; sweep it with `ARK_MOE_W4A8_PREFILL_TILE=128x128` | The 2D store was previously listed as needing a device rather than a flag, on the grounds that the sibling MoE kernels reach it through diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index a53519712d..c80d4a194d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -1250,6 +1250,74 @@ pytest test_moe_w4a8_perf.py -k "claim_early" -v python test_moe_w4a8_perf.py --skip-accuracy --claim-early --long-seq --rounds 5 ``` +### 寄存器预算,其实就是 occupancy 这个旋钮 + +上一段点名的正是那些 sweep 唯一没碰过的杠杆,现在把它接上了。`grf_size` 过去是 +launcher 里的一个常量,而且它不是一个人:网格规模由紧挨着它上面的 +`MaxThreadsPerSM = 512` 决定,而 512 并不是一个泛泛的数字——它就是 +`8 个 vector engine × 4 个线程 × SIMD16`,也正是**每个 lane 要 256 个寄存器时**一个 +Xe core 能容纳的量。改成要 128 个,同一个寄存器文件每个 engine 就能放 8 个线程, +即 1024 个 work-item。这两个常量是同一个事实写了两遍,所以现在它们合并到了一处: + +| `grf_size` | 每 Xe core 线程数 | 常驻 work-group 数 (每 WG 256 线程时) | +|---|---|---| +| 256 | 512 | 2 | +| 128 | 1024 | **4** | + +这件事之所以重要,是因为这个 kernel 是常驻式的:网格按"恰好铺满设备一次"来调整, +之后 work-group 就从一个 device-scope 计数器不断领取 tile,直到 tile 用完为止。 +**启动的网格规模本身就是常驻度**,所以只把寄存器请求减半、却不同时把 +`MaxThreadsPerSM` 翻倍,启动的 work-group 数会和以前完全一样,什么也测不出来。 + +哪些 tile 有资格提出这个请求,取决于一个 lane 在整个 mainloop 期间要持有什么——即 C +fragment,也就是 WG tile 除以 sub-group 数: + +| policy | WG tile | SG 数 | SG C fragment | int32 寄存器/lane | 可否申请 128 | +|---|---|---|---|---|---| +| `m_8` | 8 × 128 | 4 | 8 × 32 | 16 | 可以 | +| `m_64` | 64 × 128 | 8 | 32 × 32 | 64 | 可以 | +| `m_128` | 128 × 128 | 16 | 32 × 32 | 64 | 可以 | +| `m_128_n256` | 128 × 256 | 16 | 32 × 64 | **128** | 不可以 | +| `m_256_n128` | 256 × 128 | 32 | 32 × 32 | 64 | 可以 | +| `large` | 256 × 256 | 32 | 32 × 64 | **128** | 不可以 | + +这个分界线恰好沿着 N 维落下。N 宽 256 的 tile,它的累加器**本身就是**一整个 128 +寄存器文件,给暂存的 A/B tile 一点都不剩;而 N 宽 128 的还能留下一半。`blks > 1` +在运行期同样会退出,因为分块重标定要在整个 mainloop 期间把浮点影子 `tFrC` 和 int32 +累加器一起带着,活跃 fragment 直接翻倍——这也正是当初让 `blks == 1` 成为一条特化路径 +的那个寄存器文件论证。 + +**被实测过的那些配置没有变化。** 阶梯会把所有已发布的 qwen3 形状都送到 +`128 × 256`,而它拒绝这份减半的预算,因此在那里两种设置启动的是同一个 kernel。至于 +确实会选到 N 宽 128 的那几档——每专家行数低于 128,或者 `N % 256 != 0`——则按默认接受 +这份减半预算,而那里尚未被实测过。这也正是 sweep 必须强制指定一个 tile 的原因: + +| 行 | tile | `grf_size` | 用途 | +|---|---|---|---| +| 1 | 128 × 128 | 256 | 此前扫过的那个 tile —— 用来隔离出预算这一个变量 | +| 2 | 128 × 128 | 128 | 同一个 tile,occupancy 翻倍 | +| 3 | 128 × 256 | 256 | 已发布的默认值,作为参照 | + +第 1、2 行是测量,第 3 行才是它们要回答的问题。问题不是"同一个 tile 上 128 个寄存器 +是否胜过 256 个",而是"**occupancy 翻倍之后,小 tile 是否终于追上了那个在此前每一轮 +sweep 里都赢过它的大 tile**"。如果第 2 行赢了第 1 行却仍然输给第 3 行,那阶梯就维持 +原样,结论是 N 宽 256 的 tile 赢在 A 的重复读取上,而不是赢在 occupancy 上。 + +要盯的形状是 down projection,理由由成本模型给出:它一半的时间是 per-tile 成本,其中 +约 78% 是 D 的写出,而用别的 work-group 去掩盖一条写出流,正是常驻度买来的东西。 +qwen3 up 没什么可掩盖的——把量化那一遍减掉之后,它的 GEMM 已经在设备 int8 峰值的 86% +上了。三行全部打平同样是一个实实在在的结果:那将关掉最后一个延迟类旋钮,让 D 的写出 +成为 prefill 唯一还站着的杠杆。 + +每一行都用相同的输入计算相同的 tile,因此三者必须逐位相同; +`test_prefill_small_grf_matches` 会在一个不整齐 (ragged) 的 batch 上断言这一点——规模 +不同的网格走同一个工作窃取计数器,正是最容易漏掉或重复某个 tile 的地方。 + +```bash +pytest test_moe_w4a8_perf.py -k "grf" -v +python test_moe_w4a8_perf.py --skip-accuracy --grf --long-seq --rounds 5 +``` + ### 还剩下什么 在 prologue 已被实测排除、tile 阶梯也已扫描过之后,剩下的差距在于流量,而且它的 @@ -1384,6 +1452,7 @@ epilogue 做这件事比单独一遍做得更差。 | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的)。 | | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | 常驻 prefill kernel 何时从 device-scope 工作计数器领取下一个 tile:**`1` (默认)** 把 `atomicAdd` 发在 GEMM 之前,于是这次 L2 往返在整个 mainloop 期间都在途;`0` 恢复旧顺序,即它完全暴露在两个 tile 之间的空档里。计算哪些 tile 完全没变,因此两者逐位相同 (`test_prefill_claim_early_matches` 断言 `torch.equal`);`test_perf_prefill_claim_early_sweep{,_long_seq}` 会对这一对计时。**B70 上实测打平**——两个形状、两个 batch 都是;保持开启是因为它免费,而计数器竞争随常驻 work-group 数量增长,而 B70 的常驻数量很少。参见[常驻 kernel 何时去领下一个 tile](#常驻-kernel-何时去领下一个-tile)。 | +| `ARK_MOE_W4A8_PREFILL_SMALL_GRF` | 有能力承担的 tile policy 是否改为申请 `grf_size<128>` 而不是 `<256>`;这会让每个 Xe core 的常驻 work-group 数翻倍 (512 -> 1024 线程),而且由于 prefill kernel 是常驻式的,启动的网格也随之翻倍。**`1` (默认)**;`0` 让所有 tile 都维持大预算。只有 N 宽 128 的 policy 有资格——N 宽 256 的 tile 其 32x64 累加器就是 128 个寄存器,即整个小寄存器文件——并且 `blks > 1` 会在运行期退出,因为分块重标定要额外带一份浮点 fragment。**每个已发布的 qwen3 形状都落在 `128x256` 上,而它会拒绝该预算,因此被实测过的那些配置没有变化;至于确实会选到 N 宽 128 的那几档阶梯——每专家行数低于 128,或者 `N % 256 != 0`——则会按默认启用这份减半预算,而那里尚未被实测过。** 两种设置逐位相同 (`test_prefill_small_grf_matches`);`test_perf_prefill_grf_sweep{,_long_seq}` 会对它计时。参见[寄存器预算,其实就是 occupancy 这个旋钮](#寄存器预算其实就是-occupancy-这个旋钮)。 | ## 形状约束 @@ -1453,6 +1522,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 | 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×;留在寄存器里的行并未溢出 | | D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×,prefill 单项收益最大 | | tile 的领取改到 GEMM 之前而不是之后,让工作计数器的 device-scope atomic 与 mainloop 重叠 | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **打平**——两个形状、两个 batch 全是 1.00×,落在 drift 之内,两次运行之间符号还翻转;保留是因为它免费,且竞争程度随 Xe core 数量增长 | +| 按 policy 区分的寄存器预算:N 宽 128 的 tile 申请 `grf_size<128>`,每个 Xe core 常驻 4 个 work-group 而不是 2 个 | `ARK_MOE_W4A8_PREFILL_SMALL_GRF=0` | **尚未实测**——在已发布的 `128x256` tile 上是 no-op;用 `ARK_MOE_W4A8_PREFILL_TILE=128x128` 来扫它 | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** From 4ec0465a00ea1d2c2d7df61895d1c49cbd4aa49a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:03:42 +0000 Subject: [PATCH 107/112] =?UTF-8?q?perf:=20default=20the=20w4a8=20prefill?= =?UTF-8?q?=20small-GRF=20budget=20off=20=E2=80=94=20B70=20measured=20it?= =?UTF-8?q?=201.2-1.6x=20slower?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../wrapper/include/sycl_tla_moe_w4a8.hpp | 69 +++++++++++------ .../ark/test/README_MOE_W4A8.md | 75 ++++++++++++++----- .../ark/test/README_MOE_W4A8_CN.md | 60 +++++++++++---- .../ark/test/test_moe_w4a8_perf.py | 63 ++++++++++------ 4 files changed, 188 insertions(+), 79 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp index ee55db608f..70d86f4198 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8.hpp @@ -269,8 +269,16 @@ class MoEW4A8GemmName; // 128-wide N can double its occupancy, 256-wide N cannot be asked to. // // This is a request, not a guarantee: if the halved budget does not hold the -// staged tiles the compiler spills, which costs time but not correctness. The -// flag says "this tile may be asked", the sweep says whether it should be. +// staged tiles the compiler spills, which costs time but not correctness. +// +// **Measured on B70, it spills.** Forcing `128x128` and halving the budget cost +// 1.56-1.60x on qwen3 up and 1.16-1.20x on down -- the occupancy doubled and the +// tile stopped fitting. The penalty tracks `K` rather than tile count, putting +// it in the mainloop, on exactly the staged fragments this paragraph worried +// about. So `ARK_MOE_W4A8_PREFILL_SMALL_GRF` defaults to off and the ladder +// keeps the large budget; `kSmallGrfOk` remains only so the question can be +// re-asked on a part with a different register file. Full numbers are in the +// launch-time comment on `moe_w4a8_prefill_launch`. // --------------------------------------------------------------------------- class w4a8_policy_m_8 : public moe_dpas_fp8::dpas_policy_base { public: @@ -1107,26 +1115,41 @@ void MoEGEMMLauncher_w4a8(sycl::queue& stream, const int8_t* activations, const // claim stays on because it is free and counter contention grows with the // number of resident work-groups. The host-side counter reset it forced is a // correctness fix and applies to both settings. -// `ARK_MOE_W4A8_PREFILL_SMALL_GRF=0` keeps every tile at `grf_size<256>` and the -// 512-work-item grid, the A/B baseline for the per-policy register budget. The -// GRF request used to be hardwired here, which pinned all six tiles at 2 -// work-groups per Xe-core -- so the small tiles in the ladder paid for their -// smaller accumulator and never collected the occupancy that is the reason to -// have them. It is now a property of the policy (`kSmallGrfOk`, see the tile -// table above): 128-wide-N tiles ask for half the registers and get twice the -// resident work-groups, 256-wide-N tiles cannot because their accumulator alone -// is a whole 128-register file. `blks > 1` opts out at runtime -- the blocked -// re-scale carries a second, float fragment for the whole mainloop. -// -// The default ladder sends every shipped qwen3 shape to `128x256`, which is not -// eligible, so this changes nothing until a tile is forced: -// `ARK_MOE_W4A8_PREFILL_TILE=128x128` with and without this flag separates the -// tile from the register budget, which a policy-only knob could not do. Both -// settings compute the same tiles in the same order and are bit-identical; -// `test_prefill_small_grf_matches` asserts it. Occupancy is a latency-hiding -// lever, so the shape to watch is the down projection: half its time is -// per-tile cost, ~78% of that the 64 KB of D each tile writes, and covering a -// store stream is what more resident work-groups are for. +// `ARK_MOE_W4A8_PREFILL_SMALL_GRF=1` lets a tile policy that can afford it ask +// for `grf_size<128>` instead of `<256>`, which doubles both the threads an Xe +// core holds (512 -> 1024) and, because this kernel is persistent and its grid +// is sized to fill the device exactly once, the launched grid with it. It is +// **off by default: measured on B70, it loses, and not narrowly.** +// +// tile 128x128, forced grf 256 grf 128 +// qwen3 up @ 49152 tokens 2.399 ms 3.745 ms 1.56x slower +// qwen3 up @ 65536 tokens 2.866 ms 4.579 ms 1.60x slower +// qwen3 down @ 49152 tokens 1.794 ms 2.147 ms 1.20x slower +// qwen3 down @ 65536 tokens 2.352 ms 2.723 ms 1.16x slower +// +// The occupancy did double; it bought nothing because the tile no longer fits. +// A 128-wide-N tile's accumulator is 64 int32 per lane, which is half of a +// 128-register file before a single operand is staged, and the measurement says +// the other half does not hold the mainloop's live set. The penalty scales with +// `K` -- 1.56-1.60x at K=2048 against 1.16-1.20x at K=768, i.e. with mainloop +// trips rather than with tile count -- which places the spill inside the +// mainloop, on the staged A/B fragments that are live across every iteration. +// Round-to-round drift on the spilling rows also jumped from the usual 0.5-1.7% +// to 4.2-15.3%, as scratch traffic makes the runtime depend on memory state. +// +// So the reason the small tiles in the ladder were swept at the large budget is +// not an oversight: at 128 registers they stop being fast. `kSmallGrfOk` and +// `W4A8GrfBudget` stay so the result can be re-checked on a part with a +// different register file, but on this one the knob is closed. Both settings +// remain bit-identical (`test_prefill_small_grf_matches`), and +// `test_perf_prefill_grf_sweep{,_long_seq}` reproduces the table above -- +// forcing `ARK_MOE_W4A8_PREFILL_TILE=128x128`, since the ladder sends every +// shipped qwen3 shape to `128x256`, which is not eligible either way. +// +// This closes the last latency knob. Prefetch depth, tile order, claim order +// and now occupancy have all come back flat or worse, which is what the cost +// model predicted: the down projection's per-tile cost is ~78% the 64 KB of D +// it writes, and bytes are not something residency can hide. // --------------------------------------------------------------------------- template void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { @@ -1151,7 +1174,7 @@ void moe_w4a8_prefill_launch(const moe_w4a8_detail::W4A8PrefillParams& p) { store_2d_aligned && moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_STORE_2D", true); const int prefetch_dist = moe_w4a8_prefill_prefetch_dist(); const bool claim_early = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_CLAIM_EARLY", true); - const bool small_grf = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_SMALL_GRF", true); + const bool small_grf = moe_decode_detail::env_flag_enabled("ARK_MOE_W4A8_PREFILL_SMALL_GRF", false); int32_t* atomic_buffer = moe_dpas_fp8::get_atomic_scratch_buffer(p.q); MoEGEMMLauncher_w4a8(*p.q, p.qact, p.weights, p.ascale, p.wscale, outputs, p.N, p.K, diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 2612ef169a..c79afaef17 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1442,8 +1442,10 @@ python test_moe_w4a8_perf.py --skip-accuracy --claim-early --long-seq --rounds 5 ### The register budget, which is really the occupancy knob -That last paragraph names the one lever the sweeps had not touched, so it is now -wired up. `grf_size` used to be a constant in the launcher, and it was not +That last paragraph names the one lever the sweeps had not touched, so it was +wired up and swept. **It lost**, and the numbers are below; this section keeps +the reasoning because the way it lost is the useful part. `grf_size` used to be +a constant in the launcher, and it was not alone: the grid is sized from a `MaxThreadsPerSM = 512` right above it, and 512 is not a generic number — it is `8 vector engines × 4 threads × SIMD16`, which is exactly what an Xe core holds *when each lane asks for 256 registers*. Ask @@ -1483,10 +1485,8 @@ argument that made `blks == 1` a specialized path in the first place. **The measured configurations are unchanged.** The ladder sends every shipped qwen3 shape to `128 × 256`, which declines the halved budget, so both settings -launch the identical kernel there. The rungs that do pick a 128-wide tile — -under 128 rows/expert, or `N % 256 != 0` — take the halved budget by default, -and those have not been measured. That is also why the sweep has to force a -tile: +launch the identical kernel there. Observing the knob at all therefore requires +forcing a tile: | row | tile | `grf_size` | what it is for | |---|---|---|---| @@ -1494,19 +1494,54 @@ tile: | 2 | 128 × 128 | 128 | the same tile at double occupancy | | 3 | 128 × 256 | 256 | the shipped default, for reference | -Rows 1 and 2 are the measurement; row 3 is the question they answer. It is not -"do 128 registers beat 256 on the same tile" but "**does the small tile at -double occupancy finally catch the big tile that has beaten it in every sweep so -far**". If row 2 beats row 1 and still loses to row 3, the ladder stays as it is -and the answer is that the 256-wide N tile wins on A re-reads, not on occupancy. +#### The answer: it spills, and the flag now defaults to off -The shape to watch is the down projection, for the reason the cost model gives: -half its time is per-tile cost, ~78% of that is the D write, and covering a -store stream with other work-groups is precisely what residency buys. qwen3 up -has nothing to hide — with the quantizer subtracted its GEMM is already at 86% -of the device's int8 peak. A tie on all three rows is a real result too: it -would close the last latency knob and leave the D write as the only prefill -lever standing. +| tile 128 × 128, forced | `grf 256` | `grf 128` | | +|---|---|---|---| +| qwen3 up @ 49152 tokens | 2.399 ms | 3.745 ms | **1.56× slower** | +| qwen3 up @ 65536 tokens | 2.866 ms | 4.579 ms | **1.60× slower** | +| qwen3 down @ 49152 tokens | 1.794 ms | 2.147 ms | **1.20× slower** | +| qwen3 down @ 65536 tokens | 2.352 ms | 2.723 ms | **1.16× slower** | + +The occupancy did double. It bought nothing, because the tile stopped fitting. +A 128-wide-N accumulator is 64 int32 per lane — half a 128-register file before +a single operand is staged — and the measurement says the other half does not +hold the mainloop's live set. + +Where the spill is, is readable from how the penalty scales: + +| shape | K | mainloop trips | extra per tile | extra per trip | +|---|---|---|---|---| +| qwen3 up | 2048 | 32 | 279–292 ns | 8.7–9.1 ns | +| qwen3 down | 768 | 12 | 45–58 ns | 3.8–4.8 ns | + +The ratio is 1.56–1.60× at K=2048 against 1.16–1.20× at K=768, so the cost is +per mainloop trip rather than per tile. That rules out the prologue and the +epilogue and puts it on the staged A/B fragments, which are the operands live +across every iteration. Drift on the spilling rows corroborates it: it rose from +the usual 0.5–1.7% to 4.2–15.3%, which is what scratch traffic does to a +measurement. + +So the small tiles in the ladder were never being short-changed by the large +budget — at 128 registers they stop being fast, which is why the ladder was +swept at 256 in the first place. `kSmallGrfOk` and `W4A8GrfBudget` stay in the +kernel so the question can be re-asked on a part with a different register file, +but `ARK_MOE_W4A8_PREFILL_SMALL_GRF` defaults to `0` and the sweep above is now +a regression guard rather than a search. + +**This closes the last latency knob.** Prefetch depth, tile order, claim order +and now occupancy have all come back flat or worse — which is what the cost +model said would happen. Half of qwen3 down's time is per-tile cost, ~78% of +that is the 64 KB of D each tile writes, and bytes are not something residency +can hide. What is left for prefill is byte-count work, not scheduling. + +One thing the sweep does *not* settle: at the large budget, forced `128 × 128` +against the shipped `128 × 256`. On qwen3 up it is 5.6% and 0.9% ahead at the +two batches, both inside the 6.3% and 7.8% drift on those rows — inconclusive. +On down the two gaps exceed drift but point in opposite directions: `128 × 128` +is 1.7% ahead at 49152 tokens, the default 4.7% ahead at 65536, which is the +point the harness named a winner at. The ladder's choice therefore stands where +it matters and the shorter batch is left unexplained. Every row computes the same tiles from the same inputs, so all three must be bit-identical; `test_prefill_small_grf_matches` asserts it against a ragged @@ -1675,7 +1710,7 @@ analysis above is why. | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no). | | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | When the persistent prefill kernel claims its next tile from the device-scope work counter: **`1` (default)** issues the `atomicAdd` before the GEMM, so the L2 round trip is in flight across the mainloop; `0` restores the old order, where it sat fully exposed in the gap between two tiles. Which tiles run does not change, so the two are bit-identical (`test_prefill_claim_early_matches` asserts `torch.equal`); `test_perf_prefill_claim_early_sweep{,_long_seq}` times the pair. **Measured a tie on B70** at both shapes and both batches — kept on because it is free and counter contention scales with the number of resident work-groups, which B70 has few of. See [When the persistent kernel asks for its next tile](#when-the-persistent-kernel-asks-for-its-next-tile). | -| `ARK_MOE_W4A8_PREFILL_SMALL_GRF` | Whether a tile policy that can afford it asks for `grf_size<128>` instead of `<256>`, which doubles the resident work-groups per Xe core (512 -> 1024 threads) and, because the prefill kernel is persistent, doubles the launched grid with it. **`1` (default)**; `0` keeps every tile at the large budget. Only the 128-wide-N policies are eligible -- a 256-wide N tile's 32x64 accumulator is 128 registers, the whole small file -- and `blks > 1` opts out at runtime because the blocked re-scale carries a second, float fragment. **Every shipped qwen3 shape lands on `128x256`, which declines it, so the measured configurations are unchanged; the ladder rungs that do pick a 128-wide tile -- under 128 rows/expert, or `N % 256 != 0` -- take the halved budget by default and have not been measured there.** Bit-identical either way (`test_prefill_small_grf_matches`); `test_perf_prefill_grf_sweep{,_long_seq}` times it. See [The register budget, which is really the occupancy knob](#the-register-budget-which-is-really-the-occupancy-knob). | +| `ARK_MOE_W4A8_PREFILL_SMALL_GRF` | Whether a tile policy that can afford it asks for `grf_size<128>` instead of `<256>`, which doubles the resident work-groups per Xe core (512 -> 1024 threads) and, because the prefill kernel is persistent, doubles the launched grid with it. **`0` (default)**; `1` lets the eligible tiles ask. Only the 128-wide-N policies are eligible -- a 256-wide N tile's 32x64 accumulator is 128 registers, the whole small file -- and `blks > 1` opts out at runtime because the blocked re-scale carries a second, float fragment. **Measured on B70 and it loses: 1.56-1.60x slower on qwen3 up and 1.16-1.20x on down at a forced `128x128`, because the halved file cannot hold the mainloop's staged operands. Off by default; the flag survives only so the question can be re-asked on a part with a different register file.** Bit-identical either way (`test_prefill_small_grf_matches`); `test_perf_prefill_grf_sweep{,_long_seq}` times it. See [The register budget, which is really the occupancy knob](#the-register-budget-which-is-really-the-occupancy-knob). | ## Shape constraints @@ -1751,7 +1786,7 @@ have now been timed twice, and all three kept their default: | Single-pass activation quantizer — the row stays in registers, so `[T, K]` is read once instead of twice | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×; the register-resident row does not spill | | 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×, the largest single prefill win | | Tile claim issued before the GEMM instead of after it, so the work counter's device-scope atomic overlaps the mainloop | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **A tie** — 1.00× on both shapes at both batches, inside drift, sign flips between runs; kept because it is free and contention scales with Xe-core count | -| Per-policy register budget: 128-wide-N tiles ask for `grf_size<128>` and get 4 resident work-groups per Xe core instead of 2 | `ARK_MOE_W4A8_PREFILL_SMALL_GRF=0` | **Not yet measured** — no-op on the shipped `128x256` tile; sweep it with `ARK_MOE_W4A8_PREFILL_TILE=128x128` | +| Register budget made a property of the tile policy (`kSmallGrfOk`), then measured and left **off** | already off — `ARK_MOE_W4A8_PREFILL_SMALL_GRF=1` asks for it | **0.63–0.86×, a clear loss.** Double occupancy at `grf_size<128>`, but a 128-wide-N accumulator is half the file and the mainloop's staged operands spill; the penalty scales with K, not with tiles | The 2D store was previously listed as needing a device rather than a flag, on the grounds that the sibling MoE kernels reach it through diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index c80d4a194d..40f07d5dcb 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -1252,7 +1252,8 @@ python test_moe_w4a8_perf.py --skip-accuracy --claim-early --long-seq --rounds 5 ### 寄存器预算,其实就是 occupancy 这个旋钮 -上一段点名的正是那些 sweep 唯一没碰过的杠杆,现在把它接上了。`grf_size` 过去是 +上一段点名的正是那些 sweep 唯一没碰过的杠杆,于是把它接上并扫了一遍。**结果是输了**, +数字在下面;这一节保留原有推理,因为"输在哪里"才是有用的那部分。`grf_size` 过去是 launcher 里的一个常量,而且它不是一个人:网格规模由紧挨着它上面的 `MaxThreadsPerSM = 512` 决定,而 512 并不是一个泛泛的数字——它就是 `8 个 vector engine × 4 个线程 × SIMD16`,也正是**每个 lane 要 256 个寄存器时**一个 @@ -1288,9 +1289,8 @@ fragment,也就是 WG tile 除以 sub-group 数: 的那个寄存器文件论证。 **被实测过的那些配置没有变化。** 阶梯会把所有已发布的 qwen3 形状都送到 -`128 × 256`,而它拒绝这份减半的预算,因此在那里两种设置启动的是同一个 kernel。至于 -确实会选到 N 宽 128 的那几档——每专家行数低于 128,或者 `N % 256 != 0`——则按默认接受 -这份减半预算,而那里尚未被实测过。这也正是 sweep 必须强制指定一个 tile 的原因: +`128 × 256`,而它拒绝这份减半的预算,因此在那里两种设置启动的是同一个 kernel。所以要 +想观察到这个旋钮,就必须强制指定一个 tile: | 行 | tile | `grf_size` | 用途 | |---|---|---|---| @@ -1298,16 +1298,46 @@ fragment,也就是 WG tile 除以 sub-group 数: | 2 | 128 × 128 | 128 | 同一个 tile,occupancy 翻倍 | | 3 | 128 × 256 | 256 | 已发布的默认值,作为参照 | -第 1、2 行是测量,第 3 行才是它们要回答的问题。问题不是"同一个 tile 上 128 个寄存器 -是否胜过 256 个",而是"**occupancy 翻倍之后,小 tile 是否终于追上了那个在此前每一轮 -sweep 里都赢过它的大 tile**"。如果第 2 行赢了第 1 行却仍然输给第 3 行,那阶梯就维持 -原样,结论是 N 宽 256 的 tile 赢在 A 的重复读取上,而不是赢在 occupancy 上。 +#### 答案:它 spill 了,因此这个开关现在默认关闭 -要盯的形状是 down projection,理由由成本模型给出:它一半的时间是 per-tile 成本,其中 -约 78% 是 D 的写出,而用别的 work-group 去掩盖一条写出流,正是常驻度买来的东西。 -qwen3 up 没什么可掩盖的——把量化那一遍减掉之后,它的 GEMM 已经在设备 int8 峰值的 86% -上了。三行全部打平同样是一个实实在在的结果:那将关掉最后一个延迟类旋钮,让 D 的写出 -成为 prefill 唯一还站着的杠杆。 +| 强制 tile 128 × 128 | `grf 256` | `grf 128` | | +|---|---|---|---| +| qwen3 up @ 49152 tokens | 2.399 ms | 3.745 ms | **慢 1.56×** | +| qwen3 up @ 65536 tokens | 2.866 ms | 4.579 ms | **慢 1.60×** | +| qwen3 down @ 49152 tokens | 1.794 ms | 2.147 ms | **慢 1.20×** | +| qwen3 down @ 65536 tokens | 2.352 ms | 2.723 ms | **慢 1.16×** | + +occupancy 确实翻倍了,但什么也没买到,因为 tile 装不下了。N 宽 128 的累加器是每个 lane +64 个 int32——在任何一个操作数被暂存之前,就已经占掉了 128 寄存器文件的一半——而实测说明 +剩下那一半装不下 mainloop 的活跃集合。 + +spill 在哪里,可以从这个代价如何随规模变化读出来: + +| 形状 | K | mainloop 轮数 | 每 tile 多出 | 每轮多出 | +|---|---|---|---|---| +| qwen3 up | 2048 | 32 | 279–292 ns | 8.7–9.1 ns | +| qwen3 down | 768 | 12 | 45–58 ns | 3.8–4.8 ns | + +K=2048 时比值是 1.56–1.60×,K=768 时是 1.16–1.20×,所以这个代价是按 mainloop 轮数 +而不是按 tile 数增长的。这就排除了 prologue 和 epilogue,把它定位在暂存的 A/B fragment +上——也就是每一轮都活跃的那些操作数。drift 也印证了这一点:发生 spill 的那几行从平常的 +0.5–1.7% 涨到了 4.2–15.3%,这正是 scratch 流量对一次测量的影响。 + +所以阶梯里的小 tile 从来就没有被大预算亏待过——在 128 个寄存器下它们不再快,而这正是当初 +阶梯就用 256 来扫的原因。`kSmallGrfOk` 和 `W4A8GrfBudget` 留在 kernel 里,是为了能在寄存器 +文件不同的硬件上重新问一遍这个问题;但 `ARK_MOE_W4A8_PREFILL_SMALL_GRF` 默认为 `0`,上面 +这个 sweep 现在是一道回归防线,而不再是一次搜索。 + +**这关掉了最后一个延迟类旋钮。** prefetch 深度、tile 顺序、claim 顺序,加上现在的 +occupancy,全都打平或更差——而这正是成本模型预言过的。qwen3 down 一半的时间是 per-tile +成本,其中约 78% 是每个 tile 写出的那 64 KB D,而字节数不是常驻度能掩盖的东西。prefill +剩下的活是减少字节数,不是调度。 + +有一件事这轮 sweep 没有定论:在大预算下,强制 `128 × 128` 与已发布的 `128 × 256` 的对比。 +在 qwen3 up 上它在两个 batch 分别领先 5.6% 和 0.9%,而这两行的 drift 是 6.3% 和 7.8%, +因此没有结论。在 down 上两个差距都超过了 drift,但方向相反:49152 tokens 时 +`128 × 128` 领先 1.7%,65536 tokens 时默认值领先 4.7%——而后者正是测试框架判定出赢家的 +那个点。所以阶梯的选择在要紧的地方站得住,较短的那个 batch 则仍未获解释。 每一行都用相同的输入计算相同的 tile,因此三者必须逐位相同; `test_prefill_small_grf_matches` 会在一个不整齐 (ragged) 的 batch 上断言这一点——规模 @@ -1452,7 +1482,7 @@ epilogue 做这件事比单独一遍做得更差。 | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的)。 | | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY` | 常驻 prefill kernel 何时从 device-scope 工作计数器领取下一个 tile:**`1` (默认)** 把 `atomicAdd` 发在 GEMM 之前,于是这次 L2 往返在整个 mainloop 期间都在途;`0` 恢复旧顺序,即它完全暴露在两个 tile 之间的空档里。计算哪些 tile 完全没变,因此两者逐位相同 (`test_prefill_claim_early_matches` 断言 `torch.equal`);`test_perf_prefill_claim_early_sweep{,_long_seq}` 会对这一对计时。**B70 上实测打平**——两个形状、两个 batch 都是;保持开启是因为它免费,而计数器竞争随常驻 work-group 数量增长,而 B70 的常驻数量很少。参见[常驻 kernel 何时去领下一个 tile](#常驻-kernel-何时去领下一个-tile)。 | -| `ARK_MOE_W4A8_PREFILL_SMALL_GRF` | 有能力承担的 tile policy 是否改为申请 `grf_size<128>` 而不是 `<256>`;这会让每个 Xe core 的常驻 work-group 数翻倍 (512 -> 1024 线程),而且由于 prefill kernel 是常驻式的,启动的网格也随之翻倍。**`1` (默认)**;`0` 让所有 tile 都维持大预算。只有 N 宽 128 的 policy 有资格——N 宽 256 的 tile 其 32x64 累加器就是 128 个寄存器,即整个小寄存器文件——并且 `blks > 1` 会在运行期退出,因为分块重标定要额外带一份浮点 fragment。**每个已发布的 qwen3 形状都落在 `128x256` 上,而它会拒绝该预算,因此被实测过的那些配置没有变化;至于确实会选到 N 宽 128 的那几档阶梯——每专家行数低于 128,或者 `N % 256 != 0`——则会按默认启用这份减半预算,而那里尚未被实测过。** 两种设置逐位相同 (`test_prefill_small_grf_matches`);`test_perf_prefill_grf_sweep{,_long_seq}` 会对它计时。参见[寄存器预算,其实就是 occupancy 这个旋钮](#寄存器预算其实就是-occupancy-这个旋钮)。 | +| `ARK_MOE_W4A8_PREFILL_SMALL_GRF` | 有能力承担的 tile policy 是否改为申请 `grf_size<128>` 而不是 `<256>`;这会让每个 Xe core 的常驻 work-group 数翻倍 (512 -> 1024 线程),而且由于 prefill kernel 是常驻式的,启动的网格也随之翻倍。**`0` (默认)**;`1` 允许有资格的 tile 提出申请。只有 N 宽 128 的 policy 有资格——N 宽 256 的 tile 其 32x64 累加器就是 128 个寄存器,即整个小寄存器文件——并且 `blks > 1` 会在运行期退出,因为分块重标定要额外带一份浮点 fragment。**已在 B70 实测,结论是输了:强制 `128x128` 时 qwen3 up 慢 1.56-1.60×、down 慢 1.16-1.20×,因为减半的寄存器文件装不下 mainloop 暂存的操作数。默认关闭;这个开关保留下来,只是为了能在寄存器文件不同的硬件上重新问一遍。** 两种设置逐位相同 (`test_prefill_small_grf_matches`);`test_perf_prefill_grf_sweep{,_long_seq}` 会对它计时。参见[寄存器预算,其实就是 occupancy 这个旋钮](#寄存器预算其实就是-occupancy-这个旋钮)。 | ## 形状约束 @@ -1522,7 +1552,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 | 单遍激活量化——行数据留在寄存器中,`[T, K]` 只读一次而不是两次 | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS=0` | 1.00–1.06×;留在寄存器里的行并未溢出 | | D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×,prefill 单项收益最大 | | tile 的领取改到 GEMM 之前而不是之后,让工作计数器的 device-scope atomic 与 mainloop 重叠 | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **打平**——两个形状、两个 batch 全是 1.00×,落在 drift 之内,两次运行之间符号还翻转;保留是因为它免费,且竞争程度随 Xe core 数量增长 | -| 按 policy 区分的寄存器预算:N 宽 128 的 tile 申请 `grf_size<128>`,每个 Xe core 常驻 4 个 work-group 而不是 2 个 | `ARK_MOE_W4A8_PREFILL_SMALL_GRF=0` | **尚未实测**——在已发布的 `128x256` tile 上是 no-op;用 `ARK_MOE_W4A8_PREFILL_TILE=128x128` 来扫它 | +| 把寄存器预算改成 tile policy 的属性 (`kSmallGrfOk`),实测后**保持关闭** | 已经是关闭的 —— `ARK_MOE_W4A8_PREFILL_SMALL_GRF=1` 才会去申请 | **0.63–0.86×,明确的负结果。** `grf_size<128>` 下 occupancy 确实翻倍,但 N 宽 128 的累加器就占掉半个文件,mainloop 暂存的操作数会 spill;代价按 K 而不是按 tile 数增长 | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 7d034f8f8f..e81b3a8d09 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1855,18 +1855,32 @@ def _call(act, ascale=None, ntpe=None): # first two rows do exactly that: same 128x128 tile, same everything else, one # register budget each. That pair is the measurement. # -# The third row is the shipped default, and it is what the pair is *for*. The -# question is not whether 128 registers beat 256 on the same tile -- it is -# whether the smaller tile at double occupancy finally catches the bigger tile -# that has beaten it in every sweep so far. If row 2 beats row 1 but neither -# reaches row 3, the ladder stays as it is and the answer is that the 256-wide N -# tile wins on A re-reads, not on occupancy. +# The third row is the shipped default, for reference. # -# Expect the effect, if any, on the down projection: occupancy hides latency, -# half of qwen3 down's time is per-tile cost that does not scale with K, and -# ~78% of that is the 64 KB of D each tile writes. Covering a store stream with -# other work-groups is what more residency buys. The up projection is at 86% of -# the DPAS peak with the quantizer subtracted and has nothing to hide. +# Measured on B70, and the answer is unambiguous: the halved budget loses. +# +# tile 128x128, forced grf 256 grf 128 +# qwen3 up @ 49152 tokens 2.399 ms 3.745 ms 1.56x slower +# qwen3 up @ 65536 tokens 2.866 ms 4.579 ms 1.60x slower +# qwen3 down @ 49152 tokens 1.794 ms 2.147 ms 1.20x slower +# qwen3 down @ 65536 tokens 2.352 ms 2.723 ms 1.16x slower +# +# The occupancy did double. The tile stopped fitting: a 128-wide-N accumulator +# is 64 int32 per lane, half a 128-register file before anything is staged, and +# the other half does not hold the mainloop's live set. The penalty scales with +# K -- 1.56-1.60x at K=2048 against 1.16-1.20x at K=768 -- so it is per mainloop +# trip, not per tile, which puts the spill on the staged A/B fragments that are +# live across every iteration. Drift on the spilling rows rose from the usual +# 0.5-1.7% to 4.2-15.3%, as scratch traffic makes the runtime depend on memory +# state. +# +# So the small tiles were never being short-changed: at 128 registers they stop +# being fast, which is why the ladder was swept at the large budget to begin +# with. The flag defaults to off and this sweep stays as the regression guard. +# It also closes the last latency knob -- prefetch depth, tile order, claim +# order and now occupancy have all come back flat or worse, exactly as the cost +# model predicted, because ~78% of qwen3 down's per-tile cost is the 64 KB of D +# it writes and bytes are not something residency can hide. _PREFILL_GRF_CONFIGS = [ ("128x128, grf 256", {"ARK_MOE_W4A8_PREFILL_TILE": "128x128", "ARK_MOE_W4A8_PREFILL_SMALL_GRF": "0"}), ("128x128, grf 128", {"ARK_MOE_W4A8_PREFILL_TILE": "128x128", "ARK_MOE_W4A8_PREFILL_SMALL_GRF": "1"}), @@ -2602,11 +2616,15 @@ def test_perf_prefill_grf_sweep(self, request): doubles it, and the two have to move together or nothing changes. The first two rows are the measurement -- one tile, two budgets -- - and the third is the question they answer: whether 128x128 at - double occupancy catches the 128x256 tile that has beaten it in - every sweep so far. Every row computes the same tiles from the same - inputs, so all three must be bit-identical and only the timing is a - measurement. + and the third is the shipped default for reference. Measured on + B70 the halved budget loses by 1.56x here and 1.20x on the down + projection: the occupancy doubles and the tile stops fitting, since + a 128-wide-N accumulator is already half of a 128-register file. + The row to watch is therefore not a hoped-for win but the size of + the spill, which is why this stays as a regression guard. + + Every row computes the same tiles from the same inputs, so all + three must be bit-identical and only the timing is a measurement. """ rows = run_config_sweep("prefill", _PREFILL_GRF_CONFIGS, models=_models_option(request)) assert rows and all(r["w4a8_ms"] > 0 for r in rows) @@ -2623,11 +2641,14 @@ def test_perf_prefill_grf_sweep_long_seq(self, request): both 128 and 256, so the padding penalty that decides the ladder elsewhere is absent here and the tiles compete on their merits. - A tie across all three rows would say the down projection's gap is - not a latency that more resident work-groups can cover, which -- - after prefetch depth, tile order and claim order all came back - flat -- would close the last of the latency knobs and leave the D - write as the only remaining prefill lever. + Measured on B70 the halved budget loses by 1.60x on the up + projection and 1.16x on the down. Both ratios are larger at + K=2048 than at K=768, so the cost scales with mainloop trips + rather than with tile count -- the spill is inside the mainloop, + on the staged A/B fragments. That closes the last of the latency + knobs, after prefetch depth, tile order and claim order all came + back flat, and leaves the D write as the only remaining prefill + lever. """ rows = run_config_sweep( "prefill", From e49b7dcab5a2bbe105ddbc98ad43e70023732e69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:38:54 +0000 Subject: [PATCH 108/112] perf: make the act-quant work-group shape a knob and time the pass directly Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/auto_round_kernel/__init__.py | 52 +++ .../ark/auto_round_kernel/ark.cpp | 10 + .../ark/auto_round_kernel/sycl_tla_moe.cpp.in | 5 + .../wrapper/include/sycl_tla_common.hpp | 14 + .../include/sycl_tla_moe_w4a8_helpers.hpp | 52 +++ .../include/sycl_tla_moe_w4a8_kernels.hpp | 83 +++- .../ark/test/test_moe_w4a8_perf.py | 435 +++++++++++++++++- 7 files changed, 628 insertions(+), 23 deletions(-) diff --git a/auto_round_extension/ark/auto_round_kernel/__init__.py b/auto_round_extension/ark/auto_round_kernel/__init__.py index 53816e3b80..346affa3db 100644 --- a/auto_round_extension/ark/auto_round_kernel/__init__.py +++ b/auto_round_extension/ark/auto_round_kernel/__init__.py @@ -3808,6 +3808,58 @@ def moe_gemm_w4a8( return outputs +def moe_w4a8_quant_act(activations: torch.Tensor) -> "tuple[torch.Tensor, torch.Tensor]": + """Quantize activations per token the way :func:`moe_gemm_w4a8` does. + + This is the call's own quantization pass, on its own. Two uses: + + * **Pre-quantization.** The returned pair is exactly what + ``moe_gemm_w4a8(..., activation_scale=...)`` accepts, so a caller that + routes the same rows through several experts, or that can produce int8 + upstream, can pay for the pass once instead of once per call. + * **Measurement.** The pass had no standalone entry point, so its cost + could only be priced by differencing a 16-bit-input call against a + pre-quantized one -- two GEMM timings subtracted, with the noise of both + landing on a number a fraction of their size. + + Args: + activations: ``[T, K]`` fp16 or bf16 on XPU, ``K % 64 == 0``. + + Returns: + ``(qact, ascale)``: ``[T, K]`` ``torch.int8`` and ``[T]`` + ``torch.float32`` row scales, ``absmax / 127`` (a row that is entirely + zero gets scale 0 and quantizes to zeros). + """ + if activations.device.type != "xpu": + raise NotImplementedError("moe_w4a8_quant_act is only supported on XPU") + if activations.dtype not in (torch.float16, torch.bfloat16): + raise ValueError(f"moe_w4a8_quant_act: activations must be fp16/bf16, got {activations.dtype}") + if activations.ndim != 2: + raise ValueError("moe_w4a8_quant_act: activations must be 2D [T, K]") + + total_tokens, K = activations.shape + if K % 64 != 0: + raise ValueError(f"moe_w4a8_quant_act: K must be a multiple of 64 (got {K})") + + activations = activations.contiguous() + qact = torch.empty((total_tokens, K), device=activations.device, dtype=torch.int8) + ascale = torch.empty((total_tokens,), device=activations.device, dtype=torch.float32) + if total_tokens == 0: + return qact, ascale + + lib = get_lib(activations) + lib.moe_w4a8_quant_act( + get_stream(activations), + activations.data_ptr(), + qact.data_ptr(), + ascale.data_ptr(), + cvt_dtype(activations.dtype), + total_tokens, + K, + ) + return qact, ascale + + def moe_w4a8( activations: torch.Tensor, weights: torch.Tensor, diff --git a/auto_round_extension/ark/auto_round_kernel/ark.cpp b/auto_round_extension/ark/auto_round_kernel/ark.cpp index e3d60544c1..eb70467aa2 100755 --- a/auto_round_extension/ark/auto_round_kernel/ark.cpp +++ b/auto_round_extension/ark/auto_round_kernel/ark.cpp @@ -581,6 +581,15 @@ static void moe_gemm_w4a8_wrapper(torch_ptr stream, torch_ptr activations, torch (const float*)routing_weights, (float*)fused_out, fused_batch); } +// Standalone per-token activation quantization -- the pass `moe_gemm_w4a8` +// runs internally, exposed so it can be timed on its own and so a caller can +// pre-quantize once and hand the result back as `qact`/`ascale`. +static void moe_w4a8_quant_act_wrapper(torch_ptr stream, torch_ptr activations, torch_ptr qact, torch_ptr ascale, + int act_dtype, int total_tokens, int K) { + ark::moe_w4a8_quant_act((sycl::queue*)stream, (const void*)activations, (void*)qact, (void*)ascale, + (BTLA_DTYPE)(act_dtype), total_tokens, K); +} + static void sage_dynamic_quant(torch_ptr stream, torch_ptr input, torch_ptr bias, torch_ptr output, torch_ptr scale_out, int num_rows, int head_dim, int block_size) { auto* q = (sycl::queue*)stream; @@ -1422,6 +1431,7 @@ PYBIND11_MODULE(PY_NAME, m) { m.def("moe_gemm_prefill_int_dpas", &ark::moe_gemm_prefill_int_dpas_wrapper); m.def("moe_w4a8_prepack", &ark::moe_w4a8_prepack_wrapper); m.def("moe_gemm_w4a8", &ark::moe_gemm_w4a8_wrapper); + m.def("moe_w4a8_quant_act", &ark::moe_w4a8_quant_act_wrapper); m.def("moe_w4a8_rescale_block_size", &ark::moe_w4a8_rescale_block_size); m.def("moe_w4a8_release_scratch", &ark::moe_w4a8_release_scratch); m.def("matmul_sycl_tla", &ark::matmul_sycl_tla); diff --git a/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in index 6b0f1c6918..e7ea3cc625 100644 --- a/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in +++ b/auto_round_extension/ark/auto_round_kernel/sycl_tla_moe.cpp.in @@ -1071,6 +1071,11 @@ int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size) { return moe_w4a8_detail::moe_w4a8_rescale_block_size(K, group_size, rescale_group_size); } +void moe_w4a8_quant_act(sycl::queue* q, const void* activations, void* qact, void* ascale, BTLA_DTYPE act_dtype, + int total_tokens, int K) { + moe_w4a8_detail::moe_w4a8_quant_act(q, activations, qact, ascale, act_dtype, total_tokens, K); +} + void moe_w4a8_release_scratch() { moe_w4a8_detail::moe_w4a8_release_scratch(); } } // namespace ark diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp index 07afa30645..ddfec9e570 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_common.hpp @@ -283,6 +283,20 @@ void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, void* ws int num_experts, int total_tokens, int phase, const void* qact_in, const float* ascale_in, const int* row_to_token, const float* routing_weights, float* fused_out, int fused_batch); +/** + * @brief W4A8 per-token activation quantization on its own -- the same pass + * `moe_gemm_w4a8` runs internally, exposed so it can be timed directly and so + * a caller can pre-quantize once and reuse the result across calls. + * + * Quantizes `[total_tokens, K]` `act_dtype` activations to `[total_tokens, K]` + * int8 plus `[total_tokens]` fp32 row scales (`absmax / 127`) -- exactly the + * pair `moe_gemm_w4a8` accepts as `qact_in` / `ascale_in`. + * + * STATUS: NEEDS-HARDWARE-VALIDATION. + */ +void moe_w4a8_quant_act(sycl::queue* q, const void* activations, void* qact, void* ascale, BTLA_DTYPE act_dtype, + int total_tokens, int K); + /** * @brief Resolve the effective W4A8 AUTO_S8 re-scale block size for a given * K / group_size, honouring `ARK_MOE_W4A8_AUTO_S8`. Returns K (one scale per diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp index d25f8afeef..fbd804f232 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_helpers.hpp @@ -536,6 +536,58 @@ inline void moe_gemm_w4a8(sycl::queue* q, void* activations, void* weights_s8, v } } +// --------------------------------------------------------------------------- +// Public entry point 3 -- the per-token activation quantization on its own. +// +// This is the same pass `moe_gemm_w4a8` runs internally when the caller does +// not supply `qact_in`/`ascale_in`, exposed so it can be *measured*. Until now +// the quantizer had no standalone entry point, so the harness priced it by +// differencing a 16-bit-input call against a pre-quantized one: two GEMM +// timings subtracted, with the whole GEMM's run-to-run noise landing on a +// number that is a quarter of one of them. Every claim about the quantizer -- +// its share of the call, the bandwidth it achieves, the ceiling a faster one +// could reach -- rests on that difference, so it is worth having directly. +// +// It is a measurement and pre-quantization entry point, not a new call +// contract: `moe_gemm_w4a8` is unchanged, and its result is what feeds +// `qact_in`/`ascale_in` there. `expert_map` stays null (the decode map is a +// decode-path concern the fused pass folds in for itself). +// +// - activations : [total_tokens, K] act dtype +// - qact : [total_tokens, K] int8 out +// - ascale : [total_tokens] fp32 out, `absmax / 127` +// --------------------------------------------------------------------------- +inline void moe_w4a8_quant_act(sycl::queue* q, const void* activations, void* qact, void* ascale, + BTLA_DTYPE act_dtype, int total_tokens, int K) { + if (total_tokens == 0) return; + if (total_tokens < 0) { + throw std::invalid_argument("moe_w4a8_quant_act: total_tokens must be non-negative"); + } + if (K <= 0 || K % 64 != 0) { + throw std::invalid_argument("moe_w4a8_quant_act: K must be a positive multiple of 64"); + } + if (activations == nullptr || qact == nullptr || ascale == nullptr) { + throw std::invalid_argument("moe_w4a8_quant_act: null buffer"); + } + if (act_dtype != BTLA_DTYPE::F16 && act_dtype != BTLA_DTYPE::BF16) { + throw std::invalid_argument("moe_w4a8_quant_act: act_dtype must be F16 or BF16"); + } + + W4A8QuantParams qp; + qp.q = q; + qp.activations = activations; + qp.qact = static_cast(qact); + qp.ascale = static_cast(ascale); + qp.total_tokens = total_tokens; + qp.K = K; + + if (act_dtype == BTLA_DTYPE::F16) { + quant_f16(qp); + } else { + quant_bf16(qp); + } +} + // Resolve the effective AUTO_S8 block size (host helper, also exported to // Python so callers can size the `wscales` tensor consistently). inline int moe_w4a8_rescale_block_size(int K, int group_size, int rescale_group_size) { diff --git a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp index fda352b08f..c4b84fd9db 100644 --- a/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp +++ b/auto_round_extension/ark/auto_round_kernel/wrapper/include/sycl_tla_moe_w4a8_kernels.hpp @@ -229,6 +229,68 @@ inline void act_quant_write_scale(float* ascale, int token, float scale, int* ex expert_id_per_token[token] = expert; } +// --------------------------------------------------------------------------- +// Work-group shape: how many token rows share one work-group. +// +// Every kernel below maps one sub-group to one token, so the obvious launch is +// `global{T, SG_SIZE} local{1, SG_SIZE}` -- and that is what shipped: one +// sub-group, i.e. **one hardware thread, per work-group**. 65536 routed rows +// are then 65536 single-thread work-groups. +// +// That is the one dimension of this kernel nobody has swept. `ACT_QUANT_VEC` +// (bytes per lane), `ACT_QUANT_UNROLL` (loads in flight) and +// `ACT_QUANT_SINGLE_PASS` (row resident in registers) all tune *what one thread +// does*; none of them changes *how many threads are resident*. Intel's thread +// dispatcher caps the work-groups resident on an Xe-core well below the number +// of thread slots that Xe-core has, so at one thread per work-group the +// work-group limit binds first and the slots go unfilled. Every other kernel in +// this file runs 256 work-items per work-group; the quantizer is the outlier. +// +// Widening to `local{R, SG_SIZE}` carries no synchronization risk: there is no +// barrier and no SLM here, and SYCL linearizes work-items with the last +// dimension fastest, so local id `(r, lane)` is linear id `r * SG_SIZE + lane`. +// With `reqd_sub_group_size(SG_SIZE)` each sub-group therefore still covers +// exactly one token's SG_SIZE lanes, `reduce_over_group(sg, ...)` keeps its +// meaning, and the `token >= total_tokens` guard is sub-group-uniform (all +// SG_SIZE lanes of a sub-group share one `token`), which is what a sub-group +// collective requires. The output is bit-identical, and +// `test_act_quant_rows_per_wg_matches` asserts exactly that with `torch.equal`. +// +// R is a *runtime* value -- it reaches `nd_range` and nothing else, never the +// kernel body -- so the rungs cost no extra kernel instantiations in a +// translation unit that already builds 11. +// +// Default 1, today's shape, until `test_perf_act_quant_wg_sweep` rules on real +// hardware; the per-policy GRF budget is the standing reminder of what shipping +// an unmeasured default costs. Expect little at long prefill: at 65536 rows the +// pass already streams ~493 GB/s, above this machine's own device-copy probe, +// so it is sitting on the roof and there is nothing there to win. The room is +// in the short batch -- 8192 rows read only ~370 GB/s, and 8x the rows cost +// 6.0x the time, so it is short prompts (and decode) that are under-fed. +inline constexpr int kActQuantRowsPerWgDefault = 1; + +inline int moe_w4a8_act_quant_rows_per_wg() { + const char* env = std::getenv("ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG"); + if (env != nullptr) { + char* end = nullptr; + const long long v = std::strtoll(env, &end, 10); + // Powers of two through 16. 16 rows is 256 work-items -- the work-group + // size the GEMMs in this file already launch -- so no device query is + // needed to know the largest rung fits. + if (end != env && (v == 1 || v == 2 || v == 4 || v == 8 || v == 16)) return static_cast(v); + } + return kActQuantRowsPerWgDefault; +} + +// Row count rounded up to whole work-groups; the padding rows exit on the +// guard at the top of each kernel. +inline sycl::nd_range<2> act_quant_nd_range(int total_tokens, int rows_per_wg) { + const size_t rows = static_cast(rows_per_wg); + const size_t padded = ((static_cast(total_tokens) + rows - 1) / rows) * rows; + return sycl::nd_range<2>(sycl::range<2>{padded, static_cast(SG_SIZE)}, + sycl::range<2>{rows, static_cast(SG_SIZE)}); +} + template void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, int8_t* qact, float* ascale, int total_tokens, int K, int* expert_id_per_token, @@ -245,12 +307,11 @@ void launch_act_dynamic_quant_vec(sycl::queue* q, const ScalarT* activations, in const int steps = K / (SG_SIZE * VEC); const int main_steps = steps - (steps % UNROLL); - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + act_quant_nd_range(total_tokens, moe_w4a8_act_quant_rows_per_wg()), + [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { const int token = static_cast(it.get_global_id(0)); + if (token >= total_tokens) return; const int lane = static_cast(it.get_local_id(1)); const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); QVec* out = reinterpret_cast(qact + static_cast(token) * K); @@ -364,12 +425,11 @@ void launch_act_dynamic_quant_vec_single(sycl::queue* q, const ScalarT* activati const int steps = K / (SG_SIZE * VEC); - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + act_quant_nd_range(total_tokens, moe_w4a8_act_quant_rows_per_wg()), + [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { const int token = static_cast(it.get_global_id(0)); + if (token >= total_tokens) return; const int lane = static_cast(it.get_local_id(1)); const ActVec* row = reinterpret_cast(activations + static_cast(token) * K); QVec* out = reinterpret_cast(qact + static_cast(token) * K); @@ -523,12 +583,11 @@ void launch_act_dynamic_quant(sycl::queue* q, const ScalarT* activations, int8_t } } - sycl::range<2> global{static_cast(total_tokens), static_cast(SG_SIZE)}; - sycl::range<2> local{1, static_cast(SG_SIZE)}; - q->parallel_for>( - sycl::nd_range<2>(global, local), [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { + act_quant_nd_range(total_tokens, moe_w4a8_act_quant_rows_per_wg()), + [=](sycl::nd_item<2> it) [[intel::reqd_sub_group_size(SG_SIZE)]] { const int token = static_cast(it.get_global_id(0)); + if (token >= total_tokens) return; const int lane = static_cast(it.get_local_id(1)); const ScalarT* row = activations + static_cast(token) * K; int8_t* out = qact + static_cast(token) * K; diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index e81b3a8d09..0184d2c7fe 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -142,6 +142,26 @@ def _w4a8_skip_reason() -> str: _W4A8_SKIP = _w4a8_skip_reason() + +def _quant_act_skip_reason() -> str: + """Skip reason for the tests that need the standalone quantization entry point. + + Kept apart from :data:`_W4A8_SKIP` so an extension built before + ``moe_w4a8_quant_act`` existed still runs the rest of the suite instead of + skipping all of it. + """ + if _W4A8_SKIP: + return _W4A8_SKIP + if not hasattr(ark.xpu_lib, "moe_w4a8_quant_act"): + return ( + "ark.xpu_lib has no moe_w4a8_quant_act symbol -- rebuild the extension " + "to measure the activation quantization pass on its own" + ) + return "" + + +_QUANT_ACT_SKIP = _quant_act_skip_reason() + print( "[moe-w4a8-perf] xpu_available=%s xpu_lib=%s has_moe_gemm_w4a8=%s" % ( @@ -1489,13 +1509,15 @@ def run_dedup_quant(batches=None, dtype=torch.bfloat16, models=None, verbose=Tru * ``dedup, fused quant`` -- the deduplication with a quantizer of the same quality as the one already in the kernel. - The fused quantizer has no standalone Python entry point, so its cost is - measured rather than assumed: the same GEMM is timed with 16-bit input and - with int8 input, on the same shape and the same weights, and the difference - is the in-kernel quantization of exactly those rows. Doing that at both - ``T`` and ``batch`` rows also cross-checks that the cost is linear in rows - (it should divide by ``top_k``), which is reported as ``fused quant - T/batch`` so a bad measurement cannot pass silently. + The fused quantizer's cost is measured rather than assumed: the same GEMM + is timed with 16-bit input and with int8 input, on the same shape and the + same weights, and the difference is the in-kernel quantization of exactly + those rows. Doing that at both ``T`` and ``batch`` rows also cross-checks + that the cost is linear in rows (it should divide by ``top_k``), which is + reported as ``fused quant T/batch`` so a bad measurement cannot pass + silently. :func:`run_act_quant` now times the same pass through + ``moe_w4a8_quant_act`` without differencing anything, and prints the two + side by side. Up/gate only. The down projection's ``T`` rows are the SiLU output, one distinct row per routed row, so there is nothing to deduplicate; its route @@ -1607,10 +1629,10 @@ def _call(act, ascale=None, ntpe=None): ) t = {k: min(v) for k, v in stages.items()} - # The fused quantizer has no standalone entry point, so difference the - # same GEMM with 16-bit and int8 input: identical shape, identical - # weights, identical output -- the only work that differs is the - # in-kernel quantization of exactly those rows. + # Difference the same GEMM with 16-bit and int8 input: identical shape, + # identical weights, identical output -- the only work that differs is + # the in-kernel quantization of exactly those rows. This is the older, + # softer estimate; `run_act_quant` times the pass itself. quant_fused_t = max(t["gemm_bf16_t"] - t["gemm_int8_t"], 0.0) quant_fused_b = max(t["gemm_bf16_b"] - t["gemm_int8_b"], 0.0) # Should be ~top_k: the quantizer is a pure streaming pass, so its cost @@ -1697,6 +1719,214 @@ def _call(act, ascale=None, ntpe=None): return rows +# --------------------------------------------------------------------------- +# The activation quantization pass, measured on its own +# +# Until `moe_w4a8_quant_act` existed the pass had no standalone entry point, so +# its cost was only ever *inferred*: time the same GEMM with 16-bit input and +# with int8 input and subtract. That is two whole-call timings differenced into +# a number a quarter their size, carrying the run-to-run noise of both -- and +# every claim about the quantizer rests on it (its share of the call, the +# bandwidth it achieves, the ceiling a faster one could reach). This sweep +# measures the pass directly and prints the differenced estimate next to it, so +# the soft number can finally be checked against a hard one. +# --------------------------------------------------------------------------- + +# Token rows per work-group. 1 is the shape that shipped -- `local{1, SG_SIZE}`, +# one sub-group and therefore one *hardware thread* per work-group, which is +# the worst dispatch shape on an Intel GPU: the thread dispatcher's per-Xe-core +# work-group limit binds long before its thread slots fill. 16 rows is 256 +# work-items, the work-group size every GEMM in the same header already uses. +_ACT_QUANT_WG_ROWS = (1, 2, 4, 8, 16) + +# A row count deliberately *not* a multiple of the largest rung, so the +# quantizer's `token >= total_tokens` guard is live in the identity test +# instead of being dead code. Every shipped routing gives a round number of +# rows, so nothing else in this file would ever reach the padding path. +_ACT_QUANT_RAGGED_TOKENS = _PREFILL_BATCHES[0] * _QWEN3_TOPK + 5 + + +def _quant_act_caller(activations): + """A zero-allocation callable that runs one activation-quantization pass. + + Goes through the extension entry point rather than + :func:`ark.moe_w4a8_quant_act` on purpose: the Python wrapper allocates its + two output tensors per call, and this measurement exists to put a hard + number on the *kernel*, not on the caching allocator. Hoisting the + allocation is the only difference; the arguments are the ones the wrapper + would have passed. + + Returns ``(call, qact, ascale)`` -- the outputs are handed back so the + caller can compare them across configurations. + """ + total_tokens, K = activations.shape + qact = torch.empty((total_tokens, K), device=activations.device, dtype=torch.int8) + ascale = torch.empty((total_tokens,), device=activations.device, dtype=torch.float32) + args = ( + ark.get_stream(activations), + activations.data_ptr(), + qact.data_ptr(), + ascale.data_ptr(), + ark.cvt_dtype(activations.dtype), + total_tokens, + K, + ) + lib = ark.get_lib(activations) + return (lambda: lib.moe_w4a8_quant_act(*args)), qact, ascale + + +def _act_quant_bytes(total_tokens, K, act_bytes) -> float: + """Bytes the quantization pass has to move, at best. + + Read ``[T, K]`` activations, write ``[T, K]`` int8, write ``[T]`` fp32 + scales. The second pass re-reads a row the first pass just touched and is + served by cache, so it is not counted -- which makes this a *lower* bound + on traffic and therefore an *upper* bound on the achieved bandwidth. + """ + return float(total_tokens) * K * act_bytes + float(total_tokens) * K + float(total_tokens) * 4.0 + + +def run_act_quant(batches=None, dtype=torch.bfloat16, models=None, verbose=True, wg_rows=None): + """Time the activation quantization pass alone, one row per work-group shape. + + Three things come out of this that the whole-call sweeps cannot give: + + * **The pass's real cost.** Directly measured, not differenced. + * **The bandwidth it achieves**, against the harness's own device-copy + probe. At 65536 rows and K = 2048 the pass moves 402.9 MB; if that lands + near the probe there is no room in the kernel and the only way to make + the pass cheaper is to give it fewer bytes (deduplicate the rows, or take + int8 from the caller), both of which are call-contract changes. + * **Whether the work-group shape matters.** ``ACT_QUANT_VEC`` (bytes per + lane), ``ACT_QUANT_UNROLL`` (loads in flight) and ``ACT_QUANT_SINGLE_PASS`` + (row resident in registers) all tune what one thread does; none of them + changes how many threads are resident, and one thread per work-group is + the one shape nobody has swept. + + Every row is required to be bit-identical to the first: the shape only + changes which thread handles which row, so a difference would be a bug, not + a trade-off. + """ + batches = _PREFILL_BATCHES if batches is None else batches + rungs = _ACT_QUANT_WG_ROWS if wg_rows is None else tuple(wg_rows) + resolved = _models(models) + probe_gbps = _device_bandwidth_gbps() + if verbose: + print() + print("=" * _PERF_WIDTH) + print( + f"W4A8 activation quantization [prefill] (models={'+'.join(n for n, _ in resolved)}, " + f"act={str(dtype).split('.')[-1]}) -- the pass timed on its own, one row per work-group shape" + ) + print( + f"{'shape':<14}{'E':>5}{'N':>7}{'K':>7}{'tokens':>8}{'rows/WG':>9}{'items/WG':>10}" + f"{'quant(ms)':>11}{'GB/s':>9}{'% of probe':>12}{'% of call':>11}{'vs 1 row':>10}{'bits':>10}" + ) + print("-" * _PERF_WIDTH) + rows = [] + shapes = [ + (nk_label, N, K, spec, batch) for _, spec in resolved for nk_label, N, K in spec["nk"] for batch in batches + ] + for nk_label, N, K, spec, batch in shapes: + E, topk, group_size = spec["E"], spec["topk"], spec["group_size"] + total_tokens = batch * topk + case = _build_case( + N, K, E, total_tokens, group_size, dtype, need_reference=False, need_dequant=False, topk=topk + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=group_size, rescale_group_size=-1 + ) + + # The call this pass is a part of, and the same call with the pass + # removed -- the pair the old estimate came from. + call_ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, "prefill")) + prequant_ms = _xpu_time_ms(lambda: _w4a8(case, weights_s8, wscales, block, "prefill", prequant=True)) + differenced_ms = max(call_ms - prequant_ms, 0.0) + + quant_bytes = _act_quant_bytes(total_tokens, K, _dtype_bytes(dtype)) + call, qact, ascale = _quant_act_caller(case["activations"]) + reference = None + first_row = len(rows) + for wg in rungs: + with _env_override(ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=str(wg)): + quant_ms = _xpu_time_ms(call) + call() # leave `qact`/`ascale` holding this configuration's output + torch.xpu.synchronize() + if reference is None: + reference = (qact.clone(), ascale.clone()) + identical = True + else: + identical = torch.equal(qact, reference[0]) and torch.equal(ascale, reference[1]) + gbps = quant_bytes / (quant_ms * 1e-3) / 1e9 if quant_ms > 0 else 0.0 + rows.append( + { + "label": nk_label, + "E": E, + "N": N, + "K": K, + "tokens": total_tokens, + "batch": batch, + "rows_per_wg": wg, + "quant_ms": quant_ms, + "quant_bytes": quant_bytes, + "gbps": gbps, + "device_bw_gbps": probe_gbps, + "call_ms": call_ms, + "prequant_ms": prequant_ms, + "differenced_ms": differenced_ms, + "share_of_call": (quant_ms / call_ms) if call_ms > 0 else None, + "identical": identical, + } + ) + # `vs 1 row` is relative to this shape's first rung, so it can only be + # filled in once that rung has been timed. + shape_rows = rows[first_row:] + baseline_ms = shape_rows[0]["quant_ms"] + for row in shape_rows: + row["vs_first"] = (baseline_ms / row["quant_ms"]) if row["quant_ms"] > 0 else None + if verbose: + share = row["share_of_call"] + probe_pct = (100.0 * row["gbps"] / probe_gbps) if probe_gbps else None + probe_txt = f"{probe_pct:.0f}%" if probe_pct else "--" + share_txt = f"{100.0 * share:.1f}%" if share else "--" + vs_txt = f"{row['vs_first']:.2f}x" if row["vs_first"] else "--" + bits_txt = "same" if row["identical"] else "DIFFER" + print( + f"{row['label']:<14}{E:>5}{N:>7}{K:>7}{total_tokens:>8}{row['rows_per_wg']:>9}" + f"{row['rows_per_wg'] * 16:>10}{row['quant_ms']:>11.3f}{row['gbps']:>9.0f}" + f"{probe_txt:>12}{share_txt:>11}{vs_txt:>10}{bits_txt:>10}" + ) + + call = qact = ascale = reference = None + case = weights_s8 = wscales = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + + if verbose and rows: + print() + print("the pass, measured against the estimate every earlier conclusion used:") + for row in rows: + # One line per shape: the first rung is the shipped launch shape. + if row["rows_per_wg"] != rungs[0]: + continue + direct, differenced = row["quant_ms"], row["differenced_ms"] + gap = (abs(direct - differenced) / direct * 100.0) if direct > 0 else None + share = row["share_of_call"] + share_txt = f", {100.0 * share:.1f}% of the {row['call_ms']:.3f} ms call" if share else "" + gap_txt = f" -- {gap:.0f}% apart" if gap is not None else "" + verdict = " <- the estimate was off" if gap is not None and gap > 25.0 else "" + print( + f" {row['label'].strip():<12} {row['tokens']:>6} rows: measured {direct:.3f} ms " + f"({row['quant_bytes'] / 1e6:.1f} MB at {row['gbps']:.0f} GB/s{share_txt})" + ) + print( + f" {'':<12} {'':>6} differencing the same call against int8-in says " + f"{differenced:.3f} ms{gap_txt}{verdict}" + ) + return rows + + # --------------------------------------------------------------------------- # Kernel-configuration sweeps # @@ -2454,6 +2684,65 @@ def test_perf_prefill_act_quant_single_pass_sweep(self, request): f"SNR {row['snr_db']:.2f} dB" ) + @pytest.mark.skipif(bool(_QUANT_ACT_SKIP), reason=_QUANT_ACT_SKIP or "standalone quantizer unavailable") + def test_perf_act_quant_wg_sweep(self, request): + """Measure the quantization pass directly, and sweep its work-group shape. + + Two things at once, because they need the same setup. + + **The measurement.** Every earlier conclusion about this pass came + from differencing a 16-bit-input call against a pre-quantized one: + two whole-call timings subtracted into a number a quarter their + size. ``moe_w4a8_quant_act`` makes the pass callable on its own, so + the table prints the measured cost, the bandwidth it implies + against the harness's own copy probe, and the differenced estimate + beside it. If the two disagree, the estimate was the problem. + + **The sweep.** The pass ships as ``local{1, SG_SIZE}`` -- one + sub-group, i.e. one hardware thread, per work-group. That is the + worst dispatch shape on an Intel GPU: the thread dispatcher caps + resident work-groups per Xe-core well below the thread slots that + Xe-core has, so the work-group limit binds first and the slots go + unfilled. Every other kernel in the same header launches 256 + work-items. ``VEC``, ``UNROLL`` and ``SINGLE_PASS`` all tune what + one thread does; this is the only knob that changes how many + threads are resident. + + Expect little at long prefill -- the pass already streams close to + the probe there, and a kernel on the roof has nothing to give. The + room, if any, is at the short batch, which reads well under it. + + The shape only decides which thread handles which row, so every + rung must be bit-identical, not merely close. + """ + rows = run_act_quant(models=_models_option(request)) + assert rows and all(r["quant_ms"] > 0 for r in rows) + for row in rows: + assert row["identical"], ( + f"{row['label'].strip()}: {row['rows_per_wg']} rows/work-group changed the quantized " + f"activations -- the work-group shape must only move work between threads" + ) + + @pytest.mark.skipif(bool(_QUANT_ACT_SKIP), reason=_QUANT_ACT_SKIP or "standalone quantizer unavailable") + def test_perf_act_quant_wg_sweep_long_seq(self, request): + """Sweep the quantizer's work-group shape at the 8K-prompt routing. + + The point where the pass is most expensive in absolute terms and + least likely to be helped: at 65536 routed rows it is already + streaming enough to sit against the device copy probe, so its cost + is the byte count, not the dispatch. That makes this the row that + decides whether the whole line of attack is dead -- and it is the + batch whose 28% quantization share every ceiling calculation for + the up projection was built on. + """ + rows = run_act_quant(batches=_long_seq_batches(), models=_models_option(request)) + assert rows and all(r["quant_ms"] > 0 for r in rows) + for row in rows: + assert row["identical"], ( + f"{row['label'].strip()}: {row['rows_per_wg']} rows/work-group changed the quantized " + f"activations at the long-sequence routing" + ) + def test_perf_prefill_store_sweep(self, request): """Time the D store width at the compute-bound batch. @@ -3109,6 +3398,109 @@ def test_act_quant_single_pass_matches(self): ark.moe_w4a8_release_scratch() _release_xpu_memory() + @pytest.mark.skipif(bool(_QUANT_ACT_SKIP), reason=_QUANT_ACT_SKIP or "standalone quantizer unavailable") + def test_act_quant_rows_per_wg_matches(self): + """Packing more token rows into a work-group must change nothing. + + ``ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG`` only widens the launch: + ``local{R, SG_SIZE}`` instead of ``local{1, SG_SIZE}``. SYCL + linearizes work-items with the last dimension fastest, so local id + ``(r, lane)`` is linear id ``r * SG_SIZE + lane`` and, under + ``reqd_sub_group_size(SG_SIZE)``, each sub-group still covers + exactly one token's lanes. The reduction is the same sub-group + reduce over the same values, and there is no barrier or SLM for a + wider group to interact through -- so the outputs must be equal bit + for bit. + + The row count is deliberately *not* a multiple of the largest rung: + the launch rounds the global range up to whole work-groups, so the + trailing rows exercise the ``token >= total_tokens`` guard. Without + it those threads would read and write past the end of the buffers. + """ + for nk_label, N, K in _QWEN3_NK: + case = _build_case( + N, + K, + _QWEN3_E, + _ACT_QUANT_RAGGED_TOKENS, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + total_tokens = case["total_tokens"] + assert total_tokens % max(_ACT_QUANT_WG_ROWS) != 0, ( + f"{nk_label.strip()}: {total_tokens} rows divides by {max(_ACT_QUANT_WG_ROWS)}, so the " + f"padding guard is never exercised -- pick a row count that does not" + ) + outs = {} + for wg in _ACT_QUANT_WG_ROWS: + with _env_override(ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=str(wg)): + qact, ascale = ark.moe_w4a8_quant_act(case["activations"]) + outs[wg] = (qact, ascale) + base_q, base_s = outs[_ACT_QUANT_WG_ROWS[0]] + for wg in _ACT_QUANT_WG_ROWS[1:]: + q, s = outs[wg] + assert torch.equal(base_q, q), ( + f"{nk_label.strip()} (K={K}): {wg} rows/work-group quantizes differently from 1: " + f"max |diff| {(base_q.float() - q.float()).abs().max().item():.6g}" + ) + assert torch.equal(base_s, s), ( + f"{nk_label.strip()} (K={K}): {wg} rows/work-group writes different row scales than 1: " + f"max |diff| {(base_s - s).abs().max().item():.6g}" + ) + case = outs = base_q = base_s = q = s = qact = ascale = None + _release_xpu_memory() + + @pytest.mark.skipif(bool(_QUANT_ACT_SKIP), reason=_QUANT_ACT_SKIP or "standalone quantizer unavailable") + def test_quant_act_matches_in_call_quant(self): + """The standalone quantizer must be the pass the call runs internally. + + Timing ``moe_w4a8_quant_act`` only says something about + ``moe_gemm_w4a8`` if it is the same work. It is the same launcher + behind the same parameter struct, and this pins that down from the + outside: feed its output back as the pre-quantized contract and the + GEMM has to produce bit-identical results to letting the call + quantize for itself. Any difference -- a different rounding, a + different scale convention, a missed row -- moves at least one + output element. + """ + for nk_label, N, K in _QWEN3_NK: + case = _build_case( + N, + K, + _QWEN3_E, + _PREFILL_BATCHES[0] * _QWEN3_TOPK, + _QWEN3_GROUP_SIZE, + torch.bfloat16, + need_reference=False, + need_dequant=False, + ) + weights_s8, wscales, block = ark.moe_w4a8_prepack( + case["packed"], case["scales"], group_size=_QWEN3_GROUP_SIZE + ) + in_call = _w4a8(case, weights_s8, wscales, block, "prefill").clone() + qact, ascale = ark.moe_w4a8_quant_act(case["activations"]) + standalone = ark.moe_gemm_w4a8( + qact, + weights_s8, + wscales, + case["ntpe"], + rescale_block_size=block, + phase="prefill", + activation_scale=ascale, + out_dtype=case["dtype"], + ) + assert torch.equal(in_call, standalone), ( + f"{nk_label.strip()} (K={K}): pre-quantizing with moe_w4a8_quant_act disagrees with the " + f"call's own quantization: max |diff| " + f"{(in_call.float() - standalone.float()).abs().max().item():.6g}" + ) + case = weights_s8 = wscales = in_call = standalone = qact = ascale = None + ark.clear_moe_w4a8_prepack_cache() + ark.moe_w4a8_release_scratch() + _release_xpu_memory() + def test_prefill_2d_store_matches_scalar(self): """The 2D block store must write exactly what the scalar store wrote. @@ -3441,6 +3833,18 @@ def _parse_args(argv): "at the large budget their smaller accumulator was supposed to avoid." ), ) + parser.add_argument( + "--act-quant-wg", + action="store_true", + help=( + "Also time the activation quantization pass on its own, one row per work-group shape " + "(1-16 token rows per group). Two answers in one table: what the pass actually costs -- " + "until now it was only ever differenced out of two whole-call timings -- and whether the " + "shipped one-thread-per-work-group launch is leaving the thread dispatcher idle. Pair " + "with --long-seq for the routing whose 28% quantization share the up-projection ceilings " + "were computed from." + ), + ) parser.add_argument("--iters", type=int, default=ITERS, help=f"Timed iterations per measurement (default {ITERS}).") parser.add_argument("--warmup", type=int, default=WARMUP, help=f"Warmup iterations (default {WARMUP}).") parser.add_argument( @@ -3562,6 +3966,15 @@ def main(argv=None) -> int: batches=_long_seq_batches(), ) + if phase == "prefill" and args.act_quant_wg: + if _QUANT_ACT_SKIP: + print(f"[moe-w4a8-perf] --act-quant-wg unavailable: {_QUANT_ACT_SKIP}") + else: + if not args.long_seq or args.compute_bound: + run_act_quant(dtype=dtype, models=models) + if args.long_seq: + run_act_quant(dtype=dtype, models=models, batches=_long_seq_batches()) + if failures: print() print("ACCURACY FAILURES:") From 4851405abafed036a63f13c2016e405a311535fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:42:22 +0000 Subject: [PATCH 109/112] docs: document the act-quant work-group knob and direct timing (EN + CN) Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 127 +++++++++++++++++- .../ark/test/README_MOE_W4A8_CN.md | 106 ++++++++++++++- 2 files changed, 231 insertions(+), 2 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index c79afaef17..167b7c9184 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -1024,6 +1024,18 @@ Helper: `ark.moe_w4a8_rescale_block_size(K, group_size, rescale_group_size)` resolves the effective block size (and therefore the `wscales` shape) without allocating anything. +`ark.moe_w4a8_quant_act(activations)` runs the call's own per-token +quantization on its own, returning the `(qact, ascale)` pair the contract above +accepts. Two uses: pre-quantizing once when the same rows go through several +calls, and *measuring* the pass — until it existed, its cost could only be +inferred by differencing two whole-call timings. See [The activation quantizer, +and the one shape nobody +swept](#the-activation-quantizer-and-the-one-shape-nobody-swept). + +```python +qact, ascale = ark.moe_w4a8_quant_act(activations) # [T, K] int8, [T] fp32 +``` + ## Memory cost The prepacked weights are `E × N × K` **bytes** (int8), i.e. **2× the packed @@ -1553,6 +1565,116 @@ pytest test_moe_w4a8_perf.py -k "grf" -v python test_moe_w4a8_perf.py --skip-accuracy --grf --long-seq --rounds 5 ``` +### The activation quantizer, and the one shape nobody swept + +The quantization pass is not a preamble. It reads `[T, K]` 16-bit activations, +writes `[T, K]` int8 and writes `[T]` fp32 scales, and at prefill sizes that is +a quarter to a third of everything `moe_gemm_w4a8` does. Three knobs have +already been swept and shipped on it — `ACT_QUANT_VEC` (bytes per lane, worth +1.04–1.13×), `ACT_QUANT_UNROLL` (loads in flight, 1.02–1.03×) and +`ACT_QUANT_SINGLE_PASS` (row held in registers, 1.00–1.06×). + +Every one of them tunes *what one thread does*. None of them changes *how many +threads are resident*, and the pass ships with a launch geometry that makes +that the obvious question. + +#### Measuring it, instead of differencing it + +The pass had no standalone entry point, so its cost was never measured — it was +inferred, by timing the same GEMM with 16-bit input and with int8 input and +subtracting. That is two whole-call timings differenced into a number a quarter +their size, carrying the run-to-run noise of both. Its share of the call, the +bandwidth it achieves, the ceiling a faster one could reach: all of it rests on +that difference. + +`ark.moe_w4a8_quant_act(activations)` makes the pass callable on its own. It is +the same launcher behind the same parameter struct the GEMM uses internally, and +it returns exactly the `(qact, ascale)` pair the pre-quantized contract accepts +— which is also how it is pinned down from the outside: +`test_quant_act_matches_in_call_quant` feeds the output back through the +contract and requires the GEMM result to be bit-identical to letting the call +quantize for itself. + +`run_act_quant` then prints the measured cost, the bandwidth it implies, and +the old differenced estimate side by side, so the soft number can finally be +checked against a hard one. + +The bandwidth column is the one to read first, because it decides whether any +of this is worth pursuing. The byte count is fixed by the call contract, not by +the kernel: 402.9 MB at 65536 rows and `K = 2048`. Against the earlier +differenced estimate of 0.818 ms that is ~493 GB/s — *above* this machine's own +device-copy probe, whose best of three rounds on B70 was 439 GB/s, and read +from a 268 MB buffer far too large to be cached. If the direct measurement +confirms it, the pass is not merely fast, it is the fastest sustained stream +anything in this project has produced on B70, and it *is* the roof. + +That bounds the whole line of attack before any tuning: + +| qwen3 up @ 65536 tokens | call | speedup | +|---|---|---| +| shipped | 2.871 ms | — | +| quantization made *free* | 2.053 ms | **1.40×** | +| quantization 25% faster (needs 616 GB/s) | 2.667 ms | 1.08× | +| quantization 10% faster | 2.789 ms | 1.03× | + +Deleting the pass entirely is worth 1.40× on the up projection; a realistic +in-kernel improvement is worth 3%. On the down projection the pass is 14.8% of +the call, so even a free one is 1.17×. The levers that actually move the byte +count are the call contracts — deduplicating the routed rows (**1.46×** measured +end to end) and taking int8 from the caller (**1.76× / 1.45×**) — and both are +in [What is left](#what-is-left), not here. + +#### The one shape nobody swept + +The pass launches `global{T, 16} local{1, 16}`: one sub-group, which is **one +hardware thread, per work-group**. 65536 routed rows are 65536 single-thread +work-groups. + +That is the worst dispatch shape on an Intel GPU. The thread dispatcher caps how +many work-groups can be resident on an Xe-core, and that cap is far below the +number of thread slots the Xe-core has — so at one thread per group the +work-group limit binds first and the slots go unfilled. Every other kernel in +the same header runs 256 work-items per work-group; the quantizer is the +outlier. The harness's own comments have been assuming a 1280-thread occupancy +ceiling that this geometry may never have been able to reach. + +`ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` widens it to `local{R, 16}` for +`R ∈ {1, 2, 4, 8, 16}`, with 16 rows landing on the same 256 work-items the +GEMMs use. Nothing else changes: + +* SYCL linearizes work-items with the last dimension fastest, so local id + `(r, lane)` is linear id `r × 16 + lane`. Under `reqd_sub_group_size(16)` each + sub-group therefore still covers exactly one token's 16 lanes, and + `reduce_over_group(sg, …)` keeps its meaning. +* There is no barrier and no SLM in the kernel, so a wider group has nothing to + interact through. +* The global range rounds up to whole work-groups and the padding rows exit on a + `token >= total_tokens` guard, which is sub-group-uniform because all 16 lanes + of a sub-group share one token — what a sub-group collective requires. +* `R` reaches `nd_range` and nothing else, so the rungs cost no extra kernel + instantiations in a translation unit that already builds 11. + +The output is therefore bit-identical, which +`test_act_quant_rows_per_wg_matches` asserts with `torch.equal` on both the int8 +rows and the fp32 scales, at a row count deliberately chosen not to divide by 16 +so the padding guard is live rather than dead code. + +**Default `1` — today's shape — until B70 says otherwise.** The per-policy GRF +budget above is the standing reminder of what shipping an unmeasured default +costs. + +And the expectation is deliberately low. At 65536 rows the pass is already +against the probe, and a kernel on the roof has nothing to give; the arithmetic +above caps even a *free* quantizer at 1.40×. The room, if there is any, is at +the short batch: 8192 rows read ~370 GB/s, 75% of the same probe, and 8× the +rows cost only 6.0× the time — so it is short prompts and decode that are +under-fed, not the 8K prefill this was asked about. + +```bash +pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s +python test_moe_w4a8_perf.py --skip-accuracy --act-quant-wg --long-seq --rounds 5 +``` + ### What is left With the prologue ruled out by measurement and the tile ladder already swept, @@ -1706,6 +1828,7 @@ analysis above is why. | `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.04–1.13× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default, measured fastest). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized *two-pass* mapping: the single-pass kernel below issues the whole row at once and ignores this. | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | +| `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` | Token rows per work-group in the activation quantizer: `1` (default, the shipped shape), `2`, `4`, `8` or `16`. The pass launches one sub-group — i.e. **one hardware thread** — per work-group, which is the one dimension of it that has never been swept: `VEC`, `UNROLL` and `SINGLE_PASS` all tune what a thread does, none changes how many are resident, and Intel's dispatcher caps resident work-groups per Xe-core well below its thread slots. `16` rows is 256 work-items, the work-group size every GEMM in the same header uses. Bit-identical at every rung (`test_act_quant_rows_per_wg_matches`, on a row count that exercises the padding guard); `test_perf_act_quant_wg_sweep{,_long_seq}` times them. **Not yet measured on hardware**, hence the default of `1`. Values outside `{1, 2, 4, 8, 16}` fall back to the default. See [The activation quantizer, and the one shape nobody swept](#the-activation-quantizer-and-the-one-shape-nobody-swept). | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no). | @@ -1778,7 +1901,8 @@ the only routing where a 256-row tile pads no more than a 128-row one — the Three prefill changes used to be listed here as reasoned-through but unmeasured, because the authoring environment has no XPU and no SYCL compiler. All three -have now been timed twice, and all three kept their default: +have now been timed twice, and all three kept their default. The last row is the +one still in that state: | Change | Revert with | Measured | |---|---|---| @@ -1787,6 +1911,7 @@ have now been timed twice, and all three kept their default: | 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×, the largest single prefill win | | Tile claim issued before the GEMM instead of after it, so the work counter's device-scope atomic overlaps the mainloop | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **A tie** — 1.00× on both shapes at both batches, inside drift, sign flips between runs; kept because it is free and contention scales with Xe-core count | | Register budget made a property of the tile policy (`kSmallGrfOk`), then measured and left **off** | already off — `ARK_MOE_W4A8_PREFILL_SMALL_GRF=1` asks for it | **0.63–0.86×, a clear loss.** Double occupancy at `grf_size<128>`, but a 128-wide-N accumulator is half the file and the mainloop's staged operands spill; the penalty scales with K, not with tiles | +| Activation quantizer's work-group shape — `R` token rows per group instead of one sub-group per group | already off — `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=1` is the shipped shape | **Not yet measured.** Shipped at the default, so this row is a sweep point, not a change; the pass is already near the device copy probe at long prefill, so the expectation is short prompts and decode, not 8K | The 2D store was previously listed as needing a device rather than a flag, on the grounds that the sibling MoE kernels reach it through diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 40f07d5dcb..cb8232d1bd 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -892,6 +892,15 @@ ark.moe_w4a8_release_scratch() # 归还设备端 scratch 内存 辅助函数:`ark.moe_w4a8_rescale_block_size(K, group_size, rescale_group_size)` 可以在不做任何分配的情况下解析出有效的 block 大小 (即 `wscales` 的形状)。 +`ark.moe_w4a8_quant_act(activations)` 单独跑调用内部那一趟 per-token 量化,返回上面 +那条契约所接受的 `(qact, ascale)` 二元组。它有两个用途:同一批行要过好几次调用时只 +量化一次,以及**测量**这一趟 —— 在它出现之前,这一趟的开销只能靠两次整调用计时相减 +推出来。参见[激活量化 kernel,以及那个从没有人扫过的形状](#激活量化-kernel以及那个从没有人扫过的形状)。 + +```python +qact, ascale = ark.moe_w4a8_quant_act(activations) # [T, K] int8, [T] fp32 +``` + ## 内存开销 预处理后的权重为 `E × N × K` **字节** (int8),即打包 int4 权重的 **2 倍**: @@ -1348,6 +1357,99 @@ pytest test_moe_w4a8_perf.py -k "grf" -v python test_moe_w4a8_perf.py --skip-accuracy --grf --long-seq --rounds 5 ``` +### 激活量化 kernel,以及那个从没有人扫过的形状 + +量化这一趟并不是什么前置小活。它读 `[T, K]` 的 16-bit 激活,写 `[T, K]` 的 int8, +再写 `[T]` 的 fp32 scale;在 prefill 的规模下,这占了 `moe_gemm_w4a8` 整个调用的 +四分之一到三分之一。围绕它已经扫过并落地了三个旋钮 —— `ACT_QUANT_VEC` +(每 lane 处理多少字节,值 1.04–1.13×)、`ACT_QUANT_UNROLL` (在飞的 load 数, +1.02–1.03×) 和 `ACT_QUANT_SINGLE_PASS` (整行留在寄存器里,1.00–1.06×)。 + +它们调的全都是**一个线程做什么**。没有一个动过**有多少线程常驻**,而这一趟的 +launch 形状恰好把这件事摆成了最显眼的问题。 + +#### 直接测它,而不是靠相减推它 + +这一趟此前没有独立的入口,所以它的开销从来没被测过,只是被**推**出来的:用 +16-bit 输入和 int8 输入各跑一遍同一个 GEMM,然后相减。那是拿两个整调用的计时去 +减出一个只有它们四分之一大小的数,两边的抖动都被带了进来。它占调用的比例、它达到 +的带宽、一个更快的版本能有多少上限 —— 全都建立在这个差值上。 + +`ark.moe_w4a8_quant_act(activations)` 让这一趟可以被单独调用。它走的是 GEMM 内部 +用的同一个 launcher、同一个参数结构体,返回的正是预量化契约所接受的 +`(qact, ascale)` 二元组 —— 这也正是它从外部被钉死的方式: +`test_quant_act_matches_in_call_quant` 把它的输出喂回那条契约,要求 GEMM 的结果与 +让调用自己去量化时逐位相同。 + +`run_act_quant` 随后会把实测开销、由此反推的带宽、以及旧的相减估计并排打印出来, +好让这个软数字终于能对上一个硬数字。 + +带宽那一列是要先看的,因为它决定了这整条路线值不值得走。字节数是由调用契约定死 +的,不是由 kernel 定的:65536 行、`K = 2048` 时是 402.9 MB。对上此前相减得到的 +0.818 ms,就是约 493 GB/s —— **高过这台机器自己的 device copy 探针**,后者在 B70 +上三轮里最好的一次是 439 GB/s;而且它读的是一个 268 MB 的缓冲区,大到根本不可能 +靠缓存作弊。如果直接测量证实了这个数,那这一趟就不只是快,它是这个项目在 B70 上 +跑出过的最快持续流带宽 —— 它本身**就是那根屋顶**。 + +这在做任何调优之前就把整条路线的上限框死了: + +| qwen3 up @ 65536 tokens | 调用耗时 | 加速比 | +|---|---|---| +| 当前实现 | 2.871 ms | —— | +| 量化完全**免费** | 2.053 ms | **1.40×** | +| 量化快 25% (需要 616 GB/s) | 2.667 ms | 1.08× | +| 量化快 10% | 2.789 ms | 1.03× | + +把这一趟整个删掉,在 up 投影上值 1.40×;而一个现实的 kernel 内部改进值 3%。在 +down 投影上这一趟只占调用的 14.8%,所以即便完全免费也只有 1.17×。真正能改变字节 +数的杠杆是调用契约 —— 对路由后的行去重 (端到端**实测 1.46×**) 和由 caller 直接给 +int8 (**实测 1.76× / 1.45×**) —— 这两条都在[还剩下什么](#还剩下什么)那一节,不在 +这里。 + +#### 那个从没有人扫过的形状 + +这一趟的 launch 是 `global{T, 16} local{1, 16}`:每个 work-group 一个 sub-group, +也就是**一个硬件线程**。65536 条路由行就是 65536 个单线程 work-group。 + +这是 Intel GPU 上最差的 dispatch 形状。线程分派器对每个 Xe-core 上能常驻多少个 +work-group 有一个上限,而这个上限远低于 Xe-core 的线程槽数 —— 于是在一个 group +一个线程时,先撞上的是 work-group 上限,线程槽根本填不满。同一个头文件里其他所有 +kernel 每个 work-group 都是 256 个 work-item;量化 kernel 是唯一的异类。harness +自己的注释一直假设着一个 1280 线程的 occupancy 天花板,而这个形状可能从来就够不到。 + +`ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` 把它放宽成 `local{R, 16}`, +`R ∈ {1, 2, 4, 8, 16}`,其中 16 行正好落在 GEMM 所用的那 256 个 work-item 上。 +其他什么都不变: + +* SYCL 对 work-item 的线性化是**最后一维最快**,所以 local id `(r, lane)` 的线性 id + 就是 `r × 16 + lane`。在 `reqd_sub_group_size(16)` 下,每个 sub-group 因此仍然 + 恰好覆盖一个 token 的 16 个 lane,`reduce_over_group(sg, …)` 的语义保持不变。 +* kernel 里没有 barrier 也没有 SLM,所以放大 work-group 之后它们之间没有任何可以 + 互相影响的东西。 +* 全局范围会向上取整到整数个 work-group,多出来的行由 `token >= total_tokens` 这句 + 守卫提前返回;因为一个 sub-group 的 16 个 lane 共享同一个 token,这个条件是 + **sub-group 内一致**的 —— 这正是 sub-group 集合操作所要求的。 +* `R` 只传到 `nd_range`,不会进入 kernel 体,所以这些档位在一个已经要编 11 个 + kernel 的翻译单元里**不增加任何实例化**。 + +因此输出必然逐位相同,`test_act_quant_rows_per_wg_matches` 会用 `torch.equal` 对 +int8 行和 fp32 scale 两者都断言这一点,并且刻意选了一个不能被 16 整除的行数,好让 +那句补齐守卫是活代码而不是死代码。 + +**默认值是 `1` —— 也就是今天的形状 —— 在 B70 给出结论之前不变。** 上面那个 +per-policy GRF 预算就是"默认打开一个没测过的东西"要付什么代价的现成教训。 + +而且期望值是刻意压低的。在 65536 行上这一趟已经贴着探针了,一个已经在屋顶上的 +kernel 没有东西可以再给;上面的算术连一个**免费**的量化也只封顶到 1.40×。真要有 +空间,是在小 batch 上:8192 行只跑出约 370 GB/s,是同一个探针的 75%,而 8 倍的行 +数只花了 6.0 倍的时间 —— 也就是说没喂饱的是**短 prompt 和 decode**,不是这次被问 +到的 8K prefill。 + +```bash +pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s +python test_moe_w4a8_perf.py --skip-accuracy --act-quant-wg --long-seq --rounds 5 +``` + ### 还剩下什么 在 prologue 已被实测排除、tile 阶梯也已扫描过之后,剩下的差距在于流量,而且它的 @@ -1478,6 +1580,7 @@ epilogue 做这件事比单独一遍做得更差。 | `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.04–1.13× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认,实测最快)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化的**两遍**映射生效:下面的单遍 kernel 一次性发出整行,会忽略这个开关。 | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | +| `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` | 激活量化 kernel 里每个 work-group 处理多少个 token 行:`1` (默认,即当前形状)、`2`、`4`、`8` 或 `16`。这一趟的 launch 是每个 work-group 一个 sub-group —— 也就是**一个硬件线程** —— 而这是它唯一从没被扫过的维度:`VEC`、`UNROLL`、`SINGLE_PASS` 调的都是一个线程做什么,没有一个动过有多少线程常驻,而 Intel 的分派器对每个 Xe-core 常驻的 work-group 数的上限远低于它的线程槽数。`16` 行就是 256 个 work-item,也就是同一个头文件里每个 GEMM 所用的 work-group 大小。每一档都逐位相同 (`test_act_quant_rows_per_wg_matches`,用的行数会触发补齐守卫);`test_perf_act_quant_wg_sweep{,_long_seq}` 会对它们计时。**尚未在硬件上实测**,所以默认是 `1`。取值不在 `{1, 2, 4, 8, 16}` 内时回退到默认值。参见[激活量化 kernel,以及那个从没有人扫过的形状](#激活量化-kernel以及那个从没有人扫过的形状)。 | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的)。 | @@ -1544,7 +1647,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 本节此前列有三项"只经过推导、既未实测计时也尚未在设备上运行"的 prefill 改动,因为编写 它们的环境既没有 XPU 也没有 SYCL 编译器。这三项现在都已实测两次,并且都保持了原有默认 -值: +值。最后一行是仍处在那个状态里的: | 改动 | 回退方式 | 实测结果 | |---|---|---| @@ -1553,6 +1656,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 | D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×,prefill 单项收益最大 | | tile 的领取改到 GEMM 之前而不是之后,让工作计数器的 device-scope atomic 与 mainloop 重叠 | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **打平**——两个形状、两个 batch 全是 1.00×,落在 drift 之内,两次运行之间符号还翻转;保留是因为它免费,且竞争程度随 Xe core 数量增长 | | 把寄存器预算改成 tile policy 的属性 (`kSmallGrfOk`),实测后**保持关闭** | 已经是关闭的 —— `ARK_MOE_W4A8_PREFILL_SMALL_GRF=1` 才会去申请 | **0.63–0.86×,明确的负结果。** `grf_size<128>` 下 occupancy 确实翻倍,但 N 宽 128 的累加器就占掉半个文件,mainloop 暂存的操作数会 spill;代价按 K 而不是按 tile 数增长 | +| 激活量化 kernel 的 work-group 形状 —— 每个 group `R` 个 token 行,而不是一个 sub-group 一个 group | 已经是关闭的 —— `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=1` 就是当前形状 | **尚未实测。** 按默认值落地,所以这一行是一个待扫描点、而不是一项改动;在长 prefill 上这一趟已经贴着 device copy 探针了,所以预期收益在短 prompt 和 decode,而不是 8K | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** From e43347180a2462978781d3ce8dc746d6940cfa83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:06:54 +0000 Subject: [PATCH 110/112] fix: cost the dedup path with the direct quant measurement, not differencing Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 135 +++++++++++++----- .../ark/test/README_MOE_W4A8_CN.md | 109 ++++++++++---- .../ark/test/test_moe_w4a8_perf.py | 74 +++++++--- 3 files changed, 241 insertions(+), 77 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 167b7c9184..0db6a75a84 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -455,6 +455,14 @@ on B70 at the qwen3 up-projection shape with an 8K prompt: | dedup, torch quant | 0.468 (int8) | 1.558 (torch, 8192 rows) | 2.033 | 4.059 | **0.95x** | | dedup, fused quant | 0.468 (int8) | 0.136 (fused, 8192 rows) | 2.033 | **2.637** | **1.46x** | +Both `quant` figures for the fused quantizer in that table were obtained by +differencing two whole-call timings, which is [since known to read high at small +row counts](#measuring-it-instead-of-differencing-it): the 8192-row entry is the +inflated one, and the direct measurement puts the pass at ~0.10 ms there rather +than 0.136. The correction moves the bottom row *down*, so 1.46x is a floor on +this comparison, not a ceiling; `run_dedup_quant` now costs itself with the +direct measurement and prints the old difference beside it. + The traffic model above predicted 1.53x and the device returned 1.46x, so the byte count is what is driving this. Against W4A16 on the same end-to-end basis (its GEMM measured 3.679 ms, and it permutes 16-bit) the deduplicated path is @@ -1584,7 +1592,7 @@ The pass had no standalone entry point, so its cost was never measured — it wa inferred, by timing the same GEMM with 16-bit input and with int8 input and subtracting. That is two whole-call timings differenced into a number a quarter their size, carrying the run-to-run noise of both. Its share of the call, the -bandwidth it achieves, the ceiling a faster one could reach: all of it rests on +bandwidth it achieves, the ceiling a faster one could reach: all of it rested on that difference. `ark.moe_w4a8_quant_act(activations)` makes the pass callable on its own. It is @@ -1593,33 +1601,61 @@ it returns exactly the `(qact, ascale)` pair the pre-quantized contract accepts — which is also how it is pinned down from the outside: `test_quant_act_matches_in_call_quant` feeds the output back through the contract and requires the GEMM result to be bit-identical to letting the call -quantize for itself. +quantize for itself. `run_act_quant` prints the measured cost next to the old +differenced estimate. + +**At long prefill the estimate was right, and at short row counts it was not:** -`run_act_quant` then prints the measured cost, the bandwidth it implies, and -the old differenced estimate side by side, so the soft number can finally be -checked against a hard one. +| shape | rows | measured | differenced | | +|---|---|---|---|---| +| qwen3 up | 65536 | 0.810 ms | 0.808 ms | agree | +| qwen3 down | 65536 | 0.284 ms | 0.303 ms | 7% apart | +| qwen3 up | 1024 | 0.025 ms | 0.185 ms | **7.4× apart** | +| qwen3 down | 1024 | 0.031 ms | 0.125 ms | **4.0× apart** | + +The failure mode is exactly the one differencing is prone to. At 1024 routed +rows the call is dominated by the 402 MB of weights it streams, the pass is 2–4% +of it, and the difference between two ~1 ms timings is smaller than their own +drift. At 65536 rows the pass is a quarter of the call and the same subtraction +is sound. So every long-prefill figure quoted from the old estimate survives — +and every small-row one derived from it does not. + +That is not a footnote, because one such figure was load-bearing: the +deduplicated path is costed at `batch` rows, and its `top_k` linearity check had +been reading **6.0× for an 8× row count**. That was written up as the short row +count being under-fed. It was not: it was the smaller difference being +inflated. Corrected to the direct measurement the pass is linear in rows, as a +streaming kernel must be, and `run_dedup_quant` now costs itself that way. + +#### It is on the roof, and the roof is above the probe + +The byte count is fixed by the call contract, not by the kernel: 402.9 MB at +65536 rows and `K = 2048`. Measured directly, the pass moves it at: + +| shape | rows | bytes | measured | achieved | vs device-copy probe | +|---|---|---|---|---|---| +| qwen3 up | 65536 | 402.9 MB | 0.810 ms | **497 GB/s** | 133% | +| qwen3 down | 65536 | 151.3 MB | 0.284 ms | **533 GB/s** | 142% | -The bandwidth column is the one to read first, because it decides whether any -of this is worth pursuing. The byte count is fixed by the call contract, not by -the kernel: 402.9 MB at 65536 rows and `K = 2048`. Against the earlier -differenced estimate of 0.818 ms that is ~493 GB/s — *above* this machine's own -device-copy probe, whose best of three rounds on B70 was 439 GB/s, and read -from a 268 MB buffer far too large to be cached. If the direct measurement -confirms it, the pass is not merely fast, it is the fastest sustained stream -anything in this project has produced on B70, and it *is* the roof. +Both are *above* this machine's own device-copy probe, from a 268 MB buffer far +too large to be cached. The quantizer is the fastest sustained stream anything +in this project has produced on B70 — it does not sit under the roof, it **is** +the roof, and the probe is once again shown to be a lower bound rather than a +ceiling (see [The bandwidth probe was +lying](#the-bandwidth-probe-was-lying-and-it-mattered)). -That bounds the whole line of attack before any tuning: +Which bounds the whole line of attack, now from measured numbers: | qwen3 up @ 65536 tokens | call | speedup | |---|---|---| -| shipped | 2.871 ms | — | -| quantization made *free* | 2.053 ms | **1.40×** | -| quantization 25% faster (needs 616 GB/s) | 2.667 ms | 1.08× | -| quantization 10% faster | 2.789 ms | 1.03× | - -Deleting the pass entirely is worth 1.40× on the up projection; a realistic -in-kernel improvement is worth 3%. On the down projection the pass is 14.8% of -the call, so even a free one is 1.17×. The levers that actually move the byte +| shipped | 3.024 ms | — | +| quantization made *free* | 2.214 ms | **1.37×** | +| quantization 25% faster (needs 663 GB/s) | 2.822 ms | 1.07× | +| quantization 10% faster | 2.943 ms | 1.03× | + +Deleting the pass entirely is worth 1.37× on the up projection; a realistic +in-kernel improvement is worth 3%. On the down projection the pass is 12.6% of +the call, so even a free one is 1.14×. The levers that actually move the byte count are the call contracts — deduplicating the routed rows (**1.46×** measured end to end) and taking int8 from the caller (**1.76× / 1.45×**) — and both are in [What is left](#what-is-left), not here. @@ -1659,16 +1695,45 @@ The output is therefore bit-identical, which rows and the fp32 scales, at a row count deliberately chosen not to divide by 16 so the padding guard is live rather than dead code. -**Default `1` — today's shape — until B70 says otherwise.** The per-policy GRF -budget above is the standing reminder of what shipping an unmeasured default -costs. +#### The answer: it is flat, and the default stays at one row -And the expectation is deliberately low. At 65536 rows the pass is already -against the probe, and a kernel on the roof has nothing to give; the arithmetic -above caps even a *free* quantizer at 1.40×. The room, if there is any, is at -the short batch: 8192 rows read ~370 GB/s, 75% of the same probe, and 8× the -rows cost only 6.0× the time — so it is short prompts and decode that are -under-fed, not the 8K prefill this was asked about. +| qwen3 up @ 65536 | 1 row | 2 | 4 | 8 | 16 | +|---|---|---|---|---|---| +| quant | 0.810 ms | 0.810 | 0.805 | 0.806 | 0.806 | +| vs 1 row | — | 1.00× | 1.01× | 1.01× | 1.01× | + +| qwen3 down @ 65536 | 1 row | 2 | 4 | 8 | 16 | +|---|---|---|---|---|---| +| quant | 0.284 ms | 0.284 | 0.285 | 0.286 | 0.285 | +| vs 1 row | — | 1.00× | 1.00× | 0.99× | 1.00× | + +Sixteen times the work-group size, and the pass does not move — 1.00–1.01× +across the whole range, on both shapes, every rung bit-identical. At 1024 rows +the rungs scatter 0.91–1.09× with no ordering, which is the same answer with a +worse signal-to-noise ratio. + +**The dispatcher argument was wrong, and the reason it was wrong is the roof.** +Occupancy buys latency hiding, and latency hiding is worth something only to a +kernel that is waiting. At 497–533 GB/s this one is not waiting for memory, it +*is* the memory system — so more threads in flight have nothing left to hide. +One work-group per hardware thread was never the constraint; the DRAM was. + +The small-row rungs fail for the opposite reason. At 1024 rows the pass costs +0.025 ms (up) and 0.031 ms (down) — note that down is *slower* while moving a +third of the bytes, which no bandwidth model produces. That is a fixed +~25–30 µs launch floor, not a stream, and occupancy does not move a floor +either. It does not matter: at that size the pass is 2–4% of the call. + +So the hypothesis this knob was built to test is refuted from both ends, and +usefully so — it was the last untested dimension of this kernel. The flag stays +because it costs literally nothing (`R` reaches `nd_range` and no kernel is +instantiated per rung), because the sweep is now a regression guard, and because +`test_act_quant_rows_per_wg_matches` is a real correctness test of the padding +guard. **The default stays at `1`.** + +This also retires the "short prompts are under-fed" reading that the 8192-row +estimate had suggested: corrected, that row count streams at the same ~500 GB/s +as every other, and the pass is linear in rows throughout. ```bash pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s @@ -1828,7 +1893,7 @@ analysis above is why. | `ARK_MOE_W4A8_ACT_QUANT_VEC` | Vectorized per-token activation quantization (each lane owns 4 or 8 consecutive K elements instead of striding by the sub-group width); **on by default**, worth 1.04–1.13× on the swept shapes. Set to `0` to force the scalar mapping for A/B measurement. Ignored when K or the buffer alignment doesn't qualify, in which case the scalar kernel runs anyway. | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | Vectors the activation quantizer loads before it consumes any of them: `1`, `2` or `4` (default, measured fastest). Higher values raise the bytes a work-item keeps in flight — the pass is latency-bound, not bandwidth-bound, at one outstanding load per thread — at the cost of GRF. `1` is the kernel as it was before the batching, so it is the A/B baseline; every value is bit-identical. Values outside `{1, 2, 4}` fall back to the default. Only applies to the vectorized *two-pass* mapping: the single-pass kernel below issues the whole row at once and ignores this. | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | Keep the activation row in registers between the absmax and the quantize pass instead of reading `[T, K]` twice; **on by default** where the row fits (`K ≤ 2048` at `VEC = 8`, 64 of the 128 dwords a lane gets), worth 1.00–1.05× on the shapes that qualify. Set to `0` to force the two-pass kernel, which is also what runs for longer rows. Bit-identical to it. | -| `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` | Token rows per work-group in the activation quantizer: `1` (default, the shipped shape), `2`, `4`, `8` or `16`. The pass launches one sub-group — i.e. **one hardware thread** — per work-group, which is the one dimension of it that has never been swept: `VEC`, `UNROLL` and `SINGLE_PASS` all tune what a thread does, none changes how many are resident, and Intel's dispatcher caps resident work-groups per Xe-core well below its thread slots. `16` rows is 256 work-items, the work-group size every GEMM in the same header uses. Bit-identical at every rung (`test_act_quant_rows_per_wg_matches`, on a row count that exercises the padding guard); `test_perf_act_quant_wg_sweep{,_long_seq}` times them. **Not yet measured on hardware**, hence the default of `1`. Values outside `{1, 2, 4, 8, 16}` fall back to the default. See [The activation quantizer, and the one shape nobody swept](#the-activation-quantizer-and-the-one-shape-nobody-swept). | +| `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` | Token rows per work-group in the activation quantizer: `1` (default, the shipped shape), `2`, `4`, `8` or `16`. The pass launches one sub-group — i.e. **one hardware thread** — per work-group, which is the one dimension of it that has never been swept: `VEC`, `UNROLL` and `SINGLE_PASS` all tune what a thread does, none changes how many are resident, and Intel's dispatcher caps resident work-groups per Xe-core well below its thread slots. `16` rows is 256 work-items, the work-group size every GEMM in the same header uses. Bit-identical at every rung (`test_act_quant_rows_per_wg_matches`, on a row count that exercises the padding guard); `test_perf_act_quant_wg_sweep{,_long_seq}` times them. **Measured on B70 and it is flat**: 1.00-1.01x across the whole range at 65536 rows on both shapes, and unordered noise at 1024 rows, so the default stays `1`. Occupancy buys latency hiding and this pass is not latency-bound -- it streams 497-533 GB/s, above the device-copy probe. Kept because it costs nothing (`R` reaches `nd_range` only, so no rung adds a kernel instantiation) and the sweep is now a regression guard. Values outside `{1, 2, 4, 8, 16}` fall back to the default. See [The activation quantizer, and the one shape nobody swept](#the-activation-quantizer-and-the-one-shape-nobody-swept). | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | Skip the epilogue's store predicate and scale-index clamps on tiles that touch neither the M nor the N edge; **on by default**, worth up to 1.08× on the swept shapes (and never more than 0.9% behind). The choice is uniform across the work-group, so it costs one comparison per tile instead of several per output element. Set to `0` to force the guarded epilogue everywhere (the two must be bit-identical). | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | Write D through the hardware 2D block store instead of one scalar 32-byte message per fragment element; **on by default** where the output is aligned (`N × sizeof(ElementD) % 64 == 0`, true for every shipped shape), and the largest single prefill win of the set at 1.12–1.35×. Set to `0` to force the scalar store, which is also what runs for shapes that miss the alignment gate. Bit-identical to it. Automatically off when the fused top-k reduction is used, which scatters and therefore cannot use a block store. | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | How many k-tiles ahead the prefill mainloop prefetches A and B: `1`–`8`, default `3`. Deeper prefetch hides more DRAM latency at the cost of GRF and of a longer prologue, which matters most on short mainloops (`qwen3 down` has only 12 k-tiles per tile). Every value is bit-identical; `test_perf_prefill_prefetch_sweep` (compute-bound batch) and `test_perf_prefill_prefetch_sweep_long_seq` (8K-prompt routing) time the whole `1 / 2 / 3 / 4 / 6 / 8` range. Values outside `1`–`8` fall back to the default. The sweep found the ranking flat, so the default stays — see [Prefetch depth and K](#prefetch-depth-and-k--measured-twice-and-the-answer-is-no). | @@ -1901,8 +1966,8 @@ the only routing where a 256-row tile pads no more than a 128-row one — the Three prefill changes used to be listed here as reasoned-through but unmeasured, because the authoring environment has no XPU and no SYCL compiler. All three -have now been timed twice, and all three kept their default. The last row is the -one still in that state: +have now been timed twice, and all three kept their default. So have the two +added since, and every one of the five kept its default: | Change | Revert with | Measured | |---|---|---| @@ -1911,7 +1976,7 @@ one still in that state: | 2D block store for D — a handful of block messages instead of 64 scalar 32-byte ones per sub-group fragment | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×, the largest single prefill win | | Tile claim issued before the GEMM instead of after it, so the work counter's device-scope atomic overlaps the mainloop | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **A tie** — 1.00× on both shapes at both batches, inside drift, sign flips between runs; kept because it is free and contention scales with Xe-core count | | Register budget made a property of the tile policy (`kSmallGrfOk`), then measured and left **off** | already off — `ARK_MOE_W4A8_PREFILL_SMALL_GRF=1` asks for it | **0.63–0.86×, a clear loss.** Double occupancy at `grf_size<128>`, but a 128-wide-N accumulator is half the file and the mainloop's staged operands spill; the penalty scales with K, not with tiles | -| Activation quantizer's work-group shape — `R` token rows per group instead of one sub-group per group | already off — `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=1` is the shipped shape | **Not yet measured.** Shipped at the default, so this row is a sweep point, not a change; the pass is already near the device copy probe at long prefill, so the expectation is short prompts and decode, not 8K | +| Activation quantizer's work-group shape — `R` token rows per group instead of one sub-group per group | already off — `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=1` is the shipped shape | **1.00–1.01×, flat.** Sixteen times the work-group size changes nothing: the pass streams 497–533 GB/s, above the device-copy probe, so there is no latency left for occupancy to hide. Default unchanged | The 2D store was previously listed as needing a device rather than a flag, on the grounds that the sibling MoE kernels reach it through diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index cb8232d1bd..6f51a71792 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -392,6 +392,12 @@ permute 减半,因为 permute 现在每个元素只搬 1 字节而不是 2 字 | 去重 + torch 量化 | 0.468(int8) | 1.558(torch,8192 行) | 2.033 | 4.059 | **0.95x** | | 去重 + 融合量化 | 0.468(int8) | 0.136(融合,8192 行) | 2.033 | **2.637** | **1.46x** | +上表中融合量化器的两个 `quant` 数字都是由两次整调用计时相减得到的,而这个方法 +[现已知道在小行数上会读高](#直接测它而不是靠相减推它):8192 行那一项就是被抬高的那个, +直接测量给出的是约 0.10 ms 而不是 0.136。这个修正只会把最后一行**往下**推,所以 +1.46x 是这个对比的下界而不是上界;`run_dedup_quant` 现在按直接测量给自己计价,并把 +旧的差值并排打印出来。 + 上面的流量模型预测 1.53x,设备返回 1.46x,说明推动这个结果的确实是字节数。以同样的 端到端口径对比 W4A16(其 GEMM 实测 3.679 ms,且它 permute 的是 16-bit),去重路径是 **1.78x**。 @@ -1379,29 +1385,54 @@ launch 形状恰好把这件事摆成了最显眼的问题。 用的同一个 launcher、同一个参数结构体,返回的正是预量化契约所接受的 `(qact, ascale)` 二元组 —— 这也正是它从外部被钉死的方式: `test_quant_act_matches_in_call_quant` 把它的输出喂回那条契约,要求 GEMM 的结果与 -让调用自己去量化时逐位相同。 +让调用自己去量化时逐位相同。`run_act_quant` 会把实测值与旧的相减估计并排打印。 + +**在长 prefill 上那个估计是对的,在小行数上则不是:** + +| 形状 | 行数 | 实测 | 相减估计 | | +|---|---|---|---|---| +| qwen3 up | 65536 | 0.810 ms | 0.808 ms | 一致 | +| qwen3 down | 65536 | 0.284 ms | 0.303 ms | 差 7% | +| qwen3 up | 1024 | 0.025 ms | 0.185 ms | **差 7.4 倍** | +| qwen3 down | 1024 | 0.031 ms | 0.125 ms | **差 4.0 倍** | + +失效的方式正是相减法最容易出问题的那一种。在 1024 条路由行时,调用被它要读的 +402 MB 权重主导,这一趟只占其中 2–4%,而两个约 1 ms 的计时相减,差值比它们自身的 +抖动还小。在 65536 行时这一趟占调用的四分之一,同样的相减就是可靠的。所以凡是从 +旧估计里引用的**长 prefill 数字都站得住,而由它推出的小行数结论都不成立**。 -`run_act_quant` 随后会把实测开销、由此反推的带宽、以及旧的相减估计并排打印出来, -好让这个软数字终于能对上一个硬数字。 +这不是一个脚注,因为其中一个这样的数字是承重的:去重那条路径正是按 `batch` 行来 +计价的,而它的 `top_k` 线性度自检一直读出 **8 倍行数只花了 6.0 倍时间**。这曾被写成 +"小行数没被喂饱"。并不是:是那个较小的差值被抬高了。改用直接测量之后,这一趟就是 +按行数线性的 —— 一个流式 kernel 本来就必须如此 —— `run_dedup_quant` 现在也按这个 +口径给自己计价。 + +#### 它就在屋顶上,而这个屋顶高过探针 + +字节数是由调用契约定死的,不是由 kernel 定的:65536 行、`K = 2048` 时是 402.9 MB。 +直接测量下,这一趟搬运它的速度是: + +| 形状 | 行数 | 字节 | 实测 | 达成带宽 | 对比 device copy 探针 | +|---|---|---|---|---|---| +| qwen3 up | 65536 | 402.9 MB | 0.810 ms | **497 GB/s** | 133% | +| qwen3 down | 65536 | 151.3 MB | 0.284 ms | **533 GB/s** | 142% | -带宽那一列是要先看的,因为它决定了这整条路线值不值得走。字节数是由调用契约定死 -的,不是由 kernel 定的:65536 行、`K = 2048` 时是 402.9 MB。对上此前相减得到的 -0.818 ms,就是约 493 GB/s —— **高过这台机器自己的 device copy 探针**,后者在 B70 -上三轮里最好的一次是 439 GB/s;而且它读的是一个 268 MB 的缓冲区,大到根本不可能 -靠缓存作弊。如果直接测量证实了这个数,那这一趟就不只是快,它是这个项目在 B70 上 -跑出过的最快持续流带宽 —— 它本身**就是那根屋顶**。 +两者都**高过**这台机器自己的 device copy 探针,而且读的是一个 268 MB、大到根本不可能 +被缓存的缓冲区。这个量化 kernel 是本项目在 B70 上跑出过的最快持续流带宽 —— 它不是 +待在屋顶下面,它**就是**那个屋顶;而探针再一次被证明只是一个下界、而不是上限 +(参见[带宽探针在说谎,而且这是有后果的](#带宽探针在说谎而且这是有后果的))。 -这在做任何调优之前就把整条路线的上限框死了: +这就把整条路线的上限框死了,而且这次是用实测数字: | qwen3 up @ 65536 tokens | 调用耗时 | 加速比 | |---|---|---| -| 当前实现 | 2.871 ms | —— | -| 量化完全**免费** | 2.053 ms | **1.40×** | -| 量化快 25% (需要 616 GB/s) | 2.667 ms | 1.08× | -| 量化快 10% | 2.789 ms | 1.03× | +| 当前实现 | 3.024 ms | —— | +| 量化完全**免费** | 2.214 ms | **1.37×** | +| 量化快 25% (需要 663 GB/s) | 2.822 ms | 1.07× | +| 量化快 10% | 2.943 ms | 1.03× | -把这一趟整个删掉,在 up 投影上值 1.40×;而一个现实的 kernel 内部改进值 3%。在 -down 投影上这一趟只占调用的 14.8%,所以即便完全免费也只有 1.17×。真正能改变字节 +把这一趟整个删掉,在 up 投影上值 1.37×;而一个现实的 kernel 内部改进值 3%。在 +down 投影上这一趟只占调用的 12.6%,所以即便完全免费也只有 1.14×。真正能改变字节 数的杠杆是调用契约 —— 对路由后的行去重 (端到端**实测 1.46×**) 和由 caller 直接给 int8 (**实测 1.76× / 1.45×**) —— 这两条都在[还剩下什么](#还剩下什么)那一节,不在 这里。 @@ -1436,14 +1467,40 @@ kernel 每个 work-group 都是 256 个 work-item;量化 kernel 是唯一的 int8 行和 fp32 scale 两者都断言这一点,并且刻意选了一个不能被 16 整除的行数,好让 那句补齐守卫是活代码而不是死代码。 -**默认值是 `1` —— 也就是今天的形状 —— 在 B70 给出结论之前不变。** 上面那个 -per-policy GRF 预算就是"默认打开一个没测过的东西"要付什么代价的现成教训。 +#### 答案:完全打平,默认值保持一行 + +| qwen3 up @ 65536 | 1 行 | 2 | 4 | 8 | 16 | +|---|---|---|---|---|---| +| 量化 | 0.810 ms | 0.810 | 0.805 | 0.806 | 0.806 | +| 对比 1 行 | —— | 1.00× | 1.01× | 1.01× | 1.01× | + +| qwen3 down @ 65536 | 1 行 | 2 | 4 | 8 | 16 | +|---|---|---|---|---|---| +| 量化 | 0.284 ms | 0.284 | 0.285 | 0.286 | 0.285 | +| 对比 1 行 | —— | 1.00× | 1.00× | 0.99× | 1.00× | + +work-group 放大到 16 倍,这一趟纹丝不动 —— 整个范围内 1.00–1.01×,两个形状都一样, +每一档都逐位相同。在 1024 行时各档散布在 0.91–1.09× 且没有任何单调性,那是同一个 +答案,只是信噪比更差。 + +**分派器那个论证是错的,而它错的原因就是屋顶本身。** occupancy 买到的是延迟隐藏, +而延迟隐藏只对一个**在等待**的 kernel 有价值。在 497–533 GB/s 上,这个 kernel 并不 +在等内存,它**就是**内存系统 —— 于是更多在飞的线程已经没有任何东西可以再去隐藏。 +"每个 work-group 一个硬件线程"从来就不是瓶颈,DRAM 才是。 + +小行数那几档失败的原因恰好相反。1024 行时这一趟要 0.025 ms (up) 和 0.031 ms +(down) —— 注意 down 搬运的字节只有三分之一却**更慢**,这是任何带宽模型都给不出的 +结果。那是一个约 25–30 µs 的固定 launch 下限,而不是一条流;occupancy 同样搬不动 +一个下限。这也无所谓:在那个规模下这一趟只占调用的 2–4%。 + +所以这个旋钮要检验的假设从两头都被否证了,而且这是有价值的 —— 它是这个 kernel 最后 +一个没被测过的维度。开关保留下来,是因为它的代价确实为零 (`R` 只到 `nd_range`,没有 +哪一档会多实例化一个 kernel)、因为这个 sweep 现在是一个回归护栏、也因为 +`test_act_quant_rows_per_wg_matches` 是对补齐守卫的一个真正的正确性测试。 +**默认值保持 `1`。** -而且期望值是刻意压低的。在 65536 行上这一趟已经贴着探针了,一个已经在屋顶上的 -kernel 没有东西可以再给;上面的算术连一个**免费**的量化也只封顶到 1.40×。真要有 -空间,是在小 batch 上:8192 行只跑出约 370 GB/s,是同一个探针的 75%,而 8 倍的行 -数只花了 6.0 倍的时间 —— 也就是说没喂饱的是**短 prompt 和 decode**,不是这次被问 -到的 8K prefill。 +这同时也让"短 prompt 没被喂饱"那个由 8192 行估计得出的读法退场:修正之后,那个行数 +的流带宽和其他所有行数一样是约 500 GB/s,这一趟自始至终都是按行数线性的。 ```bash pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s @@ -1580,7 +1637,7 @@ epilogue 做这件事比单独一遍做得更差。 | `ARK_MOE_W4A8_ACT_QUANT_VEC` | 向量化的每 token 激活量化 (每个 lane 负责 4 或 8 个连续的 K 元素,而不是按 sub-group 宽度跨步);**默认开启**,在被扫描的形状上带来 1.04–1.13× 的收益。设为 `0` 可强制使用标量映射以便做 A/B 测量。当 K 或缓冲区对齐不满足条件时该开关被忽略,此时本就会运行标量 kernel。 | | `ARK_MOE_W4A8_ACT_QUANT_UNROLL` | 激活量化 kernel 在开始消费之前先加载的向量个数:`1`、`2` 或 `4` (默认,实测最快)。取值越大,一个 work-item 保持在途的字节越多——这一遍在每线程仅一条在途 load 时受限于延迟而非带宽——代价是 GRF 占用。`1` 即批量化之前的 kernel,可作为 A/B 基线;所有取值逐位相同。不在 `{1, 2, 4}` 中的取值会回退到默认值。只对向量化的**两遍**映射生效:下面的单遍 kernel 一次性发出整行,会忽略这个开关。 | | `ARK_MOE_W4A8_ACT_QUANT_SINGLE_PASS` | 在 absmax 与量化两步之间把激活行留在寄存器里,而不是把 `[T, K]` 读两遍;在行放得下时**默认开启** (`VEC = 8` 下 `K ≤ 2048`,占每 lane 128 个 dword 中的 64 个),在满足条件的形状上带来 1.00–1.05× 的收益。设为 `0` 可强制走两遍 kernel——更长的行本来也走它。两者逐位相同。 | -| `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` | 激活量化 kernel 里每个 work-group 处理多少个 token 行:`1` (默认,即当前形状)、`2`、`4`、`8` 或 `16`。这一趟的 launch 是每个 work-group 一个 sub-group —— 也就是**一个硬件线程** —— 而这是它唯一从没被扫过的维度:`VEC`、`UNROLL`、`SINGLE_PASS` 调的都是一个线程做什么,没有一个动过有多少线程常驻,而 Intel 的分派器对每个 Xe-core 常驻的 work-group 数的上限远低于它的线程槽数。`16` 行就是 256 个 work-item,也就是同一个头文件里每个 GEMM 所用的 work-group 大小。每一档都逐位相同 (`test_act_quant_rows_per_wg_matches`,用的行数会触发补齐守卫);`test_perf_act_quant_wg_sweep{,_long_seq}` 会对它们计时。**尚未在硬件上实测**,所以默认是 `1`。取值不在 `{1, 2, 4, 8, 16}` 内时回退到默认值。参见[激活量化 kernel,以及那个从没有人扫过的形状](#激活量化-kernel以及那个从没有人扫过的形状)。 | +| `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG` | 激活量化 kernel 里每个 work-group 处理多少个 token 行:`1` (默认,即当前形状)、`2`、`4`、`8` 或 `16`。这一趟的 launch 是每个 work-group 一个 sub-group —— 也就是**一个硬件线程** —— 而这是它唯一从没被扫过的维度:`VEC`、`UNROLL`、`SINGLE_PASS` 调的都是一个线程做什么,没有一个动过有多少线程常驻,而 Intel 的分派器对每个 Xe-core 常驻的 work-group 数的上限远低于它的线程槽数。`16` 行就是 256 个 work-item,也就是同一个头文件里每个 GEMM 所用的 work-group 大小。每一档都逐位相同 (`test_act_quant_rows_per_wg_matches`,用的行数会触发补齐守卫);`test_perf_act_quant_wg_sweep{,_long_seq}` 会对它们计时。**已在 B70 实测,结论是完全打平**:65536 行时两个形状在整个范围内都是 1.00-1.01×,1024 行时则是没有次序的噪声,因此默认保持 `1`。occupancy 买到的是延迟隐藏,而这一趟并不受延迟约束 —— 它跑出 497-533 GB/s,高过 device copy 探针。保留它是因为代价为零 (`R` 只到 `nd_range`,没有哪一档会多实例化一个 kernel),而且这个 sweep 现在是一个回归护栏。取值不在 `{1, 2, 4, 8, 16}` 内时回退到默认值。参见[激活量化 kernel,以及那个从没有人扫过的形状](#激活量化-kernel以及那个从没有人扫过的形状)。 | | `ARK_MOE_W4A8_PREFILL_FULL_TILE` | 对既不触及 M 边界也不触及 N 边界的 tile,跳过 epilogue 中的 store 谓词与 scale 下标钳制;**默认开启**,在被扫描的形状上最多带来 1.08× 的收益 (落后时也不超过 0.9%)。该判断在 work-group 内是一致的,因此代价是每个 tile 一次比较,而不是每个输出元素若干次。设为 `0` 可强制所有 tile 都走带保护的 epilogue (两者必须逐位相同)。 | | `ARK_MOE_W4A8_PREFILL_STORE_2D` | 用硬件 2D block store 写 D,而不是每个 fragment 元素发一条 32 字节的标量消息;在输出满足对齐条件 (`N × sizeof(ElementD) % 64 == 0`,所有已支持形状均满足) 时**默认开启**,是这组改动中 prefill 收益最大的一项,达 1.12–1.35×。设为 `0` 可强制使用标量 store——不满足对齐门限的形状本来也走它。两者逐位相同。使用融合 top-k 规约时会自动关闭,因为那是 scatter,用不了 block store。 | | `ARK_MOE_W4A8_PREFILL_PREFETCH` | prefill mainloop 预取 A/B 的 k-tile 深度:`1`–`8`,默认 `3`。预取越深越能掩盖 DRAM 延迟,代价是 GRF 和更长的 prologue——对短 mainloop 影响最大 (`qwen3 down` 每个 tile 只有 12 个 k-tile)。所有取值逐位相同;`test_perf_prefill_prefetch_sweep` (compute-bound batch) 与 `test_perf_prefill_prefetch_sweep_long_seq` (8K 提示词路由) 会对完整的 `1 / 2 / 3 / 4 / 6 / 8` 区间计时。超出 `1`–`8` 的取值回退到默认值。扫描结果显示排名平坦,因此默认值保持不变——参见[预取深度与 K](#预取深度与-k--已实测两轮结论是否定的)。 | @@ -1647,7 +1704,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 本节此前列有三项"只经过推导、既未实测计时也尚未在设备上运行"的 prefill 改动,因为编写 它们的环境既没有 XPU 也没有 SYCL 编译器。这三项现在都已实测两次,并且都保持了原有默认 -值。最后一行是仍处在那个状态里的: +值。此后新增的两项也是如此,五项全部保持了原有默认值: | 改动 | 回退方式 | 实测结果 | |---|---|---| @@ -1656,7 +1713,7 @@ scale 相关的 bug。两个 8K 提示词的 prefill 用例 | D 的 2D block store——每个 sub-group fragment 由少数几条 block 消息取代 64 条 32 字节的标量消息 | `ARK_MOE_W4A8_PREFILL_STORE_2D=0` | 1.09–1.35×,prefill 单项收益最大 | | tile 的领取改到 GEMM 之前而不是之后,让工作计数器的 device-scope atomic 与 mainloop 重叠 | `ARK_MOE_W4A8_PREFILL_CLAIM_EARLY=0` | **打平**——两个形状、两个 batch 全是 1.00×,落在 drift 之内,两次运行之间符号还翻转;保留是因为它免费,且竞争程度随 Xe core 数量增长 | | 把寄存器预算改成 tile policy 的属性 (`kSmallGrfOk`),实测后**保持关闭** | 已经是关闭的 —— `ARK_MOE_W4A8_PREFILL_SMALL_GRF=1` 才会去申请 | **0.63–0.86×,明确的负结果。** `grf_size<128>` 下 occupancy 确实翻倍,但 N 宽 128 的累加器就占掉半个文件,mainloop 暂存的操作数会 spill;代价按 K 而不是按 tile 数增长 | -| 激活量化 kernel 的 work-group 形状 —— 每个 group `R` 个 token 行,而不是一个 sub-group 一个 group | 已经是关闭的 —— `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=1` 就是当前形状 | **尚未实测。** 按默认值落地,所以这一行是一个待扫描点、而不是一项改动;在长 prefill 上这一趟已经贴着 device copy 探针了,所以预期收益在短 prompt 和 decode,而不是 8K | +| 激活量化 kernel 的 work-group 形状 —— 每个 group `R` 个 token 行,而不是一个 sub-group 一个 group | 已经是关闭的 —— `ARK_MOE_W4A8_ACT_QUANT_ROWS_PER_WG=1` 就是当前形状 | **1.00–1.01×,完全打平。** work-group 放大到 16 倍什么都没变:这一趟跑出 497–533 GB/s,高过 device copy 探针,因此已经没有延迟留给 occupancy 去隐藏。默认值不变 | 本节此前把 2D store 列为"需要设备而不是一个开关"的工作,理由是同类 MoE kernel 都经由 `partition_sg_fragment_S` + `reorder` 抵达它,而其中没有任何一个是对**带 scale 的** diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index 0184d2c7fe..c3a0d5a4f3 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1509,15 +1509,16 @@ def run_dedup_quant(batches=None, dtype=torch.bfloat16, models=None, verbose=Tru * ``dedup, fused quant`` -- the deduplication with a quantizer of the same quality as the one already in the kernel. - The fused quantizer's cost is measured rather than assumed: the same GEMM - is timed with 16-bit input and with int8 input, on the same shape and the - same weights, and the difference is the in-kernel quantization of exactly - those rows. Doing that at both ``T`` and ``batch`` rows also cross-checks - that the cost is linear in rows (it should divide by ``top_k``), which is - reported as ``fused quant T/batch`` so a bad measurement cannot pass - silently. :func:`run_act_quant` now times the same pass through - ``moe_w4a8_quant_act`` without differencing anything, and prints the two - side by side. + The fused quantizer's cost is measured rather than assumed, and since + ``moe_w4a8_quant_act`` exists it is measured *directly* -- the pass is + timed on its own instead of being inferred from the gap between the same + GEMM run with 16-bit and with int8 input. That matters here: the two agree + at 65536 rows but not at small row counts, where the difference is taken + between two timings whose own drift is the size of the answer, and it + reads high. The deduplicated path is costed at ``batch`` rows, so it was + exactly the case the old estimate got wrong. Both numbers are still + reported, together with the ``top_k`` linearity check that first exposed + the discrepancy. Up/gate only. The down projection's ``T`` rows are the SiLU output, one distinct row per routed row, so there is nothing to deduplicate; its route @@ -1632,13 +1633,41 @@ def _call(act, ascale=None, ntpe=None): # Difference the same GEMM with 16-bit and int8 input: identical shape, # identical weights, identical output -- the only work that differs is # the in-kernel quantization of exactly those rows. This is the older, - # softer estimate; `run_act_quant` times the pass itself. - quant_fused_t = max(t["gemm_bf16_t"] - t["gemm_int8_t"], 0.0) - quant_fused_b = max(t["gemm_bf16_b"] - t["gemm_int8_b"], 0.0) + # softer estimate, kept only as a cross-check. + diff_fused_t = max(t["gemm_bf16_t"] - t["gemm_int8_t"], 0.0) + diff_fused_b = max(t["gemm_bf16_b"] - t["gemm_int8_b"], 0.0) + + # Measured on B70, the difference is only trustworthy when the pass is + # a large share of the call. At 65536 rows differencing and the direct + # measurement agree to 0-7%; at 1024 rows, where the call is weight- + # bound and the pass is 2-4% of it, differencing reads 4-7x high -- + # it is subtracting two ~1 ms timings whose own drift is the size of + # the answer. `batch` rows sits between the two, so the deduplicated + # path is costed with the direct measurement where it is available and + # only falls back to the difference on a build without the entry point. + if _QUANT_ACT_SKIP is None: + # The output tensors must stay referenced for as long as the + # callable is used: it captures their raw `data_ptr()`, so dropping + # them here would leave the timed call writing into freed memory. + quant_t = _quant_act_caller(permuted_bf16) + quant_fused_t = _xpu_time_ms(quant_t[0]) + quant_t = None + _release_xpu_memory() + quant_b = _quant_act_caller(hidden) + quant_fused_b = _xpu_time_ms(quant_b[0]) + quant_b = None + _release_xpu_memory() + quant_measured = True + else: + quant_fused_t, quant_fused_b = diff_fused_t, diff_fused_b + quant_measured = False # Should be ~top_k: the quantizer is a pure streaming pass, so its cost - # is linear in rows. A ratio far from top_k means one of the two - # differences is noise rather than signal. + # is linear in rows. This is the check that first caught the estimate + # being wrong -- differencing put it at 6.0x for an 8x row count, which + # was read as the short row count being under-fed when it was really + # the smaller difference being inflated. fused_ratio = (quant_fused_t / quant_fused_b) if quant_fused_b > 0 else None + diff_ratio = (diff_fused_t / diff_fused_b) if diff_fused_b > 0 else None perm_bf16_ms, perm_int8_ms = t["perm_bf16"], t["perm_int8"] gemm_ms = t["gemm_int8_t"] @@ -1669,7 +1698,11 @@ def _call(act, ascale=None, ntpe=None): "quant_torch_ms": t["quant_torch"], "quant_fused_batch_ms": quant_fused_b, "quant_fused_tokens_ms": quant_fused_t, + "quant_measured": quant_measured, + "diff_fused_batch_ms": diff_fused_b, + "diff_fused_tokens_ms": diff_fused_t, "fused_ratio": fused_ratio, + "diff_ratio": diff_ratio, "speedup": (today_ms / dedup_fused_ms) if dedup_fused_ms else None, "speedup_torch": (today_ms / dedup_torch_ms) if dedup_torch_ms else None, "snr_db": snr_db, @@ -1711,11 +1744,20 @@ def _call(act, ascale=None, ntpe=None): f"{row['batch']} rows, vs {row['quant_fused_batch_ms']:.3f} ms fused" ) print( - f" {'':<12} fused quant measured by differencing: {row['quant_fused_tokens_ms']:.3f} ms at " - f"{row['tokens']} rows / {row['quant_fused_batch_ms']:.3f} ms at {row['batch']} rows = " + f" {'':<12} fused quant {'measured directly' if row['quant_measured'] else 'by differencing'}: " + f"{row['quant_fused_tokens_ms']:.3f} ms at {row['tokens']} rows / " + f"{row['quant_fused_batch_ms']:.3f} ms at {row['batch']} rows = " f"{(f'{ratio:.1f}x' if ratio else 'n/a')} for {row['topk']}x the rows" f"{'' if ratio and 0.5 * row['topk'] <= ratio <= 2.0 * row['topk'] else ' <- NOT linear, treat as noise'}" ) + if row["quant_measured"]: + diff_ratio = row["diff_ratio"] + print( + f" {'':<12} the old differenced estimate, for comparison: " + f"{row['diff_fused_tokens_ms']:.3f} ms / {row['diff_fused_batch_ms']:.3f} ms = " + f"{(f'{diff_ratio:.1f}x' if diff_ratio else 'n/a')} -- differencing inflates the " + f"smaller row count, which is why this path is no longer costed with it" + ) return rows From 10cf6a26953482d079368ec2cb13b1fcf7183e29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:22:26 +0000 Subject: [PATCH 111/112] fix: repair the dead guard that made the dedup path silently fall back to differencing Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 68 ++++++++++++------- .../ark/test/README_MOE_W4A8_CN.md | 44 +++++++----- .../ark/test/test_moe_w4a8_perf.py | 15 +++- 3 files changed, 81 insertions(+), 46 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 0db6a75a84..2dfa2acf81 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -456,29 +456,36 @@ on B70 at the qwen3 up-projection shape with an 8K prompt: | dedup, fused quant | 0.468 (int8) | 0.136 (fused, 8192 rows) | 2.033 | **2.637** | **1.46x** | Both `quant` figures for the fused quantizer in that table were obtained by -differencing two whole-call timings, which is [since known to read high at small -row counts](#measuring-it-instead-of-differencing-it): the 8192-row entry is the -inflated one, and the direct measurement puts the pass at ~0.10 ms there rather -than 0.136. The correction moves the bottom row *down*, so 1.46x is a floor on -this comparison, not a ceiling; `run_dedup_quant` now costs itself with the -direct measurement and prints the old difference beside it. +differencing two whole-call timings, which is [since known to read high when the +pass is a small share of the +call](#measuring-it-instead-of-differencing-it). The 65536-row entry is safe: +differencing and the direct measurement agree there to 1%. The 8192-row entry is +the open one, and it is *not* yet settled which way it errs — extrapolating the +confirmed 65536-row measurement linearly predicts ~0.10 ms against the +0.132–0.136 ms differencing reports, but the 1024-row points show the pass also +carries a fixed ~25–30 µs launch cost, which is the same size as that gap. So +the 8192-row figure is either inflated by differencing or genuinely paying a +floor, and the two are not distinguishable from the numbers in this table. +`run_dedup_quant` now measures it directly rather than differencing it. Either +way the correction can only move that row *down*, so 1.46–1.47x is a floor on +this comparison rather than a ceiling. The traffic model above predicted 1.53x and the device returned 1.46x, so the byte count is what is driving this. Against W4A16 on the same end-to-end basis (its GEMM measured 3.679 ms, and it permutes 16-bit) the deduplicated path is **1.78x**. -The fused quantizer has no standalone Python entry point, so the benchmark -does not assume its cost: it times the same GEMM with 16-bit input and with -int8 input, on the same shape and the same weights, and takes the difference — -the only work that differs is the in-kernel quantization of exactly those -rows. Doing that at both `T` and `batch` rows also cross-checks that the cost -is linear in rows, which it must be for a streaming pass; the test asserts the -ratio lands within 2x of `top_k`, so a difference that is really measurement -noise cannot quietly become a headline number. Measured, it is **6.0x for 8x -the rows** — mildly sublinear, in the direction fixed per-launch cost predicts -(33% more expensive per row at the smaller size), which is why the check is a -band rather than an equality. +The benchmark does not assume the fused quantizer's cost. It used to obtain it +by timing the same GEMM with 16-bit input and with int8 input and taking the +difference — the only work that differs is the in-kernel quantization of +exactly those rows — and since `moe_w4a8_quant_act` exists it measures the pass +directly instead, printing the old difference alongside. Doing it at both `T` +and `batch` rows cross-checks the cost against rows; the test asserts the ratio +lands within 2x of `top_k`, so a difference that is really measurement noise +cannot quietly become a headline number. It reads **6.0–6.2x for 8x the rows** +— sublinear in the direction a fixed per-launch cost predicts, which is why the +check is a band rather than an equality, and which is also why that sublinearity +is not headroom. The practical consequence: **do not deduplicate with an eager-torch quantizer.** The version worth shipping folds the quantization into the @@ -1620,12 +1627,20 @@ drift. At 65536 rows the pass is a quarter of the call and the same subtraction is sound. So every long-prefill figure quoted from the old estimate survives — and every small-row one derived from it does not. -That is not a footnote, because one such figure was load-bearing: the -deduplicated path is costed at `batch` rows, and its `top_k` linearity check had -been reading **6.0× for an 8× row count**. That was written up as the short row -count being under-fed. It was not: it was the smaller difference being -inflated. Corrected to the direct measurement the pass is linear in rows, as a -streaming kernel must be, and `run_dedup_quant` now costs itself that way. +That is not a footnote, because one such figure is load-bearing: the +deduplicated path is costed at `batch` rows, and its `top_k` linearity check +reads **6.0–6.2× for an 8× row count**. That gap was once written up as the +short row count being under-fed, i.e. as headroom. It is not headroom. The +1024-row measurements settle the direction: the pass costs 0.025 ms on up and +0.031 ms on down there — down being *slower* while moving a third of the bytes, +which no bandwidth model produces — so at small row counts the pass is paying a +fixed ~25–30 µs launch cost, not running short of work. A floor is not +something occupancy, vectorization or any other in-kernel change moves. + +What the 1024-row points do *not* settle is the 8192-row one, which is the row +count that actually matters here, and which sits between a regime where +differencing is trustworthy and one where it is not. That is why +`run_dedup_quant` now measures it directly. #### It is on the roof, and the roof is above the probe @@ -1731,9 +1746,10 @@ instantiated per rung), because the sweep is now a regression guard, and because `test_act_quant_rows_per_wg_matches` is a real correctness test of the padding guard. **The default stays at `1`.** -This also retires the "short prompts are under-fed" reading that the 8192-row -estimate had suggested: corrected, that row count streams at the same ~500 GB/s -as every other, and the pass is linear in rows throughout. +These 1024-row numbers also retire the "short prompts are under-fed" reading +that the 8192-row estimate had suggested — though not by restoring linearity. +The pass really is sublinear at small row counts; it is just that the cause is a +launch floor rather than an unfed stream, so there is nothing there to win. ```bash pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 6f51a71792..588302174d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -393,22 +393,25 @@ permute 减半,因为 permute 现在每个元素只搬 1 字节而不是 2 字 | 去重 + 融合量化 | 0.468(int8) | 0.136(融合,8192 行) | 2.033 | **2.637** | **1.46x** | 上表中融合量化器的两个 `quant` 数字都是由两次整调用计时相减得到的,而这个方法 -[现已知道在小行数上会读高](#直接测它而不是靠相减推它):8192 行那一项就是被抬高的那个, -直接测量给出的是约 0.10 ms 而不是 0.136。这个修正只会把最后一行**往下**推,所以 -1.46x 是这个对比的下界而不是上界;`run_dedup_quant` 现在按直接测量给自己计价,并把 -旧的差值并排打印出来。 +[现已知道在这一趟只占调用一小部分时会读高](#直接测它而不是靠相减推它)。65536 行那一项 +是可靠的:相减与直接测量在那里相差 1%。8192 行那一项才是悬着的,而且**目前还判不出它 +偏在哪一边** —— 把已确认的 65536 行实测按线性外推,预测约 0.10 ms,而相减给出的是 +0.132–0.136 ms;但 1024 行的数据显示这一趟还带着一个约 25–30 µs 的固定 launch 开销, +其量级恰好就是这个差。所以 8192 行那个数要么是被相减抬高了,要么是真的在付这个下限, +仅凭这张表里的数字无法区分。`run_dedup_quant` 现在直接测它,而不再相减。无论是哪一种, +这个修正都只会把最后一行**往下**推,所以 1.46–1.47x 是这个对比的下界而不是上界。 上面的流量模型预测 1.53x,设备返回 1.46x,说明推动这个结果的确实是字节数。以同样的 端到端口径对比 W4A16(其 GEMM 实测 3.679 ms,且它 permute 的是 16-bit),去重路径是 **1.78x**。 -融合量化器没有独立的 Python 入口,所以 benchmark 不去假设它的开销:它在同一形状、 -同一份权重上分别计时 16-bit 输入和 int8 输入的同一个 GEMM,取差值——两者唯一不同的 -工作就是对这些行做的算子内量化。在 `T` 行和 `batch` 行上各做一次,还能交叉验证这个 -开销确实与行数成正比(流式 pass 必然如此);测试会断言这个比值落在 `top_k` 的 2 倍 -以内,这样一个其实只是测量噪声的差值就不会悄悄变成一个醒目的结论数字。实测是 -**8 倍行数对应 6.0x**——略低于线性,方向正是固定的每次 launch 开销所预期的(较小规模 -下每行贵 33%),这也是为什么这个检查用的是一个区间而不是相等。 +benchmark 不去假设融合量化器的开销。它此前的做法是在同一形状、同一份权重上分别计时 +16-bit 输入和 int8 输入的同一个 GEMM,取差值——两者唯一不同的工作就是对这些行做的算子内 +量化;自从有了 `moe_w4a8_quant_act`,它改为直接测量这一趟,并把旧的差值并排打印出来。 +在 `T` 行和 `batch` 行上各做一次,可以把开销与行数交叉验证;测试会断言这个比值落在 +`top_k` 的 2 倍以内,这样一个其实只是测量噪声的差值就不会悄悄变成一个醒目的结论数字。 +读数是 **8 倍行数对应 6.0–6.2x**——低于线性,方向正是固定的每次 launch 开销所预期的, +这既是这个检查用区间而不是相等的原因,也是这段低于线性**并不构成可挖空间**的原因。 实际结论是:**不要用 eager-torch 量化器去做去重。**值得上线的版本是把量化折进产出 `hidden_states` 的那个算子的 epilogue(MoE 之前的 norm),那里行本来就在寄存器里、 @@ -1402,10 +1405,16 @@ launch 形状恰好把这件事摆成了最显眼的问题。 旧估计里引用的**长 prefill 数字都站得住,而由它推出的小行数结论都不成立**。 这不是一个脚注,因为其中一个这样的数字是承重的:去重那条路径正是按 `batch` 行来 -计价的,而它的 `top_k` 线性度自检一直读出 **8 倍行数只花了 6.0 倍时间**。这曾被写成 -"小行数没被喂饱"。并不是:是那个较小的差值被抬高了。改用直接测量之后,这一趟就是 -按行数线性的 —— 一个流式 kernel 本来就必须如此 —— `run_dedup_quant` 现在也按这个 -口径给自己计价。 +计价的,而它的 `top_k` 线性度自检读出的是 **8 倍行数只花了 6.0–6.2 倍时间**。这个差 +曾被写成"小行数没被喂饱",也就是被当成了可挖的空间。它不是空间。1024 行的实测定住了 +方向:这一趟在那里 up 花 0.025 ms、down 花 0.031 ms —— down 搬运的字节只有三分之一却 +**更慢**,这是任何带宽模型都给不出的结果 —— 所以小行数下这一趟付的是一个约 25–30 µs +的固定 launch 开销,而不是活不够干。而一个下限,是 occupancy、向量化或任何其他 kernel +内部改动都搬不动的。 + +1024 行的数据**没有**定住的,是 8192 行那个点 —— 而那恰好是这里真正要紧的行数,它正好 +落在"相减可信"和"相减不可信"这两个区间之间。这就是 `run_dedup_quant` 现在直接测它的 +原因。 #### 它就在屋顶上,而这个屋顶高过探针 @@ -1499,8 +1508,9 @@ work-group 放大到 16 倍,这一趟纹丝不动 —— 整个范围内 1.00 `test_act_quant_rows_per_wg_matches` 是对补齐守卫的一个真正的正确性测试。 **默认值保持 `1`。** -这同时也让"短 prompt 没被喂饱"那个由 8192 行估计得出的读法退场:修正之后,那个行数 -的流带宽和其他所有行数一样是约 500 GB/s,这一趟自始至终都是按行数线性的。 +1024 行的这几个数字同时也让"短 prompt 没被喂饱"那个由 8192 行估计得出的读法退场 —— +但不是靠恢复线性。这一趟在小行数上**确实**低于线性;只是原因是一个 launch 下限、而不是 +一条没被喂饱的流,所以那里没有任何东西可赢。 ```bash pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s diff --git a/auto_round_extension/ark/test/test_moe_w4a8_perf.py b/auto_round_extension/ark/test/test_moe_w4a8_perf.py index c3a0d5a4f3..33c5e0ca58 100644 --- a/auto_round_extension/ark/test/test_moe_w4a8_perf.py +++ b/auto_round_extension/ark/test/test_moe_w4a8_perf.py @@ -1645,7 +1645,7 @@ def _call(act, ascale=None, ntpe=None): # the answer. `batch` rows sits between the two, so the deduplicated # path is costed with the direct measurement where it is available and # only falls back to the difference on a build without the entry point. - if _QUANT_ACT_SKIP is None: + if not _QUANT_ACT_SKIP: # The output tensors must stay referenced for as long as the # callable is used: it captures their raw `data_ptr()`, so dropping # them here would leave the timed call writing into freed memory. @@ -1755,8 +1755,17 @@ def _call(act, ascale=None, ntpe=None): print( f" {'':<12} the old differenced estimate, for comparison: " f"{row['diff_fused_tokens_ms']:.3f} ms / {row['diff_fused_batch_ms']:.3f} ms = " - f"{(f'{diff_ratio:.1f}x' if diff_ratio else 'n/a')} -- differencing inflates the " - f"smaller row count, which is why this path is no longer costed with it" + f"{(f'{diff_ratio:.1f}x' if diff_ratio else 'n/a')}" + ) + else: + # Say why, rather than quietly reporting the softer number: the + # fallback previously fired on every run because the guard + # tested the skip reason against `None` when it is `""`, and a + # plausible-looking differenced figure is exactly the kind of + # thing that hides such a bug. + print( + f" {'':<12} (differenced because the direct measurement is unavailable: " + f"{_QUANT_ACT_SKIP or 'unknown'})" ) return rows From c77f80bab1dbfe6f2af6799592bd0e1fc7a79843 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:37:08 +0000 Subject: [PATCH 112/112] docs: record the settled 8192-row quantization measurement Co-authored-by: a32543254 <53296245+a32543254@users.noreply.github.com> --- .../ark/test/README_MOE_W4A8.md | 99 ++++++++++--------- .../ark/test/README_MOE_W4A8_CN.md | 73 +++++++------- 2 files changed, 94 insertions(+), 78 deletions(-) diff --git a/auto_round_extension/ark/test/README_MOE_W4A8.md b/auto_round_extension/ark/test/README_MOE_W4A8.md index 2dfa2acf81..3b8dc98b9d 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8.md @@ -451,27 +451,29 @@ on B70 at the qwen3 up-projection shape with an 8K prompt: | path | permute | quant | GEMM | total | vs in-call | |---|---|---|---|---|---| -| in-call quant | 1.011 (bf16) | 0.818 (fused, 65536 rows) | 2.033 | 3.862 | — | -| dedup, torch quant | 0.468 (int8) | 1.558 (torch, 8192 rows) | 2.033 | 4.059 | **0.95x** | -| dedup, fused quant | 0.468 (int8) | 0.136 (fused, 8192 rows) | 2.033 | **2.637** | **1.46x** | - -Both `quant` figures for the fused quantizer in that table were obtained by -differencing two whole-call timings, which is [since known to read high when the -pass is a small share of the -call](#measuring-it-instead-of-differencing-it). The 65536-row entry is safe: -differencing and the direct measurement agree there to 1%. The 8192-row entry is -the open one, and it is *not* yet settled which way it errs — extrapolating the -confirmed 65536-row measurement linearly predicts ~0.10 ms against the -0.132–0.136 ms differencing reports, but the 1024-row points show the pass also -carries a fixed ~25–30 µs launch cost, which is the same size as that gap. So -the 8192-row figure is either inflated by differencing or genuinely paying a -floor, and the two are not distinguishable from the numbers in this table. -`run_dedup_quant` now measures it directly rather than differencing it. Either -way the correction can only move that row *down*, so 1.46–1.47x is a floor on -this comparison rather than a ceiling. - -The traffic model above predicted 1.53x and the device returned 1.46x, so the -byte count is what is driving this. Against W4A16 on the same end-to-end basis +| in-call quant | 1.015 (bf16) | 0.804 (fused, 65536 rows) | 2.204 | 4.028 | — | +| dedup, torch quant | 0.468 (int8) | 1.557 (torch, 8192 rows) | 2.204 | 4.230 | **0.95x** | +| dedup, fused quant | 0.468 (int8) | 0.110 (fused, 8192 rows) | 2.204 | **2.782** | **1.45x** | + +Both `quant` figures for the fused quantizer in that table are now measured +directly. They used to be obtained by differencing two whole-call timings, which +is [known to read high when the pass is a small share of the +call](#measuring-it-instead-of-differencing-it), and the 8192-row entry was the +one that sat in that regime. It is settled: **differencing said 0.153 ms and the +measurement says 0.110 ms**, 39% high, in exactly the predicted direction. The +[fixed launch cost](#measuring-it-instead-of-differencing-it) is real but far +smaller than the 1024-row points alone suggested — about 11 µs on this shape, +not the 25–30 µs that was briefly written here. + +The headline reads 1.45x here against 1.46–1.47x in the two earlier runs, and +the quantizer is not the reason. The GEMM ran 2.204 ms this time against +2.033–2.040 ms before — 8% slower, and *common to both rows*, so it dilutes the +ratio no matter what the quantizer does. Hold it at the earlier value and the +same three measurements give 1.47x. The run-to-run spread on this comparison is +the GEMM, not the pass being compared. + +The traffic model above predicted 1.53x and the device returns 1.45–1.47x, so +the byte count is what is driving this. Against W4A16 on the same end-to-end basis (its GEMM measured 3.679 ms, and it permutes 16-bit) the deduplicated path is **1.78x**. @@ -482,10 +484,10 @@ exactly those rows — and since `moe_w4a8_quant_act` exists it measures the pas directly instead, printing the old difference alongside. Doing it at both `T` and `batch` rows cross-checks the cost against rows; the test asserts the ratio lands within 2x of `top_k`, so a difference that is really measurement noise -cannot quietly become a headline number. It reads **6.0–6.2x for 8x the rows** -— sublinear in the direction a fixed per-launch cost predicts, which is why the -check is a band rather than an equality, and which is also why that sublinearity -is not headroom. +cannot quietly become a headline number. Measured, it reads **7.3x for 8x the +rows** — 91% of linear, the shortfall being the fixed per-launch cost. The old +differenced version of the same check read 5.3x, and that larger gap was once +written up as headroom; it was mostly the difference being inflated. The practical consequence: **do not deduplicate with an eager-torch quantizer.** The version worth shipping folds the quantization into the @@ -493,7 +495,7 @@ epilogue of whatever produces `hidden_states` (the norm ahead of the MoE), where the row is already in registers and the absmax is free — which is the same "upstream this is free" the contract above describes. Failing that, a single fused quantization kernel over the `[batch, K]` hidden states is what -the 0.136 ms column represents; the 1.558 ms column is what calling eager +the 0.110 ms column represents; the 1.557 ms column is what calling eager torch costs instead. Two limits worth stating plainly: @@ -1617,6 +1619,7 @@ differenced estimate. |---|---|---|---|---| | qwen3 up | 65536 | 0.810 ms | 0.808 ms | agree | | qwen3 down | 65536 | 0.284 ms | 0.303 ms | 7% apart | +| qwen3 up | 8192 | 0.110 ms | 0.153 ms | **39% apart** | | qwen3 up | 1024 | 0.025 ms | 0.185 ms | **7.4× apart** | | qwen3 down | 1024 | 0.031 ms | 0.125 ms | **4.0× apart** | @@ -1629,18 +1632,22 @@ and every small-row one derived from it does not. That is not a footnote, because one such figure is load-bearing: the deduplicated path is costed at `batch` rows, and its `top_k` linearity check -reads **6.0–6.2× for an 8× row count**. That gap was once written up as the -short row count being under-fed, i.e. as headroom. It is not headroom. The -1024-row measurements settle the direction: the pass costs 0.025 ms on up and -0.031 ms on down there — down being *slower* while moving a third of the bytes, -which no bandwidth model produces — so at small row counts the pass is paying a -fixed ~25–30 µs launch cost, not running short of work. A floor is not -something occupancy, vectorization or any other in-kernel change moves. - -What the 1024-row points do *not* settle is the 8192-row one, which is the row -count that actually matters here, and which sits between a regime where -differencing is trustworthy and one where it is not. That is why -`run_dedup_quant` now measures it directly. +read **5.3× for an 8× row count** when it was differenced. That gap was written +up as the short row count being under-fed, i.e. as headroom. It was not. +Measured, the same check reads **7.3×**, and the pass costs 0.110 ms at 8192 +rows rather than the 0.153 ms the subtraction reported. + +What is left after the correction is a genuine but small fixed cost. Fitting a +straight line through the measured up-projection points gives **≈11 µs of +per-launch overhead and 508 GB/s of marginal bandwidth**, and that one line +reproduces all three row counts across two independent runs — 0.023 ms predicted +against 0.025 measured at 1024, 0.110 at 8192, 0.804 at 65536. At 8192 rows the +pass therefore still achieves 458 GB/s, 92% of its own long-prefill figure. The +down projection's 1024-row point implies a larger intercept (≈27 µs, which is +why it is *slower* than up there while moving a third of the bytes), so the +floor is shape-dependent rather than a single constant. None of it is +recoverable: a fixed launch cost is not something occupancy, vectorization or +any other in-kernel change moves. #### It is on the roof, and the roof is above the probe @@ -1735,9 +1742,9 @@ One work-group per hardware thread was never the constraint; the DRAM was. The small-row rungs fail for the opposite reason. At 1024 rows the pass costs 0.025 ms (up) and 0.031 ms (down) — note that down is *slower* while moving a -third of the bytes, which no bandwidth model produces. That is a fixed -~25–30 µs launch floor, not a stream, and occupancy does not move a floor -either. It does not matter: at that size the pass is 2–4% of the call. +third of the bytes, which no bandwidth model produces. That is a fixed launch +cost (≈11 µs on up, ≈27 µs on down), not a stream, and occupancy does not move a +floor either. It does not matter: at that size the pass is 2–4% of the call. So the hypothesis this knob was built to test is refuted from both ends, and usefully so — it was the last untested dimension of this kernel. The flag stays @@ -1747,9 +1754,11 @@ instantiated per rung), because the sweep is now a regression guard, and because guard. **The default stays at `1`.** These 1024-row numbers also retire the "short prompts are under-fed" reading -that the 8192-row estimate had suggested — though not by restoring linearity. -The pass really is sublinear at small row counts; it is just that the cause is a -launch floor rather than an unfed stream, so there is nothing there to win. +that the 8192-row estimate had suggested — and the direct measurement has since +retired most of the estimate itself. At the row count that actually matters the +pass costs 0.110 ms, not the 0.153 ms differencing reported, and holds 458 GB/s. +What sublinearity survives is a launch floor rather than an unfed stream, so +there is nothing there to win. ```bash pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s @@ -1819,7 +1828,7 @@ quantization has to happen somewhere. It is reachable anyway, because the row absmax does not depend on the expert, so on the up/gate projection the in-call pass quantizes `top_k` identical copies of every token. Quantizing the `batch` distinct rows once and permuting int8 gets to the same call, and measures -**1.46x end to end** including the caller's permute — the whole story is in +**1.45–1.47x end to end** including the caller's permute — the whole story is in [deduplicate the quantization](#reaching-contract-1-with-no-int8-upstream-deduplicate-the-quantization), including the way it is a *regression* if the deduplicated rows go through an eager-torch quantizer. diff --git a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md index 588302174d..c62d310516 100644 --- a/auto_round_extension/ark/test/README_MOE_W4A8_CN.md +++ b/auto_round_extension/ark/test/README_MOE_W4A8_CN.md @@ -388,20 +388,23 @@ permute 减半,因为 permute 现在每个元素只搬 1 字节而不是 2 字 | 路径 | permute | quant | GEMM | 合计 | vs 调用内 | |---|---|---|---|---|---| -| 调用内量化 | 1.011(bf16) | 0.818(融合,65536 行) | 2.033 | 3.862 | — | -| 去重 + torch 量化 | 0.468(int8) | 1.558(torch,8192 行) | 2.033 | 4.059 | **0.95x** | -| 去重 + 融合量化 | 0.468(int8) | 0.136(融合,8192 行) | 2.033 | **2.637** | **1.46x** | - -上表中融合量化器的两个 `quant` 数字都是由两次整调用计时相减得到的,而这个方法 -[现已知道在这一趟只占调用一小部分时会读高](#直接测它而不是靠相减推它)。65536 行那一项 -是可靠的:相减与直接测量在那里相差 1%。8192 行那一项才是悬着的,而且**目前还判不出它 -偏在哪一边** —— 把已确认的 65536 行实测按线性外推,预测约 0.10 ms,而相减给出的是 -0.132–0.136 ms;但 1024 行的数据显示这一趟还带着一个约 25–30 µs 的固定 launch 开销, -其量级恰好就是这个差。所以 8192 行那个数要么是被相减抬高了,要么是真的在付这个下限, -仅凭这张表里的数字无法区分。`run_dedup_quant` 现在直接测它,而不再相减。无论是哪一种, -这个修正都只会把最后一行**往下**推,所以 1.46–1.47x 是这个对比的下界而不是上界。 - -上面的流量模型预测 1.53x,设备返回 1.46x,说明推动这个结果的确实是字节数。以同样的 +| 调用内量化 | 1.015(bf16) | 0.804(融合,65536 行) | 2.204 | 4.028 | — | +| 去重 + torch 量化 | 0.468(int8) | 1.557(torch,8192 行) | 2.204 | 4.230 | **0.95x** | +| 去重 + 融合量化 | 0.468(int8) | 0.110(融合,8192 行) | 2.204 | **2.782** | **1.45x** | + +上表中融合量化器的两个 `quant` 数字现在都是直接测出来的。它们此前是由两次整调用计时 +相减得到的,而这个方法[已知在这一趟只占调用一小部分时会读高](#直接测它而不是靠相减推它), +8192 行那一项正落在这个区间里。现在它定了:**相减说 0.153 ms,实测是 0.110 ms**,高了 +39%,方向正是此前预期的那一边。[固定的 launch 开销](#直接测它而不是靠相减推它)确实存在, +但远小于单看 1024 行时所推测的量级 —— 在这个形状上约 11 µs,而不是这里一度写下的 +25–30 µs。 + +这一次的结论数字是 1.45x,而此前两次是 1.46–1.47x,原因不在量化器。GEMM 这次是 +2.204 ms,此前是 2.033–2.040 ms —— 慢了 8%,而且它是**两行共有的**,所以不管量化器 +怎么变,它都会稀释这个比值。把 GEMM 固定回此前的值,同样这三个实测给出的就是 1.47x。 +这个对比的跑间波动来自 GEMM,而不是被对比的那一趟。 + +上面的流量模型预测 1.53x,设备返回 1.45–1.47x,说明推动这个结果的确实是字节数。以同样的 端到端口径对比 W4A16(其 GEMM 实测 3.679 ms,且它 permute 的是 16-bit),去重路径是 **1.78x**。 @@ -410,14 +413,15 @@ benchmark 不去假设融合量化器的开销。它此前的做法是在同一 量化;自从有了 `moe_w4a8_quant_act`,它改为直接测量这一趟,并把旧的差值并排打印出来。 在 `T` 行和 `batch` 行上各做一次,可以把开销与行数交叉验证;测试会断言这个比值落在 `top_k` 的 2 倍以内,这样一个其实只是测量噪声的差值就不会悄悄变成一个醒目的结论数字。 -读数是 **8 倍行数对应 6.0–6.2x**——低于线性,方向正是固定的每次 launch 开销所预期的, -这既是这个检查用区间而不是相等的原因,也是这段低于线性**并不构成可挖空间**的原因。 +实测读数是 **8 倍行数对应 7.3x**——达到线性的 91%,差的那一点就是固定的每次 launch +开销。同一个检查在相减版本下读出的是 5.3x,那个更大的差曾被写成可挖的空间;它主要 +只是差值被抬高了。 实际结论是:**不要用 eager-torch 量化器去做去重。**值得上线的版本是把量化折进产出 `hidden_states` 的那个算子的 epilogue(MoE 之前的 norm),那里行本来就在寄存器里、 absmax 是免费的——也就是上面契约里说的“上游做这件事是免费的”。退一步,用一个融合的 -量化 kernel 处理 `[batch, K]` 的 hidden states,就是 0.136 ms 那一列所代表的;而 -1.558 ms 那一列是改用 eager torch 的代价。 +量化 kernel 处理 `[batch, K]` 的 hidden states,就是 0.110 ms 那一列所代表的;而 +1.557 ms 那一列是改用 eager torch 的代价。 有两条边界需要讲清楚: @@ -1396,6 +1400,7 @@ launch 形状恰好把这件事摆成了最显眼的问题。 |---|---|---|---|---| | qwen3 up | 65536 | 0.810 ms | 0.808 ms | 一致 | | qwen3 down | 65536 | 0.284 ms | 0.303 ms | 差 7% | +| qwen3 up | 8192 | 0.110 ms | 0.153 ms | **差 39%** | | qwen3 up | 1024 | 0.025 ms | 0.185 ms | **差 7.4 倍** | | qwen3 down | 1024 | 0.031 ms | 0.125 ms | **差 4.0 倍** | @@ -1405,16 +1410,17 @@ launch 形状恰好把这件事摆成了最显眼的问题。 旧估计里引用的**长 prefill 数字都站得住,而由它推出的小行数结论都不成立**。 这不是一个脚注,因为其中一个这样的数字是承重的:去重那条路径正是按 `batch` 行来 -计价的,而它的 `top_k` 线性度自检读出的是 **8 倍行数只花了 6.0–6.2 倍时间**。这个差 -曾被写成"小行数没被喂饱",也就是被当成了可挖的空间。它不是空间。1024 行的实测定住了 -方向:这一趟在那里 up 花 0.025 ms、down 花 0.031 ms —— down 搬运的字节只有三分之一却 -**更慢**,这是任何带宽模型都给不出的结果 —— 所以小行数下这一趟付的是一个约 25–30 µs -的固定 launch 开销,而不是活不够干。而一个下限,是 occupancy、向量化或任何其他 kernel -内部改动都搬不动的。 - -1024 行的数据**没有**定住的,是 8192 行那个点 —— 而那恰好是这里真正要紧的行数,它正好 -落在"相减可信"和"相减不可信"这两个区间之间。这就是 `run_dedup_quant` 现在直接测它的 -原因。 +计价的,而它的 `top_k` 线性度自检在相减版本下读出的是 **8 倍行数只花了 5.3 倍时间**。 +这个差曾被写成"小行数没被喂饱",也就是被当成了可挖的空间。它不是。实测下同一个检查 +读出的是 **7.3 倍**,而这一趟在 8192 行的开销是 0.110 ms,而不是相减报出的 0.153 ms。 + +修正之后剩下的,是一个真实但很小的固定开销。把实测的 up 投影各点拟合成一条直线,得到 +**约 11 µs 的每次 launch 开销和 508 GB/s 的边际带宽**,而这同一条线能复现两次独立运行 +里的全部三个行数 —— 1024 行预测 0.023 ms 对实测 0.025,8192 行 0.110,65536 行 0.804。 +所以即使在 8192 行,这一趟仍然跑到 458 GB/s,是它自己长 prefill 数字的 92%。down 投影 +的 1024 行点对应的截距更大(约 27 µs,这也正是它在那里搬运三分之一字节却比 up 更慢的 +原因),所以这个下限是随形状而变的,而不是一个统一的常数。这些都拿不回来:一个固定的 +launch 开销,是 occupancy、向量化或任何其他 kernel 内部改动都搬不动的。 #### 它就在屋顶上,而这个屋顶高过探针 @@ -1499,8 +1505,8 @@ work-group 放大到 16 倍,这一趟纹丝不动 —— 整个范围内 1.00 小行数那几档失败的原因恰好相反。1024 行时这一趟要 0.025 ms (up) 和 0.031 ms (down) —— 注意 down 搬运的字节只有三分之一却**更慢**,这是任何带宽模型都给不出的 -结果。那是一个约 25–30 µs 的固定 launch 下限,而不是一条流;occupancy 同样搬不动 -一个下限。这也无所谓:在那个规模下这一趟只占调用的 2–4%。 +结果。那是一个固定的 launch 开销(up 约 11 µs、down 约 27 µs),而不是一条流; +occupancy 同样搬不动一个下限。这也无所谓:在那个规模下这一趟只占调用的 2–4%。 所以这个旋钮要检验的假设从两头都被否证了,而且这是有价值的 —— 它是这个 kernel 最后 一个没被测过的维度。开关保留下来,是因为它的代价确实为零 (`R` 只到 `nd_range`,没有 @@ -1509,8 +1515,9 @@ work-group 放大到 16 倍,这一趟纹丝不动 —— 整个范围内 1.00 **默认值保持 `1`。** 1024 行的这几个数字同时也让"短 prompt 没被喂饱"那个由 8192 行估计得出的读法退场 —— -但不是靠恢复线性。这一趟在小行数上**确实**低于线性;只是原因是一个 launch 下限、而不是 -一条没被喂饱的流,所以那里没有任何东西可赢。 +而直接测量此后又把那个估计本身也大半推翻了。在真正要紧的那个行数上,这一趟的开销是 +0.110 ms 而不是相减报出的 0.153 ms,并且仍然保持 458 GB/s。剩下的那点低于线性来自一个 +launch 下限、而不是一条没被喂饱的流,所以那里没有任何东西可赢。 ```bash pytest test_moe_w4a8_perf.py -k "act_quant_wg" -v -s @@ -1571,7 +1578,7 @@ qwen3 down 未规约的 `[T, N]` 输出单独就有 268 MB——占整个调用 一个显而易见的反驳是:它需要上游能产出 int8,而多数流水线并没有——上一个算子输出 bf16, 动态量化总得有人做。但它依然是可达的,因为逐行 absmax 不依赖专家,所以在 up/gate 投影上, 调用内的那一遍量化处理的是每个 token 的 `top_k` 份相同副本。把 `batch` 个不同的行量化 -一次、再 permute int8,就能到达同一个调用,实测**端到端 1.46x**(已包含调用方的 +一次、再 permute int8,就能到达同一个调用,实测**端到端 1.45–1.47x**(已包含调用方的 permute)——完整过程见[去重量化](#上游没有-int8-时如何用上契约-1去重量化),其中也包括: 如果去重后的行交给 eager-torch 量化器来做,它反而是**倒退**。