onnxruntime
429d91c1 - [MLAS] Route ARM64 S8U8 QGEMM to the UDOT kernel (#29787)

Commit
24 days ago
[MLAS] Route ARM64 S8U8 QGEMM to the UDOT kernel (#29787) ### Description ARM64 quantized GEMM never routed the signed-A/unsigned-B (S8U8) combination to a SIMD dispatch — `MLAS_PLATFORM` didn't even declare a `GemmS8U8Dispatch` member on ARM64, unlike x86_64 (AVX2/VNNI), LoongArch64 (LSX), and RISC-V64, which all have one. It fell through to the generic scalar reference kernel. This reuses the existing UDOT kernel (already shared by U8U8 and U8S8) instead of adding new assembly. UDOT only does unsigned x unsigned, so B is already shifted into the unsigned domain with an XOR 0x80 sign flip when signed; this does the same for A (`MlasGemmQuantFixupZeroPointA`, `MlasGemmQuantCopyPackA`). K-padding bytes are left untouched across all four row-count paths (8/4/2/1-row) so they keep contributing zero to the dot product and `RowSumBuffer`. `platform.cpp` initializes the new dispatch member to the scalar default and only overrides it to UDOT inside the existing dot-product-support check, so cores without dot-product stay on the scalar path exactly as before. Also parameterized `bench_qgemm.cpp` to cover S8U8, and added a guard around the packed-B benchmark: `MlasGemmPackBSize` returns 0 for combinations the active dispatch doesn't support packing for, and calling `MlasGemmPackB` anyway was dereferencing a null `CopyPackBRoutine`. ### Motivation and Context `QLinearMatMul`, `MatMulInteger`, and `QLinearConv` register their signed-A kernels with `T2` accepting both `int8` and `uint8`, so S8U8 is reachable from real models, not just a synthetic case. Existing `test_qgemm.cpp` correctness tests already cover `int8_t`/`uint8_t`, they were just exercising the scalar fallback on ARM64. Tested on Apple M1 (macOS) and Ampere Neoverse-N1 (Ubuntu), both with NEON dot-product: - Existing MLAS qgemm suites (S8U8/U8U8/U8S8/S8S8) pass on both, no regressions. - Benchmarked `bench_qgemm` (NoPackB, real_time) before/after by temporarily forcing the scalar dispatch for comparison: | Shape (M/N/K, threads) | M1 | Neoverse-N1 | |---|---|---| | 1/512/512, 1 | 149µs → 29µs | 152µs → 51µs | | 1/1024/1024, 1 | 610µs → 202µs | 1544µs → 240µs | | 384/1024/1024, 4 | 4.96ms → 3.98ms | 12.3ms → 1.34ms | | 384/1024/3072, 4 | 20.7ms → 4.68ms | 37.2ms → 4.17ms | | 1536/1024/4096, 16 | 124.6ms → 37.6ms | 201.6ms → 37.4ms | | 3072/4096/1024, 16 | 135.4ms → 53.4ms | 388.8ms → 69.2ms | Packed-B for S8U8 works correctly on both machines now that they're on the UDOT dispatch. Without the guard mentioned above, the new packed-B benchmark case would crash on any target that still resolves to the scalar dispatch, since `MlasGemmPackBSize` returns 0 there and `MlasGemmPackB` would call a null `CopyPackBRoutine`. ARM64 cores without dot-product support are unaffected by the dispatch change — they stay on `MlasGemmQuantDispatchDefault` (scalar) exactly as before, `MlasGemmPackBSize` still returns 0 for S8U8 there, and the benchmark guard keeps that case skipping cleanly instead of crashing. This PR doesn't add an optimized S8U8 path or packed-B support for those cores; that's left for a follow-up if there's interest. The routing change in `qgemm.h` lives inside the `#if defined(MLAS_TARGET_ARM64)` guard, so native Windows ARM64 (MSVC, `MLAS_TARGET_ARM64`) picks up the same S8U8 → UDOT routing as macOS/Linux ARM64 — it wasn't separately benchmarked here. ARM64EC (`MLAS_TARGET_ARM64EC`) and 32-bit ARM stay on their existing logic, unaffected.
Author
Parents
Loading