diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e8b9ef1..3a2753b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -151,6 +151,12 @@ if(TRANSCRIBE_GGML_BACKEND_DL) endif() endif() +# Lets the loader call the Metal capability query behind the AUTO-selection +# gate (see metal_backend_lacks_simdgroup_mm in transcribe-load-common.cpp). +if(GGML_METAL) + target_compile_definitions(transcribe PRIVATE TRANSCRIBE_HAS_METAL=1) +endif() + if(TRANSCRIBE_ENABLE_VALIDATION_HOOKS) target_compile_definitions(transcribe PRIVATE TRANSCRIBE_ENABLE_VALIDATION_HOOKS) endif() diff --git a/src/transcribe-load-common.cpp b/src/transcribe-load-common.cpp index f120590..df14823 100644 --- a/src/transcribe-load-common.cpp +++ b/src/transcribe-load-common.cpp @@ -9,6 +9,11 @@ #include "ggml-backend.h" #include "ggml.h" #include "gguf.h" +// Under GGML_BACKEND_DL the Metal symbols live in a loadable module and are +// not link-visible here, so the capability query below is compiled out. +#if defined(TRANSCRIBE_HAS_METAL) && !defined(TRANSCRIBE_GGML_BACKEND_DL) +# include "ggml-metal.h" +#endif #include "transcribe-backend.h" #include "transcribe-log.h" #include "transcribe-path.h" @@ -24,6 +29,32 @@ namespace transcribe::load_common { +// GPUs below MTLGPUFamilyApple7 (Intel iGPUs, AMD dGPUs on Intel Macs) have +// no simdgroup matrix multiply; ggml's fallback matmul kernels silently +// produce garbage transcripts there (Handy issue #1608). No generic ggml +// capability exposes this — MUL_MAT's supports_op only needs simdgroup +// *reduction*, which these GPUs do report — so ask Metal directly. +bool metal_backend_lacks_simdgroup_mm(ggml_backend_t be, ggml_backend_dev_t dev) { + // Test hook: force the "missing" verdict by device-name substring + // ("*" matches any) so the gate can be exercised on healthy hardware. + if (const char * match = std::getenv("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM"); + match != nullptr && match[0] != '\0') { + const char * name = ggml_backend_dev_name(dev); + if (std::strcmp(match, "*") == 0 || (name != nullptr && std::strstr(name, match) != nullptr)) { + return true; + } + } +#if defined(TRANSCRIBE_HAS_METAL) && !defined(TRANSCRIBE_GGML_BACKEND_DL) + // ggml_backend_metal_supports_family asserts the backend is Metal; guard + // so a classify/is_metal disagreement can never abort the process. + if (ggml_backend_is_metal(be)) { + return !ggml_backend_metal_supports_family(be, /*MTLGPUFamilyApple7=*/7); + } +#endif + (void) be; + return false; +} + namespace { // Device init can throw from GPU drivers. Treat that like nullptr so AUTO can @@ -87,6 +118,26 @@ ggml_backend_t try_init_kind(BackendKind wanted, const char * error_tag, Backend } const BackendKind kind = classify_device(dev); + + // A Metal device without simdgroup matmul yields garbage transcripts + // (see metal_backend_lacks_simdgroup_mm): skip it under AUTO, honor + // an explicit Metal request with a warning. + if (kind == BackendKind::Metal && metal_backend_lacks_simdgroup_mm(be, dev)) { + const char * dname = ggml_backend_dev_name(dev); + if (wanted == BackendKind::OtherGpu) { + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, + "%s: skipping Metal device \"%s\": no simdgroup matrix multiply " + "(pre-Apple7 GPU)", + error_tag, dname != nullptr ? dname : "?"); + safe_backend_free(be); + continue; + } + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, + "%s: Metal device \"%s\" has no simdgroup matrix multiply " + "(pre-Apple7 GPU); transcription may be incorrect and very slow", + error_tag, dname != nullptr ? dname : "?"); + } + log_msg(TRANSCRIBE_LOG_LEVEL_INFO, "%s: using %s backend: %s", error_tag, kind_name(kind), ggml_backend_dev_name(dev)); out_kind = kind; @@ -200,6 +251,16 @@ transcribe_status init_backends_explicit_index(transcribe_backend_request reques log_msg(TRANSCRIBE_LOG_LEVEL_INFO, "%s: using %s backend (gpu_device %d): %s", error_tag, kind_name(got), dev_index, ggml_backend_dev_name(dev)); + // An explicit device index is always honored: warn only (same gate as + // try_init_kind). + if (got == BackendKind::Metal && metal_backend_lacks_simdgroup_mm(gpu_be, dev)) { + const char * dname = ggml_backend_dev_name(dev); + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, + "%s: Metal device \"%s\" has no simdgroup matrix multiply " + "(pre-Apple7 GPU); transcription may be incorrect and very slow", + error_tag, dname != nullptr ? dname : "?"); + } + out.primary = gpu_be; out.primary_kind = got; out.scheduler_list.push_back(gpu_be); diff --git a/src/transcribe-load-common.h b/src/transcribe-load-common.h index 1d548fe..fd0f72f 100644 --- a/src/transcribe-load-common.h +++ b/src/transcribe-load-common.h @@ -26,12 +26,22 @@ struct ggml_backend; typedef struct ggml_backend * ggml_backend_t; struct ggml_backend_buffer; typedef struct ggml_backend_buffer * ggml_backend_buffer_t; +struct ggml_backend_device; +typedef struct ggml_backend_device * ggml_backend_dev_t; struct ggml_context; struct ggml_tensor; struct gguf_context; namespace transcribe::load_common { +// True when `dev` is a Metal device with no simdgroup matrix multiply (below +// MTLGPUFamilyApple7), which silently produces garbage transcripts (Handy +// issue #1608). `be` must be the initialized backend for `dev`. Honors the +// TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM hook; returns false when the Metal +// query is compiled out (non-Metal or GGML_BACKEND_DL builds). Exposed for +// the gate unit test. +bool metal_backend_lacks_simdgroup_mm(ggml_backend_t be, ggml_backend_dev_t dev); + // Resolve a BackendPlan from a caller's backend request by walking // ggml's device registry. // diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b35bb29..dcf5c11 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -267,6 +267,23 @@ transcribe_apply_warnings(transcribe_backend_init_throw_unit) add_test(NAME transcribe_backend_init_throw_unit COMMAND transcribe_backend_init_throw_unit) +# ----------------------------------------------------------------------------- +# Pins the AUTO-selection gate that skips a Metal device with no simdgroup +# matrix multiply (pre-Apple7 GPUs) and falls back to CPU (Handy issue #1608). + +add_executable(transcribe_backend_metal_simdgroup_gate_unit + backend_metal_simdgroup_gate_unit.cpp) + +target_link_libraries(transcribe_backend_metal_simdgroup_gate_unit PRIVATE transcribe ggml) + +target_include_directories(transcribe_backend_metal_simdgroup_gate_unit PRIVATE + ${CMAKE_SOURCE_DIR}/src) + +transcribe_apply_warnings(transcribe_backend_metal_simdgroup_gate_unit) + +add_test(NAME transcribe_backend_metal_simdgroup_gate_unit + COMMAND transcribe_backend_metal_simdgroup_gate_unit) + # ----------------------------------------------------------------------------- # No-throw teardown wrappers (safe_backend_free & co.) # ----------------------------------------------------------------------------- diff --git a/tests/backend_metal_simdgroup_gate_unit.cpp b/tests/backend_metal_simdgroup_gate_unit.cpp new file mode 100644 index 0000000..b3485ba --- /dev/null +++ b/tests/backend_metal_simdgroup_gate_unit.cpp @@ -0,0 +1,164 @@ +// backend_metal_simdgroup_gate_unit.cpp - AUTO device selection skips a Metal +// device that lacks simdgroup matrix multiply (Handy issue #1608). +// +// Forces the gate's verdict via the TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM +// hook and pins the behavior: +// +// AUTO -> skip the gated Metal device and fall back to CPU. +// explicit METAL -> honor the device the caller named (warn only). +// hook unset / non-matching -> selection matches the real hardware verdict, +// probed once in main() (on a real pre-Apple7 +// GPU the gate fires with the hook unset). +// +// Every case is a no-op on machines with no Metal device (e.g. Linux CI). + +#include "ggml-backend.h" +#include "transcribe-backend.h" +#include "transcribe-load-common.h" +#include "transcribe.h" + +#include +#include + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +void set_env(const char * key, const char * value) { +#if defined(_WIN32) + _putenv_s(key, value); +#else + setenv(key, value, 1); +#endif +} + +void unset_env(const char * key) { +#if defined(_WIN32) + _putenv_s(key, ""); +#else + unsetenv(key); +#endif +} + +void free_plan(transcribe::BackendPlan & plan) { + for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { + ggml_backend_free(*it); + } + plan = transcribe::BackendPlan{}; +} + +// Real hardware verdict for the first Metal device, probed once in main() +// with the hook cleared. Baselines assert against it because on a real +// pre-Apple7 GPU the gate sends AUTO to CPU even with the hook unset. +enum class MetalProbe { None, HasSimdgroupMM, LacksSimdgroupMM }; + +MetalProbe g_probe = MetalProbe::None; + +MetalProbe probe_metal_device() { + const size_t n = ggml_backend_dev_count(); + for (size_t i = 0; i < n; ++i) { + ggml_backend_dev_t dev = ggml_backend_dev_get(i); + if (transcribe::classify_device(dev) != transcribe::BackendKind::Metal) { + continue; + } + ggml_backend_t be = ggml_backend_dev_init(dev, nullptr); + if (be == nullptr) { + // A Metal device that can't init never reaches the gate; treat + // as absent. + return MetalProbe::None; + } + const bool lacks = transcribe::load_common::metal_backend_lacks_simdgroup_mm(be, dev); + ggml_backend_free(be); + return lacks ? MetalProbe::LacksSimdgroupMM : MetalProbe::HasSimdgroupMM; + } + return MetalProbe::None; +} + +transcribe::BackendKind expected_auto_kind() { + return g_probe == MetalProbe::LacksSimdgroupMM ? transcribe::BackendKind::Cpu : transcribe::BackendKind::Metal; +} + +// Baseline: with no hook, AUTO selection matches the hardware verdict. +void test_hook_unset_matches_hardware_verdict() { + if (g_probe == MetalProbe::None) { + return; + } + unset_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM"); + transcribe::BackendPlan plan; + CHECK(transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", plan) == TRANSCRIBE_OK); + CHECK(plan.primary_kind == expected_auto_kind()); + free_plan(plan); +} + +// A non-matching hook value leaves selection unchanged. +void test_nonmatching_hook_is_inert() { + if (g_probe == MetalProbe::None) { + return; + } + set_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM", "no-such-device-name-xyzzy"); + transcribe::BackendPlan plan; + CHECK(transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", plan) == TRANSCRIBE_OK); + CHECK(plan.primary_kind == expected_auto_kind()); + free_plan(plan); + unset_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM"); +} + +// The fix: AUTO skips a simdgroup-mm-less Metal device and lands on CPU. +void test_auto_skips_gated_metal_and_falls_back_to_cpu() { + if (g_probe == MetalProbe::None) { + return; + } + set_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM", "*"); + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", plan); + CHECK(st == TRANSCRIBE_OK); + CHECK(plan.primary != nullptr); + CHECK(plan.primary_kind == transcribe::BackendKind::Cpu); + free_plan(plan); + unset_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM"); +} + +// An explicit Metal request is an override: the gated device is honored +// (the loader warns) rather than turning into a hard failure. +void test_explicit_metal_is_honored_despite_gate() { + if (g_probe == MetalProbe::None) { + return; + } + set_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM", "*"); + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_METAL, 0, "test", plan); + CHECK(st == TRANSCRIBE_OK); + CHECK(plan.primary_kind == transcribe::BackendKind::Metal); + free_plan(plan); + unset_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM"); +} + +} // namespace + +int main() { + transcribe_init_backends_default(); + + // Clear any inherited hook so the probe sees the real hardware verdict. + unset_env("TRANSCRIBE_TEST_METAL_NO_SIMDGROUP_MM"); + g_probe = probe_metal_device(); + + test_hook_unset_matches_hardware_verdict(); + test_nonmatching_hook_is_inert(); + test_auto_skips_gated_metal_and_falls_back_to_cpu(); + test_explicit_metal_is_honored_despite_gate(); + + if (g_failures != 0) { + std::fprintf(stderr, "%d check(s) failed\n", g_failures); + return 1; + } + std::printf("ok\n"); + return 0; +}