From 7318b55307cfbf69fd4108311a1a55cdc1a97701 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 6 Sep 2026 08:02:28 -0400 Subject: [PATCH 1/4] fix: preserve KleidiAI Windows ARM64 assembly preprocessing --- .github/workflows/validate_wrapper.yml | 37 +++++++++++ CMakeLists.txt | 2 + cmake/kleidiai_windows.cmake | 28 ++++++++ .../fixtures/kleidiai_windows/CMakeLists.txt | 42 ++++++++++++ .../kleidiai_windows/kleidiai/CMakeLists.txt | 6 ++ tests/test_kleidiai_windows.py | 65 +++++++++++++++++++ 6 files changed, 180 insertions(+) create mode 100644 cmake/kleidiai_windows.cmake create mode 100644 tests/fixtures/kleidiai_windows/CMakeLists.txt create mode 100644 tests/fixtures/kleidiai_windows/kleidiai/CMakeLists.txt create mode 100644 tests/test_kleidiai_windows.py diff --git a/.github/workflows/validate_wrapper.yml b/.github/workflows/validate_wrapper.yml index 36e58a9..0c80576 100644 --- a/.github/workflows/validate_wrapper.yml +++ b/.github/workflows/validate_wrapper.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/validate_wrapper.yml' - '.github/workflows/native_release.yml' - 'CMakeLists.txt' + - 'cmake/**' - 'src/**' - 'tests/**' - 'tools/tts_smoke.cpp' @@ -22,6 +23,7 @@ on: - '.github/workflows/validate_wrapper.yml' - '.github/workflows/native_release.yml' - 'CMakeLists.txt' + - 'cmake/**' - 'src/**' - 'tests/**' - 'tools/tts_smoke.cpp' @@ -36,6 +38,41 @@ permissions: contents: read jobs: + windows-arm64-kleidiai: + runs-on: windows-11-arm + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + upstream: [pinned, v0.4.0] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: recursive + - name: Setup ARM64 MSVC environment + uses: TheMrMilchmann/setup-msvc-dev@v4.1.0 + with: + arch: arm64 + - name: Select candidate upstream + if: matrix.upstream == 'v0.4.0' + shell: bash + run: | + git -C third_party/llama.cpp fetch --depth=1 origin tag v0.4.0 + git -C third_party/llama.cpp checkout --detach 5266f24da75dc449bd56cbed7addb9c8e4a6a73e + - name: Verify assembly policy regressions + run: python -m unittest discover -s tests -p test_kleidiai_windows.py + - name: Configure release compiler and optimized CPU + run: >- + cmake --preset windows-arm64-full + -DGGML_BLAS=OFF -DGGML_VULKAN=OFF -DGGML_CUDA=OFF + -DGGML_OPENCL=OFF -DGGML_OPENMP=OFF -DGGML_CCACHE=OFF + -DGGML_CPU_KLEIDIAI=ON -DLLAMADART_BUILD_TESTS=ON + - name: Build wrapper and CPU contracts + run: cmake --build --preset windows-arm64-full --parallel 4 + - name: Run native wrapper contracts + run: ctest --test-dir build/wa64 -C Release --output-on-failure + linux-artifact-contract: runs-on: ubuntu-latest strategy: diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a23037..11e517c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -138,6 +138,8 @@ if (APPLE) endif() add_subdirectory(third_party/llama.cpp) +include(cmake/kleidiai_windows.cmake) +llamadart_configure_kleidiai_windows_assembly() # Probe declarations without calling upstream symbols or running target code. # Recheck after switching upstream revisions in an existing build. diff --git a/cmake/kleidiai_windows.cmake b/cmake/kleidiai_windows.cmake new file mode 100644 index 0000000..ca8eb3c --- /dev/null +++ b/cmake/kleidiai_windows.cmake @@ -0,0 +1,28 @@ +# ClangCL's preprocessor emits GNU-style line markers, which armasm64 rejects +# with A2230. Ask the Visual Studio MARMASM preprocessing task to omit them; +# do not change the compiler, kernel sources, or assembly instruction set. +function(llamadart_configure_kleidiai_windows_assembly) + if (NOT MSVC OR NOT CMAKE_C_COMPILER_ID STREQUAL "Clang" OR + NOT CMAKE_GENERATOR MATCHES "^Visual Studio" OR NOT TARGET kleidiai) + return() + endif() + + # VS_SETTINGS applies to known source types starting with CMake 3.22. + if (CMAKE_VERSION VERSION_LESS 3.22) + message(FATAL_ERROR "ClangCL KleidiAI assembly requires CMake >= 3.22") + endif() + + get_target_property(kleidiai_source_dir kleidiai SOURCE_DIR) + get_target_property(kleidiai_sources kleidiai SOURCES) + foreach(source IN LISTS kleidiai_sources) + if (NOT IS_ABSOLUTE "${source}") + set(source "${kleidiai_source_dir}/${source}") + endif() + get_source_file_property(language "${source}" + TARGET_DIRECTORY kleidiai LANGUAGE) + if (language STREQUAL "ASM_MARMASM") + set_property(SOURCE "${source}" TARGET_DIRECTORY kleidiai APPEND + PROPERTY VS_SETTINGS "PreprocessSuppressLineNumbers=true") + endif() + endforeach() +endfunction() diff --git a/tests/fixtures/kleidiai_windows/CMakeLists.txt b/tests/fixtures/kleidiai_windows/CMakeLists.txt new file mode 100644 index 0000000..aff4e6e --- /dev/null +++ b/tests/fixtures/kleidiai_windows/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.22) +project(kleidiai_windows_metadata NONE) + +# These tests check CMake property scope and branch behavior on every host. +# Only a real Visual Studio build can validate the generated assembly pipeline. +include("${HELPER}") +if (NOT CASE STREQUAL "missing_target") + add_subdirectory(kleidiai) +endif() +set(MSVC TRUE) +set(CMAKE_C_COMPILER_ID Clang) +set(CMAKE_GENERATOR "Visual Studio 17 2022") +if (CASE STREQUAL "not_msvc") + set(MSVC FALSE) +elseif (CASE STREQUAL "not_clang") + set(CMAKE_C_COMPILER_ID MSVC) +elseif (CASE STREQUAL "not_visual_studio") + set(CMAKE_GENERATOR Ninja) +endif() + +llamadart_configure_kleidiai_windows_assembly() +if (TARGET kleidiai) + foreach(source kernel.S ordinary.c generic.S) + get_source_file_property(settings "${CMAKE_CURRENT_SOURCE_DIR}/kleidiai/${source}" + TARGET_DIRECTORY kleidiai VS_SETTINGS) + set(expected "ExistingMetadata=retained") + if (CASE STREQUAL "enabled" AND source STREQUAL "kernel.S") + list(APPEND expected "PreprocessSuppressLineNumbers=true") + endif() + if (NOT settings STREQUAL expected) + message(FATAL_ERROR "${source}: expected '${expected}', got '${settings}'") + endif() + endforeach() + get_target_property(sources kleidiai SOURCES) + if (NOT sources STREQUAL "kernel.S;ordinary.c;generic.S") + message(FATAL_ERROR "Kernel source list changed: ${sources}") + endif() +endif() + +# Stop before generation: the fixture intentionally models a Windows compiler +# and source language without requiring a Windows toolchain on this host. +message(FATAL_ERROR "METADATA_CONTRACT_PASSED") diff --git a/tests/fixtures/kleidiai_windows/kleidiai/CMakeLists.txt b/tests/fixtures/kleidiai_windows/kleidiai/CMakeLists.txt new file mode 100644 index 0000000..8f98f64 --- /dev/null +++ b/tests/fixtures/kleidiai_windows/kleidiai/CMakeLists.txt @@ -0,0 +1,6 @@ +add_library(kleidiai STATIC kernel.S ordinary.c generic.S) +set_source_files_properties(kernel.S PROPERTIES LANGUAGE ASM_MARMASM) +set_source_files_properties(ordinary.c PROPERTIES LANGUAGE C) +set_source_files_properties(generic.S PROPERTIES LANGUAGE ASM) +set_source_files_properties(kernel.S ordinary.c generic.S + PROPERTIES VS_SETTINGS "ExistingMetadata=retained") diff --git a/tests/test_kleidiai_windows.py b/tests/test_kleidiai_windows.py new file mode 100644 index 0000000..59887a4 --- /dev/null +++ b/tests/test_kleidiai_windows.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(shutil.which("cmake"), "CMake is required") +class KleidiAIWindowsAssemblyTest(unittest.TestCase): + @unittest.skipUnless(shutil.which("clang-cl"), "ClangCL is required") + def test_suppression_removes_clang_linemarkers_without_changing_tokens(self) -> None: + outputs = {} + for option in ("/E", "/EP"): + result = subprocess.run( + [ + "clang-cl", "--target=aarch64-pc-windows-msvc", + "/nologo", "/TC", option, "-", + ], + input="#define VALUE 42\nVALUE\n", + text=True, + capture_output=True, + check=True, + ) + outputs[option] = result.stdout + self.assertIn('# 1 ""', outputs["/E"]) + self.assertNotIn("#", outputs["/EP"]) + tokens = "\n".join( + line for line in outputs["/E"].splitlines() + if not line.startswith("#") + ).strip() + self.assertEqual(tokens, outputs["/EP"].strip()) + + def test_preprocessing_metadata_is_scoped_to_clangcl_marmasm(self) -> None: + for case in ( + "enabled", + "not_msvc", + "not_clang", + "not_visual_studio", + "missing_target", + ): + with self.subTest(case=case), tempfile.TemporaryDirectory() as build: + result = subprocess.run( + [ + "cmake", + "-S", str(ROOT / "tests/fixtures/kleidiai_windows"), + "-B", build, + f"-DHELPER={ROOT / 'cmake/kleidiai_windows.cmake'}", + f"-DCASE={case}", + ], + text=True, + capture_output=True, + check=False, + ) + output = result.stdout + result.stderr + self.assertNotEqual(0, result.returncode) + self.assertIn("METADATA_CONTRACT_PASSED", output, output) + + +if __name__ == "__main__": + unittest.main() From aa27406432c98a2904a33265cc8f284960bc4a23 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 6 Sep 2026 08:12:39 -0400 Subject: [PATCH 2/4] fix: qualify runtime-dispatched ARM64 kernels and assembler dialect --- .github/workflows/native_release.yml | 13 +- .github/workflows/validate_wrapper.yml | 77 ++++++++ CMakeLists.txt | 22 +++ cmake/kleidiai_windows.cmake | 9 +- docs/platform_backend_strategy.md | 22 ++- .../fixtures/kleidiai_windows/CMakeLists.txt | 2 + tests/kleidiai_dispatch_test.cpp | 157 +++++++++++++++ tests/test_android_cpu_isa.py | 128 ++++++++++++ tests/test_kleidiai_windows.py | 35 ++++ tools/validate_android_cpu_isa.py | 183 ++++++++++++++++++ 10 files changed, 637 insertions(+), 11 deletions(-) create mode 100644 tests/kleidiai_dispatch_test.cpp create mode 100644 tests/test_android_cpu_isa.py create mode 100644 tools/validate_android_cpu_isa.py diff --git a/.github/workflows/native_release.yml b/.github/workflows/native_release.yml index 3707e5c..053813e 100644 --- a/.github/workflows/native_release.yml +++ b/.github/workflows/native_release.yml @@ -243,14 +243,13 @@ jobs: ARMV82_LIB="$OUT_DIR/libggml-cpu-android_armv8.2_2.so" test -f "$ARMV82_LIB" || { echo "Missing $ARMV82_LIB"; exit 1; } - ARMV82_DISASM="$(mktemp)" SCORE_SYMS_FILE="$(mktemp)" - trap 'rm -f "${DISASM_FILE:-}" "${ARMV82_DISASM:-}" "${SCORE_SYMS_FILE:-}"' EXIT - "$OBJDUMP" -d --no-show-raw-insn "$ARMV82_LIB" > "$ARMV82_DISASM" - if grep -Eq '\b(addvl|ptrue|cntw|rdvl|ld1b|st1b)\b' "$ARMV82_DISASM"; then - echo "Found unexpected SVE instructions in $ARMV82_LIB" >&2 - exit 1 - fi + trap 'rm -f "${DISASM_FILE:-}" "${SCORE_SYMS_FILE:-}"' EXIT + python3 tools/validate_android_cpu_isa.py \ + --objdump "$OBJDUMP" --readelf "$READELF" \ + --llama-source third_party/llama.cpp \ + --kleidiai-source "build/android-arm64-v8a-${{ matrix.backend }}-android_armv8.2_2/_deps/kleidiai-src" \ + "$ARMV82_LIB" if [ "${{ matrix.include_core }}" = "true" ]; then expected_cpu_variants=( diff --git a/.github/workflows/validate_wrapper.yml b/.github/workflows/validate_wrapper.yml index 0c80576..8168c9a 100644 --- a/.github/workflows/validate_wrapper.yml +++ b/.github/workflows/validate_wrapper.yml @@ -11,6 +11,7 @@ on: - 'tests/**' - 'tools/tts_smoke.cpp' - 'tools/build.py' + - 'tools/validate_android_cpu_isa.py' - 'tools/linux_dlopen_smoke.c' - 'tools/package_linux_artifact.py' - 'tools/validate_linux_artifact.py' @@ -28,6 +29,7 @@ on: - 'tests/**' - 'tools/tts_smoke.cpp' - 'tools/build.py' + - 'tools/validate_android_cpu_isa.py' - 'tools/linux_dlopen_smoke.c' - 'tools/package_linux_artifact.py' - 'tools/validate_linux_artifact.py' @@ -38,6 +40,81 @@ permissions: contents: read jobs: + android-arm64-isa: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: recursive + - name: Select candidate upstream + run: | + git -C third_party/llama.cpp fetch --depth=1 origin tag v0.4.0 + git -C third_party/llama.cpp checkout --detach 5266f24da75dc449bd56cbed7addb9c8e4a6a73e + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@v4 + with: + packages: platform-tools ndk;28.2.13676358 + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y ninja-build + - name: Build and audit the release ARMv8.2 CPU variant + run: | + export ANDROID_NDK_HOME="${ANDROID_SDK_ROOT}/ndk/28.2.13676358" + python3 - <<'PY' + import os, sys + from pathlib import Path + sys.path.insert(0, 'tools') + import build + name, arch, features = build.ANDROID_ARM64_CPU_VARIANTS[2] + build.build_android_arm64_cpu_variant( + name, arch, features, build_dir=Path('build/android-isa').resolve(), + ndk=Path(os.environ['ANDROID_NDK_HOME']), env=dict(os.environ), jobs=4) + PY + LLVM="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" + python3 tools/validate_android_cpu_isa.py \ + --objdump "$LLVM/llvm-objdump" --readelf "$LLVM/llvm-readelf" \ + --llama-source third_party/llama.cpp \ + --kleidiai-source build/android-isa/_deps/kleidiai-src \ + build/android-isa/bin/libggml-cpu.so + + kleidiai-dispatch-emulated: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: recursive + - name: Select candidate upstream + run: | + git -C third_party/llama.cpp fetch --depth=1 origin tag v0.4.0 + git -C third_party/llama.cpp checkout --detach 5266f24da75dc449bd56cbed7addb9c8e4a6a73e + - name: Install cross compiler and CPU emulator + run: sudo apt-get update && sudo apt-get install -y ninja-build gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user + - name: Build the real baseline CPU and dispatch test + run: >- + cmake -S . -B build/kleidiai-dispatch -G Ninja + -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64 + -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc + -DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ + -DCMAKE_BUILD_TYPE=Release -DGGML_CPU_ARM_ARCH=armv8-a + -DGGML_CPU_KLEIDIAI=ON -DGGML_OPENMP=OFF + -DGGML_VULKAN=OFF -DGGML_BLAS=OFF -DGGML_CCACHE=OFF + -DLLAMADART_BUILD_KLEIDIAI_TESTS=ON + - name: Compile dispatch and quantized compute qualification + run: cmake --build build/kleidiai-dispatch --target llamadart_kleidiai_dispatch_test --parallel 4 + - name: Run without SVE on baseline and optimized CPU profiles + run: | + for CPU in cortex-a53 max,sve=off,sme=off; do + qemu-aarch64 -cpu "$CPU" -L /usr/aarch64-linux-gnu \ + -E LD_LIBRARY_PATH="$PWD/build/kleidiai-dispatch/bin" \ + build/kleidiai-dispatch/bin/llamadart_kleidiai_dispatch_test + done + windows-arm64-kleidiai: runs-on: windows-11-arm timeout-minutes: 30 diff --git a/CMakeLists.txt b/CMakeLists.txt index 11e517c..a842e37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,7 @@ find_package(Threads REQUIRED) option(LLAMADART_BUILD_TESTS "Build libllamadart wrapper tests" OFF) option(LLAMADART_BUILD_TTS_SMOKE "Build the local Qwen3-TTS smoke tool" OFF) +option(LLAMADART_BUILD_KLEIDIAI_TESTS "Build standalone KleidiAI dispatch and compute qualification" OFF) # Keep all targets PIC-safe for shared linking. set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -232,6 +233,27 @@ if (LLAMADART_BUILD_TESTS) add_test(NAME llamadart_mtmd_compat_test COMMAND llamadart_mtmd_compat_test) endif() +if (LLAMADART_BUILD_KLEIDIAI_TESTS) + if (NOT TARGET kleidiai OR NOT TARGET ggml-cpu) + message(FATAL_ERROR "KleidiAI qualification requires upstream standalone kleidiai and ggml-cpu targets") + endif() + enable_testing() + add_executable(llamadart_kleidiai_dispatch_test tests/kleidiai_dispatch_test.cpp) + target_compile_features(llamadart_kleidiai_dispatch_test PRIVATE cxx_std_17) + target_include_directories(llamadart_kleidiai_dispatch_test PRIVATE + third_party/llama.cpp/ggml/include + third_party/llama.cpp/ggml/src + third_party/llama.cpp/ggml/src/ggml-cpu/kleidiai) + set_target_properties(llamadart_kleidiai_dispatch_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + # ggml-cpu is a loadable MODULE on Android. Link its actual file to test + # the shipped selectors, not a second test-only copy of their source. + target_link_libraries(llamadart_kleidiai_dispatch_test PRIVATE + "$" ggml ggml-base) + add_dependencies(llamadart_kleidiai_dispatch_test ggml-cpu) + add_test(NAME llamadart_kleidiai_dispatch_test COMMAND llamadart_kleidiai_dispatch_test) +endif() + if (LLAMADART_BUILD_TTS_SMOKE) add_executable(llamadart_tts_smoke tools/tts_smoke.cpp) target_compile_features(llamadart_tts_smoke PRIVATE cxx_std_17) diff --git a/cmake/kleidiai_windows.cmake b/cmake/kleidiai_windows.cmake index ca8eb3c..72d7d2a 100644 --- a/cmake/kleidiai_windows.cmake +++ b/cmake/kleidiai_windows.cmake @@ -1,6 +1,8 @@ # ClangCL's preprocessor emits GNU-style line markers, which armasm64 rejects -# with A2230. Ask the Visual Studio MARMASM preprocessing task to omit them; -# do not change the compiler, kernel sources, or assembly instruction set. +# with A2230. Some kernels also select GNU assembly syntax when __clang__ is +# defined, even though MSBuild invokes armasm64, not Clang's assembler. Configure +# only the MARMASM preprocessing task: omit line markers and select upstream's +# existing MSVC assembly dialect. C/C++ compilation remains unchanged. function(llamadart_configure_kleidiai_windows_assembly) if (NOT MSVC OR NOT CMAKE_C_COMPILER_ID STREQUAL "Clang" OR NOT CMAKE_GENERATOR MATCHES "^Visual Studio" OR NOT TARGET kleidiai) @@ -22,7 +24,8 @@ function(llamadart_configure_kleidiai_windows_assembly) TARGET_DIRECTORY kleidiai LANGUAGE) if (language STREQUAL "ASM_MARMASM") set_property(SOURCE "${source}" TARGET_DIRECTORY kleidiai APPEND - PROPERTY VS_SETTINGS "PreprocessSuppressLineNumbers=true") + PROPERTY VS_SETTINGS "PreprocessSuppressLineNumbers=true" + "UndefinePreprocessorDefinitions=__clang__\;%(UndefinePreprocessorDefinitions)") endif() endforeach() endfunction() diff --git a/docs/platform_backend_strategy.md b/docs/platform_backend_strategy.md index 8531b80..c021d06 100644 --- a/docs/platform_backend_strategy.md +++ b/docs/platform_backend_strategy.md @@ -23,7 +23,27 @@ - Kleidi is enabled on Linux arm64 and Windows arm64 in this pipeline. - Android arm64 keeps Kleidi on by building each CPU variant in its own isolated configuration so higher-tier ISA flags do not leak into lower-tier - variant binaries. + ggml code. With llama.cpp v0.4.0, standalone KleidiAI also contains kernels + selected by runtime CPU features. The Android ISA audit permits scalable + instructions only inside exact reviewed ELF function ranges and binds that + exception to the complete audited ggml/Kleidi source fingerprints. Unknown + ranges or changed source fail closed; legacy artifacts without scalable code + continue through the strict path. Never refresh fingerprints without reviewing + feature detection, kernel tables and callers, and passing compiled dispatch + tests and non-SVE compute qualification. +- Windows ARM64 retains ClangCL and all Kleidi kernels. Source-local Visual + Studio metadata selects the native ARMASM preprocessing dialect and suppresses + incompatible line markers; C/C++ compiler behavior is unchanged. + +## ARM64 upgrade qualification + +`Validate Wrapper` checks pinned and candidate v0.4.0 Windows ARM64 builds, +the actual Android ARMv8.2 artifact, and compiled Kleidi selectors plus quantized +matrix computation under non-SVE QEMU profiles. QEMU is deterministic CPU +compatibility evidence, not physical-device or GPU-performance evidence. +Use `-DLLAMADART_BUILD_KLEIDIAI_TESTS=ON` with standalone-Kleidi upstream to +build `llamadart_kleidiai_dispatch_test`; it links the actual CPU module. +The Android artifact check is `tools/validate_android_cpu_isa.py --help`. - Non-Apple: keep backends as separate dynamic libraries (`GGML_BACKEND_DL=ON`). ## Runtime Packaging Model diff --git a/tests/fixtures/kleidiai_windows/CMakeLists.txt b/tests/fixtures/kleidiai_windows/CMakeLists.txt index aff4e6e..f11a1ad 100644 --- a/tests/fixtures/kleidiai_windows/CMakeLists.txt +++ b/tests/fixtures/kleidiai_windows/CMakeLists.txt @@ -26,6 +26,8 @@ if (TARGET kleidiai) set(expected "ExistingMetadata=retained") if (CASE STREQUAL "enabled" AND source STREQUAL "kernel.S") list(APPEND expected "PreprocessSuppressLineNumbers=true") + list(APPEND expected + "UndefinePreprocessorDefinitions=__clang__\;%(UndefinePreprocessorDefinitions)") endif() if (NOT settings STREQUAL expected) message(FATAL_ERROR "${source}: expected '${expected}', got '${settings}'") diff --git a/tests/kleidiai_dispatch_test.cpp b/tests/kleidiai_dispatch_test.cpp new file mode 100644 index 0000000..775b425 --- /dev/null +++ b/tests/kleidiai_dispatch_test.cpp @@ -0,0 +1,157 @@ +// Execute against the real compiled CPU backend, never a copied selector. +// Synthetic capability masks only inspect tables; they must not run kernels. +#include "ggml.h" +#include "ggml-alloc.h" +#include "ggml-backend.h" +#include "ggml-cpu.h" +#include "ggml-feats.h" +#include "kernels.h" +#include "kleidiai.h" + +#include +#include +#include +#include +#include + +static void require(bool condition, const char * message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + std::exit(1); + } +} + +static int expected_mask(int mask, ggml_type type) { + if (type == GGML_TYPE_Q4_0) { + if ((mask & 48) == 48) return 48; // SME2 + FP16 +#ifndef __APPLE__ + if ((mask & 7) == 7) return 7; // SVE + I8MM + DOTPROD + if ((mask & 3) == 3) return 3; +#endif + if (mask & 1) return 1; + } else if (type == GGML_TYPE_Q8_0) { + if (mask & 16) return 16; + if (mask & 8) return 8; + if ((mask & 3) == 3) return 3; + if (mask & 1) return 1; + } else if (type == GGML_TYPE_F32) { + if (mask & 16) return 16; + if (mask & 8) return 8; + } else if (type == GGML_TYPE_F16 && (mask & 16)) { + return 16; + } + return -1; +} + +static void check_selection(ggml_kleidiai_kernels * kernel, int expected, int mask) { + require((kernel != nullptr) == (expected >= 0), "selector null/fallback mismatch"); + if (kernel) { + require(static_cast(kernel->required_cpu) == expected, "selector chose wrong capability family"); + require((mask & kernel->required_cpu) == kernel->required_cpu, "selector bypassed required feature"); + require(kernel->gemm.run_kernel_ex && kernel->rhs_info.pack_func_ex, "selected incomplete kernel"); + } +} + +static void selector_tests() { + const ggml_type types[] = { GGML_TYPE_Q4_0, GGML_TYPE_Q8_0, GGML_TYPE_F32, GGML_TYPE_F16, GGML_TYPE_Q5_0 }; + for (int bits = 0; bits < 64; ++bits) { + auto mask = static_cast(bits); + check_selection(ggml_kleidiai_select_kernels_q4_0(mask), expected_mask(bits, GGML_TYPE_Q4_0), bits); + check_selection(ggml_kleidiai_select_kernels_q8_0(mask), expected_mask(bits, GGML_TYPE_Q8_0), bits); + check_selection(ggml_kleidiai_select_kernels_f32(mask), expected_mask(bits, GGML_TYPE_F32), bits); + for (auto type : types) { + ggml_tensor weights = {}, input = {}, output = {}; + weights.type = type; + input.type = output.type = GGML_TYPE_F32; + output.op = GGML_OP_MUL_MAT; + output.src[0] = &weights; + output.src[1] = &input; + check_selection(ggml_kleidiai_select_kernels(mask, &output), expected_mask(bits, type), bits); + output.op = GGML_OP_ADD; + require(!ggml_kleidiai_select_kernels(mask, &output), "wrong operation accepted"); + output.op = GGML_OP_MUL_MAT; + output.src[1] = nullptr; + require(!ggml_kleidiai_select_kernels(mask, &output), "missing source accepted"); + output.src[1] = &input; + output.src[0] = nullptr; + require(!ggml_kleidiai_select_kernels(mask, &output), "missing weights accepted"); + output.src[0] = &weights; + input.type = GGML_TYPE_I32; + require(!ggml_kleidiai_select_kernels(mask, &output), "wrong input type accepted"); + input.type = GGML_TYPE_F32; + output.type = GGML_TYPE_F16; + require(!ggml_kleidiai_select_kernels(mask, &output), "wrong output type accepted"); + } + } + std::puts("PASS: real selectors, all 64 feature masks, positive and negative tensor paths"); +} + +static void matmul_test(ggml_type type, int columns, bool use_kleidiai) { + constexpr int k = 64, rows = 16; + auto backend = ggml_backend_cpu_init(); + require(backend != nullptr, "CPU backend unavailable"); + ggml_backend_cpu_set_n_threads(backend, 2); + ggml_init_params params = { 1024 * 1024, nullptr, true }; + auto weights_ctx = ggml_init(params); + auto ctx = ggml_init(params); + require(weights_ctx && ctx, "context allocation failed"); + auto weights = ggml_new_tensor_2d(weights_ctx, type, k, rows); + auto input = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, k, columns); + auto output = ggml_mul_mat(ctx, weights, input); + auto graph = ggml_new_graph(ctx); + ggml_build_forward_expand(graph, output); + // Explicitly use the production KleidiAI repacking buffer, so this cannot + // pass by exercising only the ordinary GGML fallback implementation. + auto weights_buffer = ggml_backend_alloc_ctx_tensors_from_buft(weights_ctx, + use_kleidiai ? ggml_backend_cpu_kleidiai_buffer_type() : ggml_backend_cpu_buffer_type()); + auto buffer = ggml_backend_alloc_ctx_tensors(ctx, backend); + require(weights_buffer && buffer, "CPU buffer unavailable"); + require((weights->extra != nullptr) == use_kleidiai, "wrong optimized/fallback buffer path"); + std::vector raw(k * rows), restored(k * rows), inputs(k * columns); + for (size_t i = 0; i < raw.size(); ++i) raw[i] = (static_cast(i * 13 % 17) - 8) * 0.125f; + for (size_t i = 0; i < inputs.size(); ++i) inputs[i] = (i * 7 % 11 < 5) ? -1.0f : 1.0f; + std::vector quantized(ggml_nbytes(weights)); + require(ggml_quantize_chunk(type, raw.data(), quantized.data(), 0, rows, k, nullptr) == quantized.size(), + "quantization size mismatch"); + ggml_get_type_traits(type)->to_float(quantized.data(), restored.data(), restored.size()); + ggml_backend_tensor_set(weights, quantized.data(), 0, quantized.size()); + ggml_backend_tensor_set(input, inputs.data(), 0, inputs.size() * sizeof(float)); + require(ggml_backend_graph_compute(backend, graph) == GGML_STATUS_SUCCESS, "KleidiAI graph compute failed"); + std::vector actual(rows * columns); + ggml_backend_tensor_get(output, actual.data(), 0, actual.size() * sizeof(float)); + for (int n = 0; n < columns; ++n) { + for (int m = 0; m < rows; ++m) { + float expected = 0; + for (int i = 0; i < k; ++i) expected += restored[m * k + i] * inputs[n * k + i]; + if (!std::isfinite(actual[n * rows + m]) || std::fabs(actual[n * rows + m] - expected) > 0.005f) { + std::fprintf(stderr, "%s columns=%d row=%d col=%d expected=%f actual=%f\n", + ggml_type_name(type), columns, m, n, expected, actual[n * rows + m]); + require(false, "KleidiAI result differs from scalar dequantized reference"); + } + } + } + std::printf("PASS: %s %s matmul %dx%dx%d vs scalar reference\n", ggml_type_name(type), + use_kleidiai ? "KleidiAI repack +" : "baseline fallback", rows, columns, k); + ggml_backend_buffer_free(buffer); + ggml_backend_buffer_free(weights_buffer); + ggml_free(ctx); + ggml_free(weights_ctx); + ggml_backend_free(backend); +} + +int main(int argc, char ** argv) { + require(argc == 1 || (argc == 2 && std::strcmp(argv[1], "--selectors-only") == 0), "unknown test arguments"); + selector_tests(); + // Synthetic masks must never cause advanced kernels to execute. + if (argc > 1) return 0; + const auto runtime = ggml_feats_get_arch64_runtime(); + std::printf("Runtime: dotprod=%d i8mm=%d sve=%d sme=%d sme2=%d\n", runtime.has_dotprod, + runtime.has_i8mm, runtime.has_sve, runtime.has_sme, runtime.has_sme2); + // Both Q4_0 and Q8_0 have NEON dot-product implementations. Without it, + // exercise the ordinary CPU fallback, never force unsupported kernels. + const bool use_kleidiai = runtime.has_dotprod; + for (auto type : { GGML_TYPE_Q4_0, GGML_TYPE_Q8_0 }) { + for (int columns : { 1, 4, 9 }) matmul_test(type, columns, use_kleidiai); + } + return 0; +} diff --git a/tests/test_android_cpu_isa.py b/tests/test_android_cpu_isa.py new file mode 100644 index 0000000..5584603 --- /dev/null +++ b/tests/test_android_cpu_isa.py @@ -0,0 +1,128 @@ +import importlib.util +import contextlib +import io +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +SPEC = importlib.util.spec_from_file_location( + "android_cpu_isa", Path(__file__).resolve().parents[1] / "tools/validate_android_cpu_isa.py" +) +audit = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(audit) + + +def symbol(name="audited", start=0x100, size=8): + return f" 1: {start:016x} {size} FUNC GLOBAL DEFAULT 14 {name}" + + +def disassembly(body): + return "test.so: file format elf64-littleaarch64\nDisassembly of section .text:\n" + body + + +class AndroidCpuIsaTest(unittest.TestCase): + def check(self, symbols, body, allowed=frozenset({"audited"})): + return audit.validate_disassembly(symbols, disassembly(body), allowed) + + def test_exact_function_range_contains_nested_assembly_labels(self): + result = self.check(symbol(), "100: 04bf5020 rdvl x0, #1\n104 :\n104: d65f03c0 ret") + self.assertEqual(result, (2, 1, 1)) + + def test_baseline_without_kleidiai_remains_strictly_valid(self): + self.assertEqual(self.check(symbol("baseline"), "100: d65f03c0 ret", frozenset()), (1, 0, 0)) + + def test_sve_in_baseline_is_rejected(self): + with self.assertRaisesRegex(ValueError, "escaped"): + self.check(symbol() + "\n" + symbol("baseline", 0x200), + "100: 04bf5020 rdvl x0, #1\n200: 2518e3e0 ptrue p0.b") + + def test_kai_prefix_is_not_an_exception(self): + with self.assertRaisesRegex(ValueError, "escaped"): + self.check(symbol() + "\n" + symbol("kai_new_unreviewed", 0x200), + "100: 04bf5020 rdvl x0, #1\n200: 2518e3e0 ptrue p0.b") + + def test_range_end_is_exclusive(self): + with self.assertRaisesRegex(ValueError, "no sized function"): + self.check(symbol(), "108: 04bf5020 rdvl x0, #1") + + def test_ambiguous_overlapping_function_fails(self): + with self.assertRaisesRegex(ValueError, "escaped"): + self.check(symbol() + "\n" + symbol("baseline"), "100: 04bf5020 rdvl x0, #1") + + def test_missing_or_zero_sized_optimized_function_fails(self): + for symbols in (symbol("baseline"), symbol("audited", size=0), ""): + with self.subTest(symbols=symbols), self.assertRaises(ValueError): + self.check(symbols, "100: 04bf5020 rdvl x0, #1") + + def test_optimized_kernel_cannot_disappear(self): + with self.assertRaisesRegex(ValueError, "lost its scalable"): + self.check(symbol(), "100: d65f03c0 ret") + + def test_empty_wrong_arch_unknown_or_malformed_disassembly_fails(self): + for body in ("", "100: 00000000 ", "100: 00000000 .word 0", "100: rdvl x0, #1"): + with self.subTest(body=body), self.assertRaises(ValueError): + self.check(symbol(), body) + with self.assertRaisesRegex(ValueError, "AArch64"): + audit.validate_disassembly(symbol(), "file format elf64-x86-64") + + def test_scalable_forms_beyond_original_six_mnemonics(self): + for instruction in ("smstart sm", "smstop", "rdsvl x0, #1", "cntd x0", "incw x0", + "addsvl x0, x0, #1", "fmopa za0.s, p0/m, p1/m, z0.s, z1.s", + "ldr z0, [x0]", "str p0, [x0]", "zero {za}"): + with self.subTest(instruction=instruction): + self.assertTrue(audit.is_scalable(0, instruction)) + self.assertTrue(audit.is_scalable(0x04000000, "future_sve_alias x0")) + self.assertFalse(audit.is_scalable(0xD65F03C0, "ret")) + + def test_duplicate_instruction_is_rejected(self): + with self.assertRaisesRegex(ValueError, "Duplicate"): + self.check(symbol(), "100: 04bf5020 rdvl x0, #1\n100: d65f03c0 ret") + + def test_dispatch_or_probe_source_mutation_is_rejected(self): + with mock.patch.object(audit, "tree_digest", return_value="changed"): + with self.assertRaisesRegex(ValueError, "Unaudited"): + audit.validate_sources(Path("llama"), Path("kleidiai")) + + def test_tree_hash_binds_names_contents_and_new_callers(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "probe.cpp" + first.write_text("guarded") + original = audit.tree_digest(root) + first.write_text("bypassed") + self.assertNotEqual(original, audit.tree_digest(root)) + first.write_text("guarded") + first.rename(root / "caller.cpp") + self.assertNotEqual(original, audit.tree_digest(root)) + (root / "probe.cpp").write_text("guarded") + self.assertNotEqual(original, audit.tree_digest(root)) + + def test_missing_source_tree_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(ValueError, "empty"): + audit.tree_digest(directory) + + def test_cli_legacy_baseline_does_not_require_kleidiai_source(self): + args = ["audit", "--objdump", "objdump", "--readelf", "readelf", + "--llama-source", "absent", "--kleidiai-source", "absent", "test.so"] + outputs = [symbol("baseline"), disassembly("100: d65f03c0 ret")] + with mock.patch("sys.argv", args), mock.patch.object(audit.subprocess, "check_output", side_effect=outputs), \ + mock.patch.object(audit, "validate_sources") as validate_sources, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(audit.main(), 0) + validate_sources.assert_not_called() + + def test_cli_scalable_artifact_requires_dispatch_source_audit(self): + args = ["audit", "--objdump", "objdump", "--readelf", "readelf", + "--llama-source", "absent", "--kleidiai-source", "absent", "test.so"] + outputs = [symbol(), disassembly("100: 04bf5020 rdvl x0, #1")] + with mock.patch("sys.argv", args), mock.patch.object(audit.subprocess, "check_output", side_effect=outputs), \ + mock.patch.object(audit, "validate_sources", side_effect=ValueError("unaudited")) as validate_sources, \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(audit.main(), 1) + validate_sources.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_kleidiai_windows.py b/tests/test_kleidiai_windows.py index 59887a4..68936a0 100644 --- a/tests/test_kleidiai_windows.py +++ b/tests/test_kleidiai_windows.py @@ -12,6 +12,41 @@ @unittest.skipUnless(shutil.which("cmake"), "CMake is required") class KleidiAIWindowsAssemblyTest(unittest.TestCase): + @unittest.skipUnless(shutil.which("clang-cl"), "ClangCL is required") + def test_marmasm_preprocessing_selects_native_assembly_dialect(self) -> None: + # Mirror the two upstream assembly dialect conditions that caused A2034. + source = """ +#if defined(_MSC_VER) && !defined(__clang__) +AREA code, CODE, READONLY +END +#elif defined(_MSC_VER) && defined(__clang__) +.text +.globl kernel +#else +#error unexpected target +#endif +""" + outputs = {} + for undefine in (False, True): + result = subprocess.run( + [ + "clang-cl", "--target=aarch64-pc-windows-msvc", + "/nologo", "/TC", "/EP", + *(["/U__clang__"] if undefine else []), "-", + ], + input=source, + text=True, + capture_output=True, + check=True, + ) + outputs[undefine] = result.stdout + self.assertIn(".text", outputs[False]) + self.assertIn("AREA code, CODE, READONLY", outputs[True]) + self.assertIn("END", outputs[True]) + self.assertNotIn(".text", outputs[True]) + self.assertNotIn(".globl", outputs[True]) + self.assertNotIn("#", outputs[True]) + @unittest.skipUnless(shutil.which("clang-cl"), "ClangCL is required") def test_suppression_removes_clang_linemarkers_without_changing_tokens(self) -> None: outputs = {} diff --git a/tools/validate_android_cpu_isa.py b/tools/validate_android_cpu_isa.py new file mode 100644 index 0000000..f06f02e --- /dev/null +++ b/tools/validate_android_cpu_isa.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Audit the non-SVE Android armv8.2 CPU artifact, including dispatched KleidiAI. + +This is a bounded SVE/SME containment check, not a general ISA verifier or a +proof of runtime reachability. PR qualification runs the compiled selectors and +matmuls; release fingerprints bind the prequalified source. Real-device matmul +evidence is a separate qualification layer. +Source fingerprints deliberately fail closed on upstream changes: do not refresh +them without reviewing feature detection, every selector/table and its callers. +""" + +import argparse +import hashlib +from pathlib import Path +import re +import subprocess +import sys + + +# llama.cpp v0.4.0 / KleidiAI v1.24.0. Bind the complete source subtrees, not +# merely a selector fragment: a new caller can invalidate a dispatch audit. +SOURCE_TREES = { + "ggml/src": "c4dc92a7d95ebfad7f5f55e75be2ae773b7d95faf72a9581c9479c42bc41bca0", + "kai": "64189fc613c1c4c3aaeeb6bb12b38d85dd6728cafd2261a5a88f1b77b10fe59c", +} + +# Exact ELF STT_FUNC ranges; never allow by kai_* prefix or disassembly label. +# Assembly kernels have nested local labels, which are NOT function boundaries. +# Vector-length helpers are reached by the selected kernels' size/packing APIs. +DISPATCHED_FUNCTIONS = frozenset(""" +kai_get_sme_vector_length_u8 +kai_get_sve_vector_length_u8 +kai_kernel_lhs_pack_f32p2vlx1_f32_sme +kai_kernel_matmul_clamp_f32_bf16p2vlx2_bf16p2vlx2_2vlx2vl_sme2_mopa +kai_kernel_matmul_clamp_f32_f16p1vlx2_qsi4c32p4vlx2_1vlx4vl_sme2_mopa +kai_kernel_matmul_clamp_f32_f32_f32p2vlx1b_1x16vl_sme2_mla +kai_kernel_matmul_clamp_f32_f32p2vlx1_f32p2vlx1b_2vlx2vl_sme_mopa +kai_kernel_matmul_clamp_f32_f32p2vlx1_f32p2vlx1biasf32_sme2_mopa +kai_kernel_matmul_clamp_f32_qai8dxp1vlx4_qsi8cxp4vlx4_1vlx4vl_sme2_mopa +kai_kernel_matmul_clamp_f32_qai8dxp1vlx4_qsi8cxp4vlx4_1vlx4vl_sme_mopa +kai_kernel_matmul_clamp_f32_qai8dxp1x4_qsi8cxp4vlx4_1x4vl_sme2_dot +kai_kernel_matmul_clamp_f32_qai8dxp1x4_qsi8cxp4vlx4_1x4vl_sme_dot +kai_kernel_matmul_clamp_f32_qsi8d32p1x8_qsi4c32p8x8_1x8_sve_dotprod +kai_kernel_matmul_clamp_f32_qsi8d32p4x8_qsi4c32p8x8_16x8_sve_i8mm +kai_kernel_rhs_pack_nxk_f32p2vlx1biasf32_f32_f32_sme +kai_run_lhs_pack_bf16p2vlx2_f32_sme +kai_run_matmul_clamp_f32_qsi8d32p1x4_qsi4c32p4vlx4_1x4vl_sme2_sdot +kai_run_rhs_pack_kxn_bf16p2vlx2b_f32_x32_sme +""".split()) + +SCALABLE_REG = re.compile(r"\b(?:z\d+|p\d+|pn\d+|za\d*[hv]?|zt\d+)\b") +SCALABLE_SCALAR = re.compile( + r"^(?:add(?:s?v|s?p)l|rd(?:s?v)l|cnts?[bhwd]|(?:sq|uq)?(?:inc|dec)[bhwd]|" + r"setffr|smstart|smstop)$" +) +SYMBOL = re.compile( + r"\s*\d+:\s+([0-9a-fA-F]+)\s+(\d+)\s+FUNC\s+\S+\s+\S+\s+(\S+)\s+(\S+)" +) +INSTRUCTION = re.compile(r"\s*([0-9a-fA-F]+):\s+([0-9a-fA-F]{8})\s+(.+)") + + +def tree_digest(root): + root = Path(root) + paths = sorted(path for path in root.rglob("*") if path.is_file()) + if not paths: + raise ValueError(f"Missing or empty audited source tree: {root}") + digest = hashlib.sha256() + for path in paths: + if path.is_symlink(): + raise ValueError(f"Symlink in audited source tree: {path}") + name = path.relative_to(root).as_posix().encode() + data = path.read_bytes() + digest.update(len(name).to_bytes(8, "big")) + digest.update(name) + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return digest.hexdigest() + + +def validate_sources(llama_source, kleidiai_source): + for base, subtree in ((llama_source, "ggml/src"), (kleidiai_source, "kai")): + actual = tree_digest(Path(base) / subtree) + if actual != SOURCE_TREES[subtree]: + raise ValueError(f"Unaudited {subtree} source fingerprint {actual}; review dispatch before updating policy") + + +def function_ranges(symbol_text): + result = [] + for line in symbol_text.splitlines(): + match = SYMBOL.fullmatch(line) + if match and match[3] != "UND" and int(match[2]) > 0: + start, size = int(match[1], 16), int(match[2]) + if start % 4 or size % 4: + raise ValueError(f"Unaligned AArch64 function: {match[4]}") + result.append((start, start + size, match[4])) + if not result: + raise ValueError("No defined, sized ELF function symbols") + return result + + +def is_scalable(word, instruction): + # Arm's SVE major encoding group catches all SVE forms, including scalar + # operands and aliases. SME also occupies other groups, so inspect its + # registers and streaming-mode/vector-length scalar instructions below. + return ((word & 0x1E000000) == 0x04000000 or + bool(SCALABLE_REG.search(instruction.split("//", 1)[0])) or + bool(SCALABLE_SCALAR.fullmatch(instruction.split()[0]))) + + +def validate_disassembly(symbol_text, disassembly, allowed=DISPATCHED_FUNCTIONS): + if "file format elf64-littleaarch64" not in disassembly: + raise ValueError("Expected an AArch64 ELF artifact") + ranges = function_ranges(symbol_text) + missing = allowed - {name for _, _, name in ranges} + if missing: + raise ValueError("Missing audited optimized functions: " + ", ".join(sorted(missing))) + seen_addresses = set() + advanced = {} + failures = [] + for line in disassembly.splitlines(): + match = INSTRUCTION.fullmatch(line) + if not match: + # Do not silently ignore changed tool formatting or undecoded data. + if re.match(r"\s*[0-9a-fA-F]+:", line): + raise ValueError(f"Unparsed instruction: {line.strip()}") + continue + address, word = int(match[1], 16), int(match[2], 16) + instruction = match[3].strip() + if address in seen_addresses or address % 4: + raise ValueError(f"Duplicate or unaligned instruction address: {address:x}") + seen_addresses.add(address) + if "" in instruction or instruction.startswith("."): + raise ValueError(f"Undecoded instruction at {address:x}: {instruction}") + if not is_scalable(word, instruction): + continue + owners = [name for start, end, name in ranges if start <= address < end] + if len(owners) != 1 or owners[0] not in allowed: + failures.append(f"0x{address:x}: {instruction} ({', '.join(owners) or 'no sized function'})") + else: + advanced[owners[0]] = advanced.get(owners[0], 0) + 1 + if not seen_addresses: + raise ValueError("Empty disassembly") + if failures: + raise ValueError("SVE/SME escaped audited dispatch ranges:\n" + "\n".join(failures[:20])) + if allowed - advanced.keys(): + raise ValueError("Audited optimized function lost its scalable instructions: " + + ", ".join(sorted(allowed - advanced.keys()))) + return len(seen_addresses), sum(advanced.values()), len(advanced) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--objdump", required=True) + parser.add_argument("--readelf", required=True) + parser.add_argument("--llama-source", required=True, type=Path) + parser.add_argument("--kleidiai-source", required=True, type=Path) + parser.add_argument("library", type=Path) + args = parser.parse_args() + try: + symbols = subprocess.check_output([args.readelf, "--dyn-syms", "--wide", str(args.library)], text=True) + # Decode extensions rather than letting objdump hide them as . + disassembly = subprocess.check_output([ + args.objdump, "-d", "--mattr=+v9.4a,+sve,+sve2,+sme,+sme2,+mte", str(args.library) + ], text=True) + has_scalable = any( + is_scalable(int(match[2], 16), match[3].strip()) + for line in disassembly.splitlines() + if (match := INSTRUCTION.fullmatch(line)) + ) + if has_scalable: + validate_sources(args.llama_source, args.kleidiai_source) + total, scalable, functions = validate_disassembly( + symbols, disassembly, DISPATCHED_FUNCTIONS if has_scalable else frozenset() + ) + print(f"PASS: {total} instructions; {scalable} SVE/SME instructions contained in {functions} exact audited functions") + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"Android CPU ISA audit failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 9b6a6af66eadecaf084cd62e1f9e77fad1615567 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 6 Sep 2026 08:24:53 -0400 Subject: [PATCH 3/4] fix: retain ClangCL runtime kernel definitions --- AGENTS.md | 5 ++ cmake/kleidiai_windows.cmake | 42 +++++++++++++++++ docs/platform_backend_strategy.md | 7 ++- .../fixtures/kleidiai_windows/CMakeLists.txt | 47 ++++++++++++++++++- 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 645e83d..b281e11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,11 @@ Optional Linux container build: ## Release Workflows +For ARM64 upstream upgrades, run the candidate Windows/Android and compiled +dispatch gates in `validate_wrapper.yml` before merging. See +`docs/platform_backend_strategy.md` for the ISA audit contract. Never refresh +audited source fingerprints merely to silence a failed check. + - `.github/workflows/native_release.yml` - Exact native build + release workflow, used by manual dispatch and the automated stable dispatcher. diff --git a/cmake/kleidiai_windows.cmake b/cmake/kleidiai_windows.cmake index 72d7d2a..43bc4fb 100644 --- a/cmake/kleidiai_windows.cmake +++ b/cmake/kleidiai_windows.cmake @@ -28,4 +28,46 @@ function(llamadart_configure_kleidiai_windows_assembly) "UndefinePreprocessorDefinitions=__clang__\;%(UndefinePreprocessorDefinitions)") endif() endforeach() + + llamadart_add_kleidiai_clangcl_kernels() +endfunction() + +# KleidiAI 1.24's MSVC list excludes GNU inline-assembly C kernels that ClangCL +# supports and ggml's runtime-dispatched kernel table references. Restore only +# those exact kernels, with the same per-source ISA and SME vectorization policy +# as KleidiAI's non-MSVC build. Never raise the whole library's baseline ISA. +function(llamadart_add_kleidiai_clangcl_kernels) + get_target_property(source_dir kleidiai SOURCE_DIR) + set(dotprod_sources + kai/ukernels/matmul/matmul_clamp_f32_qsi8d32p_qsi4c32p/kai_matmul_clamp_f32_qsi8d32p1x8_qsi4c32p4x8_1x4x32_neon_dotprod.c + kai/ukernels/matmul/matmul_clamp_f32_qsi8d32p_qsi4c32p/kai_matmul_clamp_f32_qsi8d32p1x4_qsi4c32p4x4_1x4_neon_dotprod.c + kai/ukernels/matmul/matmul_clamp_f32_qsi8d32p_qsi4c32p/kai_matmul_clamp_f32_qsi8d32p4x4_qsi4c32p4x4_16x4_neon_dotprod.c + kai/ukernels/matmul/matmul_clamp_f32_qai8dxp_qsi8cxp/kai_matmul_clamp_f32_qai8dxp1x8_qsi8cxp4x8_1x4_neon_dotprod.c + kai/ukernels/matmul/matmul_clamp_f32_qai8dxp_qsi8cxp/kai_matmul_clamp_f32_qai8dxp1x4_qsi8cxp4x4_1x4_neon_dotprod.c + kai/ukernels/matmul/matmul_clamp_f32_qai8dxp_qsi8cxp/kai_matmul_clamp_f32_qai8dxp4x4_qsi8cxp4x4_16x4_neon_dotprod.c) + set(i8mm_sources + kai/ukernels/matmul/matmul_clamp_f32_qsi8d32p_qsi4c32p/kai_matmul_clamp_f32_qsi8d32p4x8_qsi4c32p4x8_16x4_neon_i8mm.c + kai/ukernels/matmul/matmul_clamp_f32_qai8dxp_qsi8cxp/kai_matmul_clamp_f32_qai8dxp4x8_qsi8cxp4x8_16x4_neon_i8mm.c) + set(sme_sources + kai/ukernels/matmul/matmul_clamp_f32_qsi8d32p_qsi4c32p/kai_matmul_clamp_f32_qsi8d32p1x4_qsi4c32p4vlx4_1x4vl_sme2_sdot.c + kai/ukernels/matmul/pack/kai_lhs_pack_bf16p2vlx2_f32_sme.c + kai/ukernels/matmul/pack/kai_rhs_pack_kxn_bf16p2vlx2b_f32_x32_sme.c) + foreach(family dotprod i8mm sme) + if (family STREQUAL "sme") + set(options /clang:-march=armv8.2-a+sve+sve2 + /clang:-fno-tree-vectorize /clang:-fno-tree-slp-vectorize) + else() + set(options "/clang:-march=armv8.2-a+${family}") + endif() + foreach(source IN LISTS ${family}_sources) + get_target_property(existing_sources kleidiai SOURCES) + set(absolute_source "${source_dir}/${source}") + if (NOT source IN_LIST existing_sources AND + NOT absolute_source IN_LIST existing_sources) + target_sources(kleidiai PRIVATE "${absolute_source}") + endif() + set_property(SOURCE "${absolute_source}" TARGET_DIRECTORY kleidiai + PROPERTY COMPILE_OPTIONS "${options}") + endforeach() + endforeach() endfunction() diff --git a/docs/platform_backend_strategy.md b/docs/platform_backend_strategy.md index c021d06..5caafdd 100644 --- a/docs/platform_backend_strategy.md +++ b/docs/platform_backend_strategy.md @@ -33,7 +33,11 @@ tests and non-SVE compute qualification. - Windows ARM64 retains ClangCL and all Kleidi kernels. Source-local Visual Studio metadata selects the native ARMASM preprocessing dialect and suppresses - incompatible line markers; C/C++ compiler behavior is unchanged. + incompatible line markers. The owning CMake integration also restores the + exact ClangCL-compatible C kernels referenced by ggml but omitted from + KleidiAI's MSVC source list, preserving their upstream per-source ISA flags + without raising the baseline ISA for other code. +- Non-Apple: keep backends as separate dynamic libraries (`GGML_BACKEND_DL=ON`). ## ARM64 upgrade qualification @@ -44,7 +48,6 @@ compatibility evidence, not physical-device or GPU-performance evidence. Use `-DLLAMADART_BUILD_KLEIDIAI_TESTS=ON` with standalone-Kleidi upstream to build `llamadart_kleidiai_dispatch_test`; it links the actual CPU module. The Android artifact check is `tools/validate_android_cpu_isa.py --help`. -- Non-Apple: keep backends as separate dynamic libraries (`GGML_BACKEND_DL=ON`). ## Runtime Packaging Model diff --git a/tests/fixtures/kleidiai_windows/CMakeLists.txt b/tests/fixtures/kleidiai_windows/CMakeLists.txt index f11a1ad..c4f602d 100644 --- a/tests/fixtures/kleidiai_windows/CMakeLists.txt +++ b/tests/fixtures/kleidiai_windows/CMakeLists.txt @@ -34,8 +34,51 @@ if (TARGET kleidiai) endif() endforeach() get_target_property(sources kleidiai SOURCES) - if (NOT sources STREQUAL "kernel.S;ordinary.c;generic.S") - message(FATAL_ERROR "Kernel source list changed: ${sources}") + if (CASE STREQUAL "enabled") + list(SUBLIST sources 0 3 original_sources) + list(SUBLIST sources 3 -1 added_sources) + set(expected_names + kai_matmul_clamp_f32_qsi8d32p1x8_qsi4c32p4x8_1x4x32_neon_dotprod.c + kai_matmul_clamp_f32_qsi8d32p1x4_qsi4c32p4x4_1x4_neon_dotprod.c + kai_matmul_clamp_f32_qsi8d32p4x4_qsi4c32p4x4_16x4_neon_dotprod.c + kai_matmul_clamp_f32_qai8dxp1x8_qsi8cxp4x8_1x4_neon_dotprod.c + kai_matmul_clamp_f32_qai8dxp1x4_qsi8cxp4x4_1x4_neon_dotprod.c + kai_matmul_clamp_f32_qai8dxp4x4_qsi8cxp4x4_16x4_neon_dotprod.c + kai_matmul_clamp_f32_qsi8d32p4x8_qsi4c32p4x8_16x4_neon_i8mm.c + kai_matmul_clamp_f32_qai8dxp4x8_qsi8cxp4x8_16x4_neon_i8mm.c + kai_matmul_clamp_f32_qsi8d32p1x4_qsi4c32p4vlx4_1x4vl_sme2_sdot.c + kai_lhs_pack_bf16p2vlx2_f32_sme.c + kai_rhs_pack_kxn_bf16p2vlx2b_f32_x32_sme.c) + foreach(source IN LISTS added_sources) + get_filename_component(name "${source}" NAME) + list(APPEND names "${name}") + get_source_file_property(options "${source}" + TARGET_DIRECTORY kleidiai COMPILE_OPTIONS) + if (name MATCHES "neon_dotprod") + set(expected_options /clang:-march=armv8.2-a+dotprod) + elseif (name MATCHES "neon_i8mm") + set(expected_options /clang:-march=armv8.2-a+i8mm) + else() + set(expected_options /clang:-march=armv8.2-a+sve+sve2 + /clang:-fno-tree-vectorize /clang:-fno-tree-slp-vectorize) + endif() + if (NOT options STREQUAL expected_options) + message(FATAL_ERROR "Incorrect source ISA options for ${name}: ${options}") + endif() + endforeach() + if (NOT names STREQUAL expected_names) + message(FATAL_ERROR "Unexpected kernel inventory: ${names}") + endif() + llamadart_add_kleidiai_clangcl_kernels() + get_target_property(repeated_sources kleidiai SOURCES) + if (NOT sources STREQUAL repeated_sources) + message(FATAL_ERROR "Duplicate kernels added on repeated configuration") + endif() + else() + set(original_sources "${sources}") + endif() + if (NOT original_sources STREQUAL "kernel.S;ordinary.c;generic.S") + message(FATAL_ERROR "Original kernel source list changed: ${original_sources}") endif() endif() From 188346ec98f2973f3125374e6d5f27746638c27b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 6 Sep 2026 08:32:00 -0400 Subject: [PATCH 4/4] test: enforce active bounded Release wrapper contracts --- .github/workflows/validate_wrapper.yml | 6 +++++- CMakeLists.txt | 9 +++++++++ docs/platform_backend_strategy.md | 2 ++ tests/speculative_api_test.cpp | 4 ++++ tests/tts_api_test.c | 4 ++++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate_wrapper.yml b/.github/workflows/validate_wrapper.yml index 8168c9a..54048e9 100644 --- a/.github/workflows/validate_wrapper.yml +++ b/.github/workflows/validate_wrapper.yml @@ -148,7 +148,11 @@ jobs: - name: Build wrapper and CPU contracts run: cmake --build --preset windows-arm64-full --parallel 4 - name: Run native wrapper contracts - run: ctest --test-dir build/wa64 -C Release --output-on-failure + run: | + $wrapperDir = (Resolve-Path build/wa64/Release).Path + $upstreamDir = (Resolve-Path build/wa64/bin/Release).Path + $env:PATH = "$wrapperDir;$upstreamDir;$env:PATH" + ctest --test-dir build/wa64 -C Release --timeout 120 --verbose --output-on-failure linux-artifact-contract: runs-on: ubuntu-latest diff --git a/CMakeLists.txt b/CMakeLists.txt index a842e37..6e4a644 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -231,6 +231,15 @@ if (LLAMADART_BUILD_TESTS) LLAMADART_MTMD_HELPER_HAS_OPTIONS=$) target_link_libraries(llamadart_mtmd_compat_test PRIVATE mtmd) add_test(NAME llamadart_mtmd_compat_test COMMAND llamadart_mtmd_compat_test) + + # Contract assertions must execute against optimized Release libraries too. + foreach(test_target llamadart_speculative_api_test llamadart_tts_api_test) + if (MSVC) + target_compile_options(${test_target} PRIVATE /UNDEBUG) + else() + target_compile_options(${test_target} PRIVATE -UNDEBUG) + endif() + endforeach() endif() if (LLAMADART_BUILD_KLEIDIAI_TESTS) diff --git a/docs/platform_backend_strategy.md b/docs/platform_backend_strategy.md index 5caafdd..2245422 100644 --- a/docs/platform_backend_strategy.md +++ b/docs/platform_backend_strategy.md @@ -45,6 +45,8 @@ the actual Android ARMv8.2 artifact, and compiled Kleidi selectors plus quantized matrix computation under non-SVE QEMU profiles. QEMU is deterministic CPU compatibility evidence, not physical-device or GPU-performance evidence. +Wrapper contract assertions stay active in Release builds; Windows CTest resolves +both wrapper and upstream DLL directories and bounds each test to 120 seconds. Use `-DLLAMADART_BUILD_KLEIDIAI_TESTS=ON` with standalone-Kleidi upstream to build `llamadart_kleidiai_dispatch_test`; it links the actual CPU module. The Android artifact check is `tools/validate_android_cpu_isa.py --help`. diff --git a/tests/speculative_api_test.cpp b/tests/speculative_api_test.cpp index b79b0ea..064de0c 100644 --- a/tests/speculative_api_test.cpp +++ b/tests/speculative_api_test.cpp @@ -1,3 +1,7 @@ +#ifdef NDEBUG +#error "Wrapper contract tests require active assertions in every configuration" +#endif + #include "llama_dart_mtp_internal.h" #include "llama_dart_speculative_compat.h" #include "llama_dart_wrapper.h" diff --git a/tests/tts_api_test.c b/tests/tts_api_test.c index 2b098a3..23d906c 100644 --- a/tests/tts_api_test.c +++ b/tests/tts_api_test.c @@ -1,3 +1,7 @@ +#ifdef NDEBUG +#error "Wrapper contract tests require active assertions in every configuration" +#endif + #include "llama_dart_wrapper.h" #include