From ed0ed8ba49e7750a20195bdf9eeadce38ea78c3b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:24:15 -0700 Subject: [PATCH] fix(packaging): build pywhispercpp at the portable x86-64 baseline (#959) * fix(packaging): build pywhispercpp at the portable x86-64 baseline Every packaged channel shipped a libggml-cpu compiled for AVX2+FMA: the manylinux PyPI wheel itself carries -mavx2 -mfma -mbmi2 code, so any package that bundles it dies with SIGILL when whisper.cpp loads a model on a pre-Haswell CPU (the reporter's i5-3337U). GGML_NATIVE=OFF alone is not the portable baseline either; while the native default is on, ggml turns every GGML_* SIMD option on, so each is pinned off. - Snap: force the sdist (PIP_NO_BINARY) with the flag floor via CMAKE_ARGS in the part's build-environment. - Flatpak: extend the module's CMAKE_ARGS with the same floor. - Native .deb/.rpm: the build still vendors the wheels, then rebuilds libggml-cpu alone from the same hash-pinned sdist and swaps it into the vendored copies, asserting no AVX/FMA survives; the container image gains cmake, g++, make and binutils. - AppImage: extract the pinned-sdist reinstall into a shared helper and route every CPU-only fallback of the Vulkan path through a new baseline rebuild at the same floor; the Vulkan compile keeps its own flags via the same helper. Keeping the wheel on those paths was the bug's exact binary. Fixes #957 * style(tests): apply black/isort to the new packaging guards * fix(native): keep the wheel's libggml providers, alias its base/gomp sonames Swapping the whole libggml set for the baseline build broke the import smoke: the wheel's auditwheel-mangled core lib doubles as the extension's __cxa_call_terminate provider, and its NEEDED entries are the mangled names. Swap only libggml-cpu, and satisfy the baseline library's own unmangled NEEDEDs (libggml-base.so.0, libgomp.so.1) with aliases of the vendored copies, which carry no generated SIMD and are the same upstream version. Fail closed when the vendored set has restructured instead of silently shipping the AVX2 wheel. * fix(packaging): fail builds that lose the speech engine, unblock the ISA assert Greptile findings on the baseline work: - A failed baseline rebuild in the AppImage returned success after remove_appdir_pywhispercpp, so the bundle would ship with no speech engine at all while logging that the wheel was kept. Propagate the failure through every CPU-only fallback so the build stops instead. - The native libggml-cpu assertion used grep -q under pipefail, where grep's early exit SIGPIPEs objdump and the pipeline reports a miss, letting an AVX2 library through. Read the full disassembly instead. * fix(installer): build pywhispercpp from sdist on the CPU path install_cpu_pywhispercpp let pip pick the manylinux wheel, whose libggml-cpu is compiled for AVX2+FMA and SIGILLs on pre-Haswell CPUs (#957). Pass --no-binary pywhispercpp like the Vulkan/CUDA paths so the sdist compiles against this machine's own instruction set. * fix(installer): install C++ toolchain on distros that ship without one Forcing the pywhispercpp sdist on the CPU path needs a compiler, and the fedora, mageia, alpine, void and solus package maps did not request one. Add gcc-c++/make (rpm), build-base (alpine), base-devel (void) and gcc/g++/make (solus) so source installs compile on minimal systems. --------- Co-authored-by: Jatin K Malik --- install.d/package_map.sh | 10 +-- install.sh | 5 +- packaging/appimage/build.sh | 86 +++++++++++++++------ packaging/flatpak/python3-dependencies.yaml | 9 ++- packaging/native/build.sh | 57 ++++++++++++++ packaging/native/container-build.sh | 4 +- scripts/distro-package-map.yaml | 18 +++-- snap/snapcraft.yaml | 8 ++ tests/test_appimage_packaging.py | 44 +++++++++-- tests/test_flatpak_packaging.py | 29 +++++-- tests/test_installer_pinned_dependencies.py | 7 ++ tests/test_native_packaging.py | 43 +++++++++++ tests/test_snap_packaging.py | 34 ++++++++ 13 files changed, 304 insertions(+), 50 deletions(-) diff --git a/install.d/package_map.sh b/install.d/package_map.sh index a3a76e4..fb8a7f6 100644 --- a/install.d/package_map.sh +++ b/install.d/package_map.sh @@ -55,7 +55,7 @@ load_distro_package_map() { ;; fedora) - SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake wget curl unzip vulkan-tools vulkan-loader-devel glslc patchelf xclip xsel wl-clipboard) + SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake gcc-c++ make wget curl unzip vulkan-tools vulkan-loader-devel glslc patchelf xclip xsel wl-clipboard) APPINDICATOR_PACKAGES=(libayatana-appindicator-gtk3 libappindicator-gtk3) XDOTOOL_PACKAGES=(xdotool) WTYPE_PACKAGES=(wtype) @@ -94,28 +94,28 @@ load_distro_package_map() { ;; alpine) - SYSTEM_PACKAGES=(py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev cairo-dev py3-virtualenv pkgconf cmake wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard) + SYSTEM_PACKAGES=(py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev cairo-dev py3-virtualenv pkgconf cmake build-base wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard) XDOTOOL_PACKAGES=(xdotool) WTYPE_PACKAGES=(wtype) YDOTOOL_PACKAGES=(ydotool) ;; void) - SYSTEM_PACKAGES=(python3-pip python3-gobject gtk+3 libappindicator-gtk3 gobject-introspection portaudio-devel cairo-devel python3-devel pkg-config cmake wget curl unzip shaderc patchelf Vulkan-Tools xclip xsel wl-clipboard) + SYSTEM_PACKAGES=(python3-pip python3-gobject gtk+3 libappindicator-gtk3 gobject-introspection portaudio-devel cairo-devel python3-devel pkg-config cmake base-devel wget curl unzip shaderc patchelf Vulkan-Tools xclip xsel wl-clipboard) XDOTOOL_PACKAGES=(xdotool) WTYPE_PACKAGES=(wtype) YDOTOOL_PACKAGES=(ydotool) ;; solus) - SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 libappindicator gobject-introspection-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard) + SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 libappindicator gobject-introspection-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake gcc g++ make wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard) XDOTOOL_PACKAGES=(xdotool) WTYPE_PACKAGES=(wtype) YDOTOOL_PACKAGES=(ydotool) ;; mageia) - SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake wget curl unzip vulkan-tools vulkan-loader-devel glslc patchelf xclip xsel wl-clipboard) + SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake gcc-c++ make wget curl unzip vulkan-tools vulkan-loader-devel glslc patchelf xclip xsel wl-clipboard) XDOTOOL_PACKAGES=(xdotool) WTYPE_PACKAGES=(wtype) YDOTOOL_PACKAGES=(ydotool) diff --git a/install.sh b/install.sh index ed9468b..c80231e 100755 --- a/install.sh +++ b/install.sh @@ -1774,8 +1774,11 @@ install_cpu_pywhispercpp() { local PYWHISPERCPP_CMAKE_ARGS PYWHISPERCPP_CMAKE_ARGS=$(get_pywhispercpp_cmake_args) + # The PyPI wheel's libggml-cpu is compiled for AVX2+FMA and SIGILLs on + # pre-Haswell CPUs (#957), so the CPU path builds the pinned sdist like + # the GPU paths do; -march=native then matches this machine exactly. CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" \ - pip_reinstall_pywhispercpp "$PIP_LOG_FILE" + pip_reinstall_pywhispercpp "$PIP_LOG_FILE" --no-binary pywhispercpp } pip_reinstall_pywhispercpp() { diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index 62dec9e..ce7ed09 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -427,6 +427,58 @@ copy_whisper_native_libs_to_usr_lib() { done < <(find "$APPDIR/usr" \( -name 'libggml*.so*' -o -name 'libwhisper.so*' \) ! -path '*/usr/lib/*' 2>/dev/null || true) } +# ggml's instruction-set floor for every CPU-side pywhispercpp build this +# script ships; CMAKE_ARGS inlines it below so the native-cache id keeps +# hashing it. GGML_NATIVE is ggml's default and compiles libggml-cpu with +# -march=native, tuned to whichever runner the build lands on: a runner with +# AVX-512 ships a libggml-cpu.so that dies with SIGILL on any CPU without it +# (#938), and the native cache then serves the same binary to every later +# build. GGML_NATIVE=OFF alone is not the portable baseline either — while +# the native default is on, ggml turns every GGML_* SIMD option on and still +# compiles -mavx2 -mfma, dying the same way on pre-Haswell CPUs (#957). Each +# option is pinned off for the plain x86-64 baseline; the Vulkan path is +# unaffected because its shaders run on the GPU. +PORTABLE_GGML_CMAKE_ARGS="-DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF" + +# Reinstall the same pinned sdist the bundle already carries, hashes and all: +# a source build is still a download, and this one ends up in ctypes.cdll. +# GGML_VULKAN=1 in the caller's env selects the Vulkan build; without it the +# compile is CPU-only and any -DVulkan_INCLUDE_DIR is dead. +_install_pinned_pywhispercpp() { + local pip_log="$1" + awk '/^pywhispercpp==/ {found=1; print; next} + found && /^[[:space:]]/ {print; next} + found {exit}' "$REPO_ROOT/requirements/vad.txt" > "$WORKDIR/pywhispercpp.txt" + # Hosts that default cc/c++ to clang (this Cloud Agent image) fail the + # CMAKE C++ compiler test: clang does not search gcc's libstdc++ path. + CC="${CC:-gcc}" CXX="${CXX:-g++}" \ + GGML_VULKAN="${GGML_VULKAN:-0}" \ + CMAKE_ARGS="-DCMAKE_INSTALL_RPATH=\$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON $PORTABLE_GGML_CMAKE_ARGS${VULKAN_INCLUDE_DIR:+ -DVulkan_INCLUDE_DIR=$VULKAN_INCLUDE_DIR}" \ + uv_install --verbose --require-hashes --no-deps \ + --reinstall-package pywhispercpp --no-binary pywhispercpp \ + -r "$WORKDIR/pywhispercpp.txt" >"$pip_log" 2>&1 +} + +# CPU-only fallback for the Vulkan path. The PyPI wheel the bundle started +# with is the same AVX2+FMA build the SIGILL reports come from, so keeping it +# is no fallback at all on pre-Haswell CPUs — rebuild the sdist at the +# baseline instead. +_compile_pywhispercpp_baseline() { + local pip_log="$WORKDIR/pywhispercpp-baseline.log" + export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)}" + ensure_build_tools + echo "== Rebuilding pywhispercpp at the portable CPU baseline ==" + remove_appdir_pywhispercpp + if ! _install_pinned_pywhispercpp "$pip_log"; then + # The wheel is already gone, so "continue" would ship an AppImage with + # no speech engine at all; fail the build instead. + echo "Baseline pywhispercpp rebuild failed; refusing to ship without the speech engine." >&2 + tail -n 40 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true + return 1 + fi + copy_whisper_native_libs_to_usr_lib +} + # The miss path of rebuild_pywhispercpp_vulkan. require_vulkan, cache_dir, and # site are locals of that caller; bash shows them to this function. _compile_pywhispercpp_vulkan() { @@ -437,7 +489,8 @@ _compile_pywhispercpp_vulkan() { echo "VOCALINUX_APPIMAGE_REQUIRE_VULKAN=1: refusing to ship a CPU-only AppImage." >&2 exit 1 fi - echo "Warning: AppImage will use the CPU-only pywhispercpp wheel." >&2 + echo "Warning: AppImage will use a CPU-only baseline pywhispercpp build." >&2 + _compile_pywhispercpp_baseline || return 1 return 0 fi @@ -448,7 +501,8 @@ _compile_pywhispercpp_vulkan() { if [ "$require_vulkan" = "1" ]; then exit 1 fi - echo "Warning: continuing with the CPU-only pywhispercpp wheel." >&2 + echo "Warning: continuing with a CPU-only baseline pywhispercpp build." >&2 + _compile_pywhispercpp_baseline || return 1 return 0 fi @@ -456,25 +510,7 @@ _compile_pywhispercpp_vulkan() { remove_appdir_pywhispercpp local pip_log="$WORKDIR/pywhispercpp-vulkan.log" export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)}" - # Reinstall the same pinned sdist the bundle already carries, hashes and all: - # a source build is still a download, and this one ends up in ctypes.cdll. - awk '/^pywhispercpp==/ {found=1; print; next} - found && /^[[:space:]]/ {print; next} - found {exit}' "$REPO_ROOT/requirements/vad.txt" > "$WORKDIR/pywhispercpp.txt" - # Hosts that default cc/c++ to clang (this Cloud Agent image) fail the - # CMAKE C++ compiler test: clang does not search gcc's libstdc++ path. - # GGML_NATIVE is ggml's default and compiles libggml-cpu with -march=native, - # tuned to whichever runner the build lands on. A runner with AVX-512 ships a - # libggml-cpu.so that dies with SIGILL on any CPU without it (#938), and the - # native cache then serves the same binary to every later build. OFF builds - # the portable baseline the PyPI wheel already ships; the Vulkan path is - # unaffected because its shaders run on the GPU. - if ! CC="${CC:-gcc}" CXX="${CXX:-g++}" \ - GGML_VULKAN=1 \ - CMAKE_ARGS="-DCMAKE_INSTALL_RPATH=\$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -DGGML_NATIVE=OFF -DVulkan_INCLUDE_DIR=$VULKAN_INCLUDE_DIR" \ - uv_install --verbose --require-hashes --no-deps \ - --reinstall-package pywhispercpp --no-binary pywhispercpp \ - -r "$WORKDIR/pywhispercpp.txt" >"$pip_log" 2>&1; then + if ! GGML_VULKAN=1 _install_pinned_pywhispercpp "$pip_log"; then # The tail is the Python traceback wrapping the build and says nothing about # the compile, so lead with the compiler's own errors. echo "pywhispercpp Vulkan rebuild failed." >&2 @@ -485,7 +521,8 @@ _compile_pywhispercpp_vulkan() { if [ "$require_vulkan" = "1" ]; then exit 1 fi - echo "Warning: continuing with the CPU-only pywhispercpp wheel." >&2 + echo "Warning: continuing with a CPU-only baseline pywhispercpp build." >&2 + _compile_pywhispercpp_baseline || return 1 return 0 fi @@ -496,7 +533,10 @@ _compile_pywhispercpp_vulkan() { tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true exit 1 fi - echo "Warning: AppImage will use CPU-only pywhispercpp." >&2 + # The GGML_VULKAN=1 compile is already the portable baseline even when no + # Vulkan library comes out of it, so this build is safe to keep. + echo "Warning: AppImage will use CPU-only baseline pywhispercpp." >&2 + copy_whisper_native_libs_to_usr_lib return 0 fi echo " found $vk_lib" diff --git a/packaging/flatpak/python3-dependencies.yaml b/packaging/flatpak/python3-dependencies.yaml index c6b484a..d78de19 100644 --- a/packaging/flatpak/python3-dependencies.yaml +++ b/packaging/flatpak/python3-dependencies.yaml @@ -29,9 +29,12 @@ modules: # GGML_NATIVE is ggml's default and compiles libggml-cpu with # -march=native, so the package inherits whichever instruction set the # Flatpak builder has. An AVX-512 builder ships a binary that SIGILLs on - # any CPU without it (#938). OFF ships the portable baseline the PyPI - # wheel carries. - - PYWHISPERCPP_VERSION=1.5.0 NO_REPAIR=1 GGML_VULKAN=1 CMAKE_ARGS="-DGGML_NATIVE=OFF" pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + # any CPU without it (#938). GGML_NATIVE=OFF alone is not the portable + # baseline either: while the native default is on, ggml turns every + # GGML_* SIMD option on and still compiles -mavx2 -mfma, dying the same + # way on pre-Haswell CPUs (#957). Each option is pinned off for the + # plain x86-64 baseline. + - PYWHISPERCPP_VERSION=1.5.0 NO_REPAIR=1 GGML_VULKAN=1 CMAKE_ARGS="-DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF" pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" --prefix=${FLATPAK_DEST} "pywhispercpp" --no-build-isolation # whisper.cpp installs its shared libraries next to the extension module. # Symlink them onto the default loader path so the extension resolves them diff --git a/packaging/native/build.sh b/packaging/native/build.sh index 4da4ecd..6efbc81 100755 --- a/packaging/native/build.sh +++ b/packaging/native/build.sh @@ -102,6 +102,63 @@ for VENDOR_WHEEL in "$TOOLS"/wheels/*/*.whl; do python3 -m zipfile -e "$VENDOR_WHEEL" "$PAYLOAD/usr/lib/vocalinux/vendor/" done +# --- portable libggml-cpu ------------------------------------------------- +# The pinned pywhispercpp wheels bundle a libggml-cpu compiled for AVX2+FMA, +# which dies with SIGILL at model load on pre-Haswell CPUs (#957). Rebuild +# the ggml libraries from the same pinned sdist at the plain x86-64 baseline — +# GGML_NATIVE=OFF alone still compiles -mavx2 -mfma, because ggml turns every +# GGML_* SIMD option on while its native default is on — and swap them into +# the vendored copies. libwhisper and the Python extension carry no generated +# SIMD, so only the libggml set needs replacing. +SDIST_DIR="$TOOLS/pywhispercpp-sdist" +mkdir -p "$SDIST_DIR" +pip3 download --require-hashes --no-deps --no-binary pywhispercpp \ + -r "$PINS_VENDOR" -d "$SDIST_DIR" >/dev/null +tar -xzf "$SDIST_DIR"/pywhispercpp-*.tar.gz -C "$SDIST_DIR" +WHISPER_GGML="$(echo "$SDIST_DIR"/pywhispercpp-*/whisper.cpp/ggml)" +[ -d "$WHISPER_GGML" ] || fail "whisper.cpp/ggml missing from pywhispercpp sdist" +# ggml.pc.in is generated upstream and absent from the sdist; it only feeds +# the standalone install rules this build never runs. +touch "$WHISPER_GGML/ggml.pc.in" +cmake -S "$WHISPER_GGML" -B "$TOOLS/ggml-build" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \ + -DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF \ + -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF \ + -DGGML_BUILD_TESTS=OFF -DGGML_BUILD_EXAMPLES=OFF +cmake --build "$TOOLS/ggml-build" --target ggml-cpu -j"$(nproc)" +BASELINE_CPU="$(find "$TOOLS/ggml-build" -name 'libggml-cpu.so.*' -type f | sort -V | tail -1)" +[ -f "$BASELINE_CPU" ] || fail "baseline libggml-cpu build produced no library" +# objdump, not a feature check: the SIGILL is ISA-level, so assert no 256-bit +# or FMA instructions survive in the library that ships. No grep -q: under +# pipefail an early exit SIGPIPEs objdump and the pipeline reports a miss. +if objdump -d "$BASELINE_CPU" | grep -E 'ymm|vfmadd|vfnmadd|vfmsub|vzeroupper' >/dev/null; then + fail "baseline libggml-cpu still carries AVX/FMA instructions" +fi +# The vendored wheel files carry auditwheel-mangled basenames +# (libggml-cpu-.so.0.9.8) that the extension's NEEDED entries point at, +# so overwrite the cpu library in place and keep its filename. Only cpu is +# swapped: the wheel's other libggml files carry no generated SIMD, and the +# mangled core lib doubles as the extension's __cxa_call_terminate provider — +# replacing it with a locally built copy drops that export. +swapped=0 +while IFS= read -r vendored; do + cp "$BASELINE_CPU" "$vendored" + swapped=$((swapped + 1)) +done < <(find "$PAYLOAD/usr/lib/vocalinux/vendor" -name 'libggml-cpu*.so*') +[ "$swapped" -gt 0 ] || fail "found no vendored libggml-cpu to replace" +# The baseline library's own NEEDED entries use the unmangled sonames +# (libggml-base.so.0, libgomp.so.1), so alias the vendored copies — the same +# upstream version, and base/gomp carry no generated SIMD anyway — under +# those names inside pywhispercpp.libs. +while IFS= read -r libs_dir; do + vendored_base="$(find "$libs_dir" -name 'libggml-base*.so*' | head -1)" + [ -n "$vendored_base" ] || fail "no vendored libggml-base in $libs_dir" + cp "$vendored_base" "$libs_dir/libggml-base.so.0" + vendored_gomp="$(find "$libs_dir" -name 'libgomp*.so*' | head -1)" + [ -n "$vendored_gomp" ] || fail "no vendored libgomp in $libs_dir" + cp "$vendored_gomp" "$libs_dir/libgomp.so.1" +done < <(find "$PAYLOAD/usr/lib/vocalinux/vendor" -type d -name 'pywhispercpp.libs') + # --- launchers ----------------------------------------------------------- # Same environment install.sh's wrapper establishes: the vendored # pywhispercpp.libs on LD_LIBRARY_PATH, user site-packages off so they cannot diff --git a/packaging/native/container-build.sh b/packaging/native/container-build.sh index 064d59b..95c5717 100755 --- a/packaging/native/container-build.sh +++ b/packaging/native/container-build.sh @@ -5,7 +5,9 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update +# cmake, g++, make and binutils rebuild the vendored libggml-cpu from the +# pinned pywhispercpp sdist at the portable baseline in build.sh. apt-get install -y --no-install-recommends \ - ca-certificates curl python3 python3-pip xz-utils + binutils ca-certificates cmake curl g++ make python3 python3-pip xz-utils exec bash "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/build.sh" "$@" diff --git a/scripts/distro-package-map.yaml b/scripts/distro-package-map.yaml index d911dd7..5d3f0b1 100644 --- a/scripts/distro-package-map.yaml +++ b/scripts/distro-package-map.yaml @@ -121,7 +121,9 @@ distributions: fedora: # cairo-devel is needed because the pinned pycairo builds from source. - system: [python3-pip, python3-gobject, gtk3, ibus-devel, gobject-introspection-devel, python3-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, wget, curl, unzip, vulkan-tools, vulkan-loader-devel, glslc, patchelf, xclip, xsel, wl-clipboard] + # gcc-c++/make compile the pywhispercpp sdist (the wheel SIGILLs on old CPUs) + # and the evdev/PyAudio sdists; python3-devel only guarantees gcc. + system: [python3-pip, python3-gobject, gtk3, ibus-devel, gobject-introspection-devel, python3-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, gcc-c++, make, wget, curl, unzip, vulkan-tools, vulkan-loader-devel, glslc, patchelf, xclip, xsel, wl-clipboard] appindicator: [libayatana-appindicator-gtk3, libappindicator-gtk3] text_input: xdotool: [xdotool] @@ -165,7 +167,8 @@ distributions: alpine: # cairo-dev is needed because the pinned pycairo builds from source. - system: [py3-gobject3, py3-pip, gtk+3.0, py3-cairo, portaudio-dev, cairo-dev, py3-virtualenv, pkgconf, cmake, wget, curl, unzip, shaderc, patchelf, vulkan-tools, xclip, xsel, wl-clipboard] + # build-base compiles the pywhispercpp sdist and the evdev/PyAudio sdists. + system: [py3-gobject3, py3-pip, gtk+3.0, py3-cairo, portaudio-dev, cairo-dev, py3-virtualenv, pkgconf, cmake, build-base, wget, curl, unzip, shaderc, patchelf, vulkan-tools, xclip, xsel, wl-clipboard] text_input: xdotool: [xdotool] wtype: [wtype] @@ -173,7 +176,8 @@ distributions: void: # cairo-devel is needed because the pinned pycairo builds from source. - system: [python3-pip, python3-gobject, gtk+3, libappindicator-gtk3, gobject-introspection, portaudio-devel, cairo-devel, python3-devel, pkg-config, cmake, wget, curl, unzip, shaderc, patchelf, Vulkan-Tools, xclip, xsel, wl-clipboard] + # base-devel compiles the pywhispercpp sdist and the evdev/PyAudio sdists. + system: [python3-pip, python3-gobject, gtk+3, libappindicator-gtk3, gobject-introspection, portaudio-devel, cairo-devel, python3-devel, pkg-config, cmake, base-devel, wget, curl, unzip, shaderc, patchelf, Vulkan-Tools, xclip, xsel, wl-clipboard] text_input: xdotool: [xdotool] wtype: [wtype] @@ -181,15 +185,17 @@ distributions: solus: # cairo-devel is needed because the pinned pycairo builds from source. - system: [python3-pip, python3-gobject, gtk3, libappindicator, gobject-introspection-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, wget, curl, unzip, shaderc, patchelf, vulkan-tools, xclip, xsel, wl-clipboard] + # gcc/g++/make compile the pywhispercpp sdist and the evdev/PyAudio sdists. + system: [python3-pip, python3-gobject, gtk3, libappindicator, gobject-introspection-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, gcc, g++, make, wget, curl, unzip, shaderc, patchelf, vulkan-tools, xclip, xsel, wl-clipboard] text_input: xdotool: [xdotool] wtype: [wtype] ydotool: [ydotool] mageia: - # The existing installer deliberately follows Fedora's package inventory. - system: [python3-pip, python3-gobject, gtk3, ibus-devel, gobject-introspection-devel, python3-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, wget, curl, unzip, vulkan-tools, vulkan-loader-devel, glslc, patchelf, xclip, xsel, wl-clipboard] + # The existing installer deliberately follows Fedora's package inventory, + # including gcc-c++/make for the pywhispercpp sdist compile. + system: [python3-pip, python3-gobject, gtk3, ibus-devel, gobject-introspection-devel, python3-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, gcc-c++, make, wget, curl, unzip, vulkan-tools, vulkan-loader-devel, glslc, patchelf, xclip, xsel, wl-clipboard] text_input: xdotool: [xdotool] wtype: [wtype] diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 911612d..3adeeab 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -105,6 +105,14 @@ parts: source-type: local build-environment: - PIP_DISABLE_PIP_VERSION_CHECK: "1" + # pywhispercpp's PyPI wheel ships a libggml-cpu compiled for AVX2+FMA, + # which dies with SIGILL at model load on pre-Haswell CPUs (#957), so + # the pinned sdist is built instead of installing that wheel. + # GGML_NATIVE=OFF alone is not the portable baseline either: while + # ggml's native default is on it turns every GGML_* SIMD option on, so + # each one is pinned off for the plain x86-64 baseline. + - PIP_NO_BINARY: pywhispercpp + - CMAKE_ARGS: "-DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF" build-packages: - build-essential - cmake diff --git a/tests/test_appimage_packaging.py b/tests/test_appimage_packaging.py index 3b5fc69..2d653b2 100644 --- a/tests/test_appimage_packaging.py +++ b/tests/test_appimage_packaging.py @@ -23,6 +23,19 @@ WORKFLOWS = REPO_ROOT / ".github" / "workflows" #: Workflows that build an AppImage, and so decide which distros it runs on. APPIMAGE_WORKFLOWS = ("unified-pipeline.yml", "release.yml", "nightly.yml") +#: ggml SIMD options pinned off for the portable x86-64 baseline in every +#: packaged pywhispercpp build (#957). GGML_NATIVE=OFF alone leaves each of +#: these on while ggml's native default is on. +PORTABLE_GGML_OPTIONS = ( + "GGML_NATIVE", + "GGML_SSE42", + "GGML_AVX", + "GGML_AVX2", + "GGML_BMI2", + "GGML_FMA", + "GGML_F16C", +) + def _pins() -> dict: """name -> (kind, value, source) from tool_checksums.txt.""" @@ -255,14 +268,33 @@ def test_vulkan_rebuild_does_not_borrow_the_builders_instruction_set() -> None: """GGML_NATIVE is ggml's default and puts -march=native on libggml-cpu, so the AppImage inherits whichever instruction set the CI runner has. A runner with AVX-512 ships a binary that SIGILLs on any CPU without it (#938), and - the shared native cache then serves the same one to every later build. OFF - matches the portable baseline the PyPI wheel falls back to.""" + the shared native cache then serves the same one to every later build. + OFF alone is not the portable baseline either: while the native default is + on, ggml turns every GGML_* SIMD option on, still compiling -mavx2 -mfma + and dying on pre-Haswell CPUs (#957), so each option must be pinned off.""" text = BUILD_SH.read_text(encoding="utf-8") command_lines = [line for line in text.splitlines() if not line.lstrip().startswith("#")] - cmake_args = re.findall(r'CMAKE_ARGS="([^"]*)"', "\n".join(command_lines)) - assert any("-DGGML_NATIVE=OFF" in args for args in cmake_args), ( - "the Vulkan rebuild must not compile with -march=native; the AppImage" - " runs on CPUs the runner's instruction set does not describe" + cmake_args = " ".join(re.findall(r'CMAKE_ARGS="([^"]*)"', "\n".join(command_lines))) + for flag in PORTABLE_GGML_OPTIONS: + assert f"-D{flag}=OFF" in cmake_args, ( + f"the Vulkan rebuild does not pin {flag} off; the AppImage runs on" + " CPUs the runner's instruction set does not describe" + ) + + +def test_cpu_only_fallback_rebuilds_at_the_portable_baseline() -> None: + """The PyPI pywhispercpp wheel is itself the AVX2+FMA build that SIGILLs on + pre-Haswell CPUs (#957), so "keep the wheel" is no fallback: every CPU-only + exit of the Vulkan path must rebuild the sdist at the baseline instead.""" + text = BUILD_SH.read_text(encoding="utf-8") + assert "_compile_pywhispercpp_baseline" in text, ( + "no CPU-only baseline rebuild exists; the Vulkan fallbacks still keep" " the AVX2+FMA wheel" + ) + command_lines = [line for line in text.splitlines() if not line.lstrip().startswith("#")] + calls = sum("_compile_pywhispercpp_baseline" in line for line in command_lines) + assert calls >= 3, ( + "expected the baseline rebuild on every CPU-only fallback path, found" + f" only {calls - 1} call site(s)" ) diff --git a/tests/test_flatpak_packaging.py b/tests/test_flatpak_packaging.py index c97a682..48d3763 100644 --- a/tests/test_flatpak_packaging.py +++ b/tests/test_flatpak_packaging.py @@ -28,6 +28,19 @@ from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name from packaging.version import Version +#: ggml SIMD options pinned off for the portable x86-64 baseline in every +#: packaged pywhispercpp build (#957). GGML_NATIVE=OFF alone leaves each of +#: these on while ggml's native default is on. +PORTABLE_GGML_OPTIONS = ( + "GGML_NATIVE", + "GGML_SSE42", + "GGML_AVX", + "GGML_AVX2", + "GGML_BMI2", + "GGML_FMA", + "GGML_F16C", +) + REPO_ROOT = Path(__file__).resolve().parents[1] PYPROJECT = REPO_ROOT / "pyproject.toml" EXPORT = REPO_ROOT / "requirements" / "runtime.txt" @@ -221,8 +234,10 @@ def test_the_version_injection_names_the_version_being_built() -> None: def test_the_vulkan_build_does_not_borrow_the_builders_instruction_set() -> None: """GGML_NATIVE is ggml's default and puts -march=native on libggml-cpu, so the Flatpak inherits whichever instruction set the builder has, and the - result SIGILLs on any CPU without it (#938). OFF ships the portable - baseline the PyPI wheel carries.""" + result SIGILLs on any CPU without it (#938). OFF alone is not the portable + baseline either: while the native default is on, ggml turns every GGML_* + SIMD option on, still compiling -mavx2 -mfma and dying on pre-Haswell CPUs + (#957), so each option must be pinned off for the plain x86-64 baseline.""" module = re.search( r"^ - name: python3-pywhispercpp$(.*?)(?=^ - name: |\Z)", DEPS_YAML.read_text(encoding="utf-8"), @@ -232,9 +247,13 @@ def test_the_vulkan_build_does_not_borrow_the_builders_instruction_set() -> None command_lines = [ line for line in module.group(1).splitlines() if not line.lstrip().startswith("#") ] - assert any( - "-DGGML_NATIVE=OFF" in line and "pip3 install" in line for line in command_lines - ), "the Flatpak Vulkan build must not compile with -march=native" + install_line = next((line for line in command_lines if "pip3 install" in line), "") + assert install_line, "the pywhispercpp pip3 install line is gone" + for flag in PORTABLE_GGML_OPTIONS: + assert f"-D{flag}=OFF" in install_line, ( + f"the Flatpak build does not pin {flag} off; the package runs on" + " CPUs the builder's instruction set does not describe" + ) def test_the_manifest_env_agrees_with_the_module() -> None: diff --git a/tests/test_installer_pinned_dependencies.py b/tests/test_installer_pinned_dependencies.py index c90bfeb..6d95c9e 100644 --- a/tests/test_installer_pinned_dependencies.py +++ b/tests/test_installer_pinned_dependencies.py @@ -130,6 +130,9 @@ def test_missing_pins_fail_before_pip(tmp_path: Path, kind: str) -> None: @pytest.mark.parametrize("backend", ["cpu", "vulkan", "cuda"]) def test_rebuild_uses_only_locked_pywhispercpp(tmp_path: Path, backend: str) -> None: + """Every backend builds the pinned sdist. The PyPI wheel's libggml-cpu is + compiled for AVX2+FMA and SIGILLs on pre-Haswell CPUs (#957), so the CPU + path must compile like the GPU paths do, never install the wheel.""" command = ( "install_cpu_pywhispercpp log" if backend == "cpu" @@ -141,6 +144,10 @@ def test_rebuild_uses_only_locked_pywhispercpp(tmp_path: Path, backend: str) -> assert {"--require-hashes", "--no-deps", "--no-build-isolation", "--force-reinstall"} <= set( call ) + no_binary = call[call.index("--no-binary") + 1] + assert ( + no_binary == "pywhispercpp" + ), "installing the wheel ships the AVX2 build that SIGILLs on old CPUs" selected = (tmp_path / "pywhispercpp.txt").read_text() assert re.findall(r"^([\w.-]+)==", selected, re.M) == ["pywhispercpp"] lock = tomllib.loads((ROOT / "uv.lock").read_text()) diff --git a/tests/test_native_packaging.py b/tests/test_native_packaging.py index c73d458..3175655 100644 --- a/tests/test_native_packaging.py +++ b/tests/test_native_packaging.py @@ -338,3 +338,46 @@ def test_pipeline_gates_the_packages() -> None: for distro in re.findall(r"- (\S+)", matrix.group(1)): family = distro.split(":")[0].split("/")[-1] assert family in smoke, f"smoke-test.sh has no recipe for {distro}" + + +#: ggml SIMD options pinned off for the portable x86-64 baseline in every +#: packaged pywhispercpp build (#957). GGML_NATIVE=OFF alone leaves each of +#: these on while ggml's native default is on. +PORTABLE_GGML_OPTIONS = ( + "GGML_NATIVE", + "GGML_SSE42", + "GGML_AVX", + "GGML_AVX2", + "GGML_BMI2", + "GGML_FMA", + "GGML_F16C", +) + + +def test_vendored_libggml_cpu_is_rebuilt_at_the_portable_baseline() -> None: + """The pinned pywhispercpp wheels build.sh vendors carry a libggml-cpu + compiled for AVX2+FMA, which dies with SIGILL at model load on pre-Haswell + CPUs (#957). The package must rebuild just that library from the same + pinned sdist at the plain x86-64 baseline and swap it into the vendored + copies; without a toolchain in the container image that build cannot run.""" + build = BUILD_SH.read_text(encoding="utf-8") + assert ( + "--no-binary pywhispercpp" in build + ), "the baseline libggml-cpu must come from the hash-pinned sdist, not a wheel" + command_lines = [line for line in build.splitlines() if not line.lstrip().startswith("#")] + cmake_args = "\n".join( + line for line in command_lines if "-DGGML_" in line or "cmake -S" in line + ) + for flag in PORTABLE_GGML_OPTIONS: + assert ( + f"-D{flag}=OFF" in cmake_args + ), f"the vendored libggml-cpu rebuild does not pin {flag} off" + assert "libggml-cpu" in build and "find" in build, ( + "the rebuild must swap into the vendored copies in place so the" + " wheels' NEEDED entries still resolve" + ) + container = CONTAINER_BUILD_SH.read_text(encoding="utf-8") + for tool in ("cmake", "g++", "binutils"): + assert ( + tool in container + ), f"container-build.sh installs no {tool}; the baseline rebuild fails" diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py index 09a60e4..2cd1d7e 100644 --- a/tests/test_snap_packaging.py +++ b/tests/test_snap_packaging.py @@ -196,3 +196,37 @@ def test_snap_promote_dispatch_input_never_reaches_the_shell() -> None: assert "${{" not in line, f"a run: step interpolates a template expression: {line.strip()}" assert "TAG: ${{ inputs.tag }}" in text assert "REVISION: ${{ steps.candidate.outputs.revision }}" in text + + +#: ggml SIMD options pinned off for the portable x86-64 baseline in every +#: packaged pywhispercpp build (#957). GGML_NATIVE=OFF alone leaves each of +#: these on while ggml's native default is on. +PORTABLE_GGML_OPTIONS = ( + "GGML_NATIVE", + "GGML_SSE42", + "GGML_AVX", + "GGML_AVX2", + "GGML_BMI2", + "GGML_FMA", + "GGML_F16C", +) + + +def test_snap_builds_pywhispercpp_at_the_portable_baseline() -> None: + """pywhispercpp's PyPI wheel ships a libggml-cpu compiled for AVX2+FMA, + which dies with SIGILL at model load on pre-Haswell CPUs (#957). The + python plugin installs that wheel by default, so the recipe must force an + sdist build with every ggml SIMD option pinned off — GGML_NATIVE=OFF + alone leaves them all on.""" + doc = yaml.safe_load(SNAPCRAFT_YAML.read_text(encoding="utf-8")) + env: dict = {} + for entry in doc["parts"]["vocalinux"].get("build-environment") or []: + env.update(entry) + assert ( + env.get("PIP_NO_BINARY") == "pywhispercpp" + ), "without --no-binary the snap installs the AVX2+FMA wheel (#957)" + cmake_args = env.get("CMAKE_ARGS") or "" + for flag in PORTABLE_GGML_OPTIONS: + assert ( + f"-D{flag}=OFF" in cmake_args + ), f"the snap's pywhispercpp build does not pin {flag} off"