diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..39820ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Patch files must stay LF on every platform. Without this, Windows checkouts +# (autocrlf) convert them to CRLF, which `git apply` cannot parse/apply +# ("patch does not apply"). The build.rs applies these to the vendored source. +*.patch text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12ea75..772ef05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,22 +8,42 @@ on: jobs: build: - name: build-${{ matrix.os }} + name: build-${{ matrix.os }}-${{ matrix.kind }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: + # --- static link (default) --- # macOS: Metal backend is auto-enabled by build.rs on target_os=macos. - os: macos-latest + kind: static features: "" # Linux + Windows: GPU support via the Vulkan backend (ggml-vulkan). # Compile-only here (GPU-less runners) — shaders are compiled to SPIR-V # via glslc at build time; no VkInstance is created. - os: ubuntu-latest + kind: static features: "--features vulkan" - os: windows-latest + kind: static features: "--features vulkan" + # --- dynamic backends (GGML_BACKEND_DL): backends as loadable modules --- + # macOS: Metal still auto-on, built as a module. + - os: macos-latest + kind: dl + features: "--features dynamic-backends" + # Linux: DL + Vulkan so the Vulkan backend is built as a module. parakeet + # calls ggml-cpu symbols directly; GNU ld defers them in the shared object + # and the RTLD_GLOBAL ggml patch resolves them at load. + - os: ubuntu-latest + kind: dl + features: "--features dynamic-backends,vulkan" + # NOTE: Windows DL is intentionally NOT built yet. MSVC cannot defer the + # direct ggml_backend_cpu_init/_set_n_threads symbol references at DLL link + # time (no `-undefined dynamic_lookup` equivalent → LNK2019). A follow-up + # parakeet patch must route CPU-backend access through the ggml registry + # before Windows DL can link. Windows is not functional yet anyway. env: # Pin a known-good LunarG SDK version for Windows (see llama.cpp CI). VULKAN_VERSION: "1.4.313.2" diff --git a/parakeet-cpp-sys/Cargo.toml b/parakeet-cpp-sys/Cargo.toml index b26fa8b..5e8ddb0 100644 --- a/parakeet-cpp-sys/Cargo.toml +++ b/parakeet-cpp-sys/Cargo.toml @@ -21,3 +21,6 @@ metal = [] vulkan = [] cuda = [] hip = [] +# Build ggml backends as loadable modules (dlopen'd at runtime) and link the +# shared core instead of a fully static binary. See build.rs `dl` branch. +dynamic-backends = [] diff --git a/parakeet-cpp-sys/build.rs b/parakeet-cpp-sys/build.rs index c10965d..491a475 100644 --- a/parakeet-cpp-sys/build.rs +++ b/parakeet-cpp-sys/build.rs @@ -11,50 +11,62 @@ fn main() { let ggml = upstream.join("third_party/ggml"); // --- Cross-platform, no-bash patch application (spec §5.2 / §13.6 option a) --- - let patches_dir = upstream.join("third_party/ggml-patches"); - if patches_dir.is_dir() { - let mut patches: Vec = std::fs::read_dir(&patches_dir) - .unwrap() - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| p.extension().is_some_and(|x| x == "patch")) - .collect(); - patches.sort(); - for p in patches { - let already = Command::new("git") - .args([ - "-C", - ggml.to_str().unwrap(), - "apply", - "--reverse", - "--check", - ]) - .arg(&p) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if already { - continue; - } - let status = Command::new("git") - .args(["-C", ggml.to_str().unwrap(), "apply"]) - .arg(&p) - .status() - .expect("failed to spawn git apply"); - assert!(status.success(), "git apply failed for {}", p.display()); - } - } + // Two independent patch sets, applied to two different roots: + // * `patches/parakeet/*` → the parakeet.cpp submodule root (CMakeLists/src) + // * `/third_party/ggml-patches/*` → the vendored ggml submodule + // Each apply is idempotent: a `--reverse --check` succeeds only when the patch + // is already in the tree, in which case we skip the forward apply. + let parakeet_patches_dir = manifest.join("patches/parakeet"); + apply_patches(¶keet_patches_dir, &upstream); + let ggml_patches_dir = upstream.join("third_party/ggml-patches"); + apply_patches(&ggml_patches_dir, &ggml); + // Our own (repo-tracked) ggml patches, applied to the ggml submodule root. + // The upstream `ggml-patches` above live INSIDE the parakeet.cpp submodule + // (not tracked by this repo); patches that must survive a clean checkout / + // CI / `git submodule update` belong here instead. + let our_ggml_patches_dir = manifest.join("patches/ggml"); + apply_patches(&our_ggml_patches_dir, &ggml); + + // Dynamic-backends mode (the `dynamic-backends` feature): ggml builds each + // backend (CPU variants + GPU) as a loadable MODULE that is dlopen'd at + // runtime, and the core (ggml/ggml-base) + parakeet build SHARED. This lets + // the app ship a portable CPU-only core and pick up a GPU backend module + // when present. The opposite (default) is a fully static link. + let dl = cfg!(feature = "dynamic-backends"); let mut cfg = cmake::Config::new(&upstream); - cfg.define("PARAKEET_SHARED", "OFF") - .define("GGML_NATIVE", "OFF") - .define("BUILD_SHARED_LIBS", "OFF") - .define("PARAKEET_BUILD_CLI", "OFF") + cfg.define("PARAKEET_BUILD_CLI", "OFF") .define("PARAKEET_BUILD_TESTS", "OFF") // Use ggml's built-in threadpool for the CPU backend instead of OpenMP, // so we don't have to link libgomp (Linux) / vcomp (Windows) into the // Rust binary. CPU is a fallback here (GPU backends are primary). .define("GGML_OPENMP", "OFF"); + if dl { + // The patch's PARAKEET_GGML_BACKEND_DL flips GGML_BACKEND_DL + + // BUILD_SHARED_LIBS + GGML_CPU_ALL_VARIANTS on and skips forcing + // GGML_NATIVE (a DL build must stay portable). Build parakeet shared so + // it links against the shared core rather than absorbing it. + cfg.define("PARAKEET_GGML_BACKEND_DL", "ON") + .define("PARAKEET_SHARED", "ON"); + // parakeet's backend.cpp / model_loader.cpp call CPU-backend symbols + // (ggml_backend_cpu_init / _is_cpu / _set_n_threads) directly. Under DL + // those live in the dlopen'd CPU module, not the link-time core, so the + // shared parakeet/final binary link must defer them to runtime resolution + // (the loaded CPU module exports them into the global table). + // macOS (Apple ld) syntax only. On Linux, GNU ld leaves undefined symbols + // in a shared object to be resolved at load time by default, so no flag is + // needed — the RTLD_GLOBAL ggml patch exposes the dlopen'd CPU module's + // symbols at runtime. (`-undefined dynamic_lookup` is not valid GNU ld.) + if cfg!(target_os = "macos") { + cfg.define("CMAKE_SHARED_LINKER_FLAGS", "-Wl,-undefined,dynamic_lookup"); + } + } else { + cfg.define("PARAKEET_SHARED", "OFF") + .define("GGML_NATIVE", "OFF") + .define("BUILD_SHARED_LIBS", "OFF"); + } + if cfg!(target_os = "macos") || cfg!(feature = "metal") { cfg.define("PARAKEET_GGML_METAL", "ON"); } @@ -78,44 +90,104 @@ fn main() { let dst = cfg.build(); - // Static libs to link, in dependency order (parakeet depends on ggml). - // Verified against vendor pin e270af7 (ggml e705c5fe). Revisit on submodule bump: - // ggml has split/renamed backend libs across versions. - // Search the install dir (`lib/`) + the build tree (`build/`), plus the - // `Release/` subdirs that Windows multi-config (MSVC/VS) generators produce. + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + // Link-search dirs. cmake-rs installs to `dst/`; under DL the SHARED core + // (libggml*.dylib / .so / .dll + import lib) lands in `lib/`, the loadable + // backend MODULES land in `bin/`. The static build keeps everything as + // `lib*.a` / `*.lib` across `lib/` + the build tree (+ `Release/` on the + // Windows multi-config generators). let lib_dirs = [ dst.join("lib"), + dst.join("bin"), dst.join("build"), dst.join("lib").join("Release"), + dst.join("bin").join("Release"), dst.join("build").join("Release"), ]; for dir in &lib_dirs { println!("cargo:rustc-link-search=native={}", dir.display()); } - let candidates = [ - "parakeet", - "ggml", - "ggml-base", - "ggml-cpu", - "ggml-metal", - "ggml-blas", - "ggml-vulkan", - "ggml-cuda", - "ggml-hip", - ]; - for lib in candidates { - // Unix: `lib.a`; Windows MSVC: `.lib`. - let found = lib_dirs.iter().any(|d| { - d.join(format!("lib{lib}.a")).exists() || d.join(format!("{lib}.lib")).exists() - }); - if found { - println!("cargo:rustc-link-lib=static={lib}"); + + if dl { + // Dynamic-backends: link only the SHARED core (parakeet + the ggml + // dispatcher + ggml-base). The backends (ggml-cpu*/ggml-metal/...) are + // loadable MODULES, dlopen'd at runtime by ggml_backend_load_all — they + // must NOT be linked. + for lib in ["parakeet", "ggml", "ggml-base"] { + println!("cargo:rustc-link-lib=dylib={lib}"); + } + + // parakeet + ggml are shared, so the final binary needs them resolvable + // at RUNTIME via an rpath to each dir that holds a produced dynamic lib + // (libparakeet.dylib lives in `build/`, the ggml core in `lib/`, the + // backend modules in `bin/`). + let dylib_dirs: Vec<&PathBuf> = lib_dirs + .iter() + .filter(|d| d.is_dir() && dir_has_dynamic_lib(d)) + .collect(); + // Emit the rpaths for this crate's own link units. NOTE: cargo does NOT + // propagate `rustc-link-arg` to downstream bins/tests, so dependent + // crates that build an executable (e.g. the DL test) must re-emit these + // from their own build script — read them from the `DEP_PARAKEET_RPATH` + // metadata key below. (Windows has no rpath concept; the loader finds the + // DLLs via the link-search dirs / PATH instead.) + if target_os == "macos" || target_os == "linux" { + for dir in &dylib_dirs { + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display()); + } + } + // Linux: the consumer binary links libparakeet.so, which leaves the + // ggml-cpu symbols undefined (resolved at runtime from the dlopen'd CPU + // module via the RTLD_GLOBAL patch). GNU ld rejects undefined shared-lib + // symbols at exe-link time unless told to allow them. (macOS uses the + // cmake `-undefined dynamic_lookup` flag instead.) + if target_os == "linux" { + println!("cargo:rustc-link-arg=-Wl,--allow-shlib-undefined"); + } + // Export the dylib dirs as `links` metadata so dependents can re-emit the + // rpath: `links = "parakeet"` maps `cargo:rpath=…` → `DEP_PARAKEET_RPATH`. + let rpath = dylib_dirs + .iter() + .map(|d| d.display().to_string()) + .collect::>() + .join(";"); + println!("cargo:rpath={rpath}"); + + // Backend MODULES live in `dst/bin`. Surface that dir so a test can point + // PARAKEET_BACKENDS_DIR at it (the patched global_backend() honors it). + // Exposed as a `links` metadata key (`DEP_PARAKEET_BACKENDS_DIR` for + // dependents) and printed for build-log discoverability. + let backends_dir = dst.join("bin"); + println!("cargo:backends_dir={}", backends_dir.display()); + } else { + // Static link, in dependency order (parakeet depends on ggml). Verified + // against vendor pin e270af7 (ggml e705c5fe). Revisit on submodule bump: + // ggml has split/renamed backend libs across versions. + let candidates = [ + "parakeet", + "ggml", + "ggml-base", + "ggml-cpu", + "ggml-metal", + "ggml-blas", + "ggml-vulkan", + "ggml-cuda", + "ggml-hip", + ]; + for lib in candidates { + // Unix: `lib.a`; Windows MSVC: `.lib`. + let found = lib_dirs.iter().any(|d| { + d.join(format!("lib{lib}.a")).exists() || d.join(format!("{lib}.lib")).exists() + }); + if found { + println!("cargo:rustc-link-lib=static={lib}"); + } } } // C++ standard library: libc++ on macOS (clang), libstdc++ on Linux (gcc). // MSVC links its C++ runtime automatically, so emit nothing on Windows. - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); match target_os.as_str() { "macos" => println!("cargo:rustc-link-lib=c++"), "linux" => println!("cargo:rustc-link-lib=stdc++"), @@ -123,7 +195,9 @@ fn main() { } // Vulkan loader, required when the ggml-vulkan backend is statically linked. - if cfg!(feature = "vulkan") { + // Under DL the backend is a runtime module that links its own loader, so the + // Rust binary needs nothing. + if cfg!(feature = "vulkan") && !dl { match target_os.as_str() { "linux" => println!("cargo:rustc-link-lib=vulkan"), "windows" => { @@ -136,7 +210,11 @@ fn main() { } } - if cfg!(target_os = "macos") { + // Frameworks are only needed at link time for the STATIC build (the Metal + // backend is compiled into the binary). Under DL the Metal module is a + // separate dlopen'd MODULE that links its own frameworks, so the Rust binary + // links none of them. + if cfg!(target_os = "macos") && !dl { for fw in [ "Metal", "MetalKit", @@ -147,7 +225,7 @@ fn main() { println!("cargo:rustc-link-lib=framework={fw}"); } } - if cfg!(target_os = "windows") { + if cfg!(target_os = "windows") && !dl { // ggml-cpu reads the Windows registry (CPU feature detection) → // RegOpenKeyExA / RegQueryValueExA / RegCloseKey live in advapi32. println!("cargo:rustc-link-lib=advapi32"); @@ -166,6 +244,66 @@ fn main() { println!("cargo:rerun-if-changed=wrapper.h"); println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=patches/parakeet"); + println!("cargo:rerun-if-changed=patches/ggml"); println!("cargo:rerun-if-changed=../vendor/parakeet.cpp/include/parakeet_capi.h"); println!("cargo:rerun-if-changed=../vendor/parakeet.cpp/CMakeLists.txt"); } + +/// Apply every `*.patch` in `dir` (sorted) to the git tree rooted at `root`, +/// idempotently: a patch that already applies cleanly in reverse is assumed +/// present and skipped. No-op when `dir` does not exist. Uses `git apply` +/// (no bash) so it works the same on every platform. +fn apply_patches(dir: &std::path::Path, root: &std::path::Path) { + if !dir.is_dir() { + return; + } + let mut patches: Vec = std::fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "patch")) + .collect(); + patches.sort(); + let root = root.to_str().unwrap(); + for p in patches { + // `--ignore-whitespace` makes apply EOL-robust: on Windows the source is + // often checked out CRLF while the patch context is LF, which otherwise + // fails with "patch does not apply". + let already = Command::new("git") + .args([ + "-C", + root, + "apply", + "--reverse", + "--check", + "--ignore-whitespace", + ]) + .arg(&p) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if already { + continue; + } + let status = Command::new("git") + .args(["-C", root, "apply", "--ignore-whitespace"]) + .arg(&p) + .status() + .expect("failed to spawn git apply"); + assert!(status.success(), "git apply failed for {}", p.display()); + } +} + +/// True if `dir` holds at least one dynamic library (`.dylib` / `.so` / `.dll`). +/// Used under DL to decide which install dirs deserve a runtime rpath. +fn dir_has_dynamic_lib(dir: &std::path::Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries.filter_map(Result::ok).any(|e| { + e.path() + .extension() + .and_then(|x| x.to_str()) + .is_some_and(|x| matches!(x, "dylib" | "so" | "dll")) + }) +} diff --git a/parakeet-cpp-sys/patches/ggml/0001-dl-load-global.patch b/parakeet-cpp-sys/patches/ggml/0001-dl-load-global.patch new file mode 100644 index 0000000..6eed7cc --- /dev/null +++ b/parakeet-cpp-sys/patches/ggml/0001-dl-load-global.patch @@ -0,0 +1,34 @@ +dl-backends: load backend modules with RTLD_GLOBAL (Unix) + +Under GGML_BACKEND_DL the backends (CPU variants + Metal/CUDA/Vulkan/...) ship +as loadable modules that ggml dlopen's at runtime. parakeet.cpp's backend.cpp +calls a handful of CPU-backend symbols DIRECTLY at link time +(ggml_backend_cpu_init / ggml_backend_cpu_set_n_threads / ggml_backend_is_cpu / +ggml_backend_cpu_buffer_from_ptr) to build the CPU fallback that the scheduler +uses for ops the GPU backend lacks. Those symbols live ONLY in the dlopen'd CPU +module, not in the shared core, so the shared parakeet/final binary links them +as undefined (resolved via -Wl,-undefined,dynamic_lookup). + +ggml's default dlopen uses RTLD_LOCAL, which keeps a module's symbols out of the +global flat-namespace pool, so those dynamic_lookup references never resolve and +the first call jumps to address 0 -> SIGSEGV (observed on macOS during the very +first Backend construction on the Metal path). Loading with RTLD_GLOBAL promotes +each backend module's exported symbols into the global namespace so the +dynamic_lookup references in libparakeet bind to the real CPU-module functions. + +Windows (the _WIN32 branch above) is unaffected: GetProcAddress already resolves +from the loaded module, and the loader finds the DLLs via the link-search/PATH. + +diff --git a/src/ggml-backend-dl.cpp b/src/ggml-backend-dl.cpp +index a65cf009..69ef4b02 100644 +--- a/src/ggml-backend-dl.cpp ++++ b/src/ggml-backend-dl.cpp +@@ -32,7 +32,7 @@ const char * dl_error() { + #else + + dl_handle * dl_load_library(const fs::path & path) { +- dl_handle * handle = dlopen(path.string().c_str(), RTLD_NOW | RTLD_LOCAL); ++ dl_handle * handle = dlopen(path.string().c_str(), RTLD_NOW | RTLD_GLOBAL); + return handle; + } + diff --git a/parakeet-cpp-sys/patches/parakeet/0001-backend-dl.patch b/parakeet-cpp-sys/patches/parakeet/0001-backend-dl.patch new file mode 100644 index 0000000..cb8a949 --- /dev/null +++ b/parakeet-cpp-sys/patches/parakeet/0001-backend-dl.patch @@ -0,0 +1,126 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index da2dfb8..4d84c3d 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -14,16 +14,32 @@ option(PARAKEET_GGML_CUDA "Forward GGML_CUDA" OFF) + option(PARAKEET_GGML_METAL "Forward GGML_METAL" OFF) + option(PARAKEET_GGML_VULKAN "Forward GGML_VULKAN" OFF) + option(PARAKEET_GGML_HIP "Forward GGML_HIP (ROCm)" OFF) ++option(PARAKEET_GGML_BACKEND_DL "Build ggml backends as loadable modules" OFF) + + set(GGML_CUDA ${PARAKEET_GGML_CUDA} CACHE BOOL "" FORCE) + set(GGML_METAL ${PARAKEET_GGML_METAL} CACHE BOOL "" FORCE) + set(GGML_VULKAN ${PARAKEET_GGML_VULKAN} CACHE BOOL "" FORCE) + set(GGML_HIP ${PARAKEET_GGML_HIP} CACHE BOOL "" FORCE) + ++# Dynamic backend loading: ggml builds each backend (CPU variants + GPU) as a ++# separate loadable module that ggml_backend_load_all[_from_path] dlopens at ++# runtime, so the app can ship CPU-only and pick up a GPU backend module when ++# present. DL implies shared libs, and the CPU backend must be built as ALL ++# portable variants (no -march=native baked in) so it runs on any CPU; native ++# tuning is selected per-variant at load time instead. See the load_all call in ++# src/ggml_graph.cpp. ++if(PARAKEET_GGML_BACKEND_DL) ++ set(GGML_BACKEND_DL ON CACHE BOOL "" FORCE) ++ set(BUILD_SHARED_LIBS ON CACHE BOOL "" FORCE) ++ set(GGML_CPU_ALL_VARIANTS ON CACHE BOOL "" FORCE) ++endif() ++ + # Performance: -march=native (GGML_NATIVE) and tinyBLAS SGEMM (GGML_LLAMAFILE) + # give meaningful free speedups (~30% and ~25% per the rt-detr.cpp benchmarks). +-# Force them on unless the caller explicitly overrides. +-if(NOT DEFINED GGML_NATIVE) ++# Force them on unless the caller explicitly overrides. DO NOT force GGML_NATIVE ++# in the DL case: a DL build must be portable (GGML_CPU_ALL_VARIANTS handles ++# per-CPU dispatch), so -march=native would defeat the point. ++if(NOT DEFINED GGML_NATIVE AND NOT PARAKEET_GGML_BACKEND_DL) + set(GGML_NATIVE ON CACHE BOOL "ggml: optimize the build for the current system" FORCE) + endif() + if(NOT DEFINED GGML_LLAMAFILE) +diff --git a/include/parakeet_capi.h b/include/parakeet_capi.h +index b082455..de21cd2 100644 +--- a/include/parakeet_capi.h ++++ b/include/parakeet_capi.h +@@ -221,6 +221,16 @@ void parakeet_capi_free_string(char* s); + // it (or until parakeet_capi_free). Returns "" if `ctx` is NULL. + const char* parakeet_capi_last_error(parakeet_ctx* ctx); + ++// Name of the compute device the (process-global) backend resolved to: "cpu" ++// for the CPU backend, or the ggml registry device name for a GPU backend (e.g. ++// "Metal", "CUDA0", "Vulkan0"). Under GGML_BACKEND_DL this reflects which ++// loadable backend module was selected after ggml_backend_load_all. The backend ++// is created lazily on first model load; if no model has been loaded yet on this ++// `ctx` (so the backend may not exist) the call still forces/returns the ++// resolved name. The returned pointer is owned by the backend and stays valid ++// for the process lifetime; do NOT free it. Returns "" if `ctx` is NULL. ++const char* parakeet_capi_backend_name(parakeet_ctx* ctx); ++ + #ifdef __cplusplus + } // extern "C" + #endif +diff --git a/src/ggml_graph.cpp b/src/ggml_graph.cpp +index f5bf84a..6b9b45c 100644 +--- a/src/ggml_graph.cpp ++++ b/src/ggml_graph.cpp +@@ -2,7 +2,9 @@ + #include "backend.hpp" + #include "common.hpp" + #include "ggml.h" ++#include "ggml-backend.h" + #include ++#include + #include + #include + +@@ -47,6 +49,21 @@ std::mutex g_backend_mutex; + constexpr int kDefaultThreads = 8; + + Backend& global_backend() { ++ // Under GGML_BACKEND_DL the GPU backends (Metal/Vulkan/CUDA/...) ship as ++ // separate loadable modules that are NOT in the device registry until they ++ // are dlopen'd. The Backend ctor walks that registry to pick a device, so the ++ // modules MUST be loaded before the FIRST Backend is constructed (here) or it ++ // would only ever see CPU. Run exactly once, process-wide, regardless of the ++ // entry point (C API, CLI, tests). PARAKEET_BACKENDS_DIR overrides the search ++ // dir (where the .so/.dylib/.dll backend modules live); unset = default search ++ // (next to the executable, then the system paths ggml probes). A no-op for a ++ // statically-linked (non-DL) build: the registry is already populated. ++ static std::once_flag s_load_backends_once; ++ std::call_once(s_load_backends_once, [] { ++ const char* dir = std::getenv("PARAKEET_BACKENDS_DIR"); ++ if (dir && *dir) ggml_backend_load_all_from_path(dir); ++ else ggml_backend_load_all(); ++ }); + // Lazy create (reset-safe: shutdown_backend() can free it, and a later call + // recreates it). Always reached under g_backend_mutex (run_graph holds it) + // or before any inference thread exists, so a plain null-check is sufficient. +diff --git a/src/parakeet_capi.cpp b/src/parakeet_capi.cpp +index 01de213..1ddc2f0 100644 +--- a/src/parakeet_capi.cpp ++++ b/src/parakeet_capi.cpp +@@ -3,6 +3,8 @@ + #include "model.hpp" // pk::Model + #include "streaming.hpp" // pk::StreamingSession + #include "mel.hpp" // pk::MelFrontend ++#include "ggml_graph.hpp" // pk::global_backend() ++#include "backend.hpp" // pk::Backend::device_name() + + #include "transcription.hpp" // pk::Transcription, pk::Word + +@@ -693,3 +695,16 @@ extern "C" const char* parakeet_capi_last_error(parakeet_ctx* ctx) { + if (!ctx) return ""; + return ctx->last_error.c_str(); + } ++ ++extern "C" const char* parakeet_capi_backend_name(parakeet_ctx* ctx) { ++ if (!ctx) return ""; ++ // The backend is process-global (pk::global_backend()), not per-ctx; `ctx` is ++ // taken for API symmetry and the NULL guard. device_name() returns a pointer ++ // owned by the backend, valid for the process lifetime. ++ try { ++ return pk::global_backend().device_name(); ++ } catch (...) { ++ ctx->last_error = "failed to resolve backend"; ++ return ""; ++ } ++} diff --git a/parakeet-cpp/Cargo.toml b/parakeet-cpp/Cargo.toml index b77709c..cb0f6a0 100644 --- a/parakeet-cpp/Cargo.toml +++ b/parakeet-cpp/Cargo.toml @@ -17,6 +17,7 @@ metal = ["parakeet-cpp-sys/metal"] vulkan = ["parakeet-cpp-sys/vulkan"] cuda = ["parakeet-cpp-sys/cuda"] hip = ["parakeet-cpp-sys/hip"] +dynamic-backends = ["parakeet-cpp-sys/dynamic-backends"] [dev-dependencies] hound = "3" diff --git a/parakeet-cpp/build.rs b/parakeet-cpp/build.rs new file mode 100644 index 0000000..b1f6790 --- /dev/null +++ b/parakeet-cpp/build.rs @@ -0,0 +1,32 @@ +// Under the `dynamic-backends` feature, parakeet-cpp-sys builds the ggml core + +// libparakeet as shared libraries and the backends as runtime-loaded modules. +// Executables that link this crate (tests, examples, downstream bins) therefore +// reference `@rpath/libparakeet.dylib` etc. at runtime. cargo does NOT propagate +// the sys crate's `rustc-link-arg` rpaths to those downstream link units, so we +// re-emit them here from the `DEP_PARAKEET_RPATH` metadata the sys crate exports. +// No-op for the default (fully static) build, where the key is absent. +fn main() { + if let Ok(rpath) = std::env::var("DEP_PARAKEET_RPATH") { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os == "macos" || target_os == "linux" { + for dir in rpath.split(';').filter(|s| !s.is_empty()) { + println!("cargo:rustc-link-arg=-Wl,-rpath,{dir}"); + } + } + // Linux DL: this crate's test/bin link units also link libparakeet.so, + // whose ggml-cpu symbols are undefined until the CPU module is loaded at + // runtime. Allow them at link time (mirrors the sys crate's flag). + if target_os == "linux" { + println!("cargo:rustc-link-arg=-Wl,--allow-shlib-undefined"); + } + } + // Surface the sys crate's loadable-backend-modules dir to this crate's own + // source/tests at compile time. cargo exposes the sys crate's `backends_dir` + // metadata key only to build scripts (as `DEP_PARAKEET_BACKENDS_DIR`), not to + // Rust code, so re-emit it as a compile-time env the DL integration test can + // read with `env!("PARAKEET_DL_BACKENDS_DIR")`. Absent on the static build. + if let Ok(dir) = std::env::var("DEP_PARAKEET_BACKENDS_DIR") { + println!("cargo:rustc-env=PARAKEET_DL_BACKENDS_DIR={dir}"); + } + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/parakeet-cpp/src/model.rs b/parakeet-cpp/src/model.rs index f11855d..4881121 100644 --- a/parakeet-cpp/src/model.rs +++ b/parakeet-cpp/src/model.rs @@ -56,6 +56,26 @@ impl Model { self.streaming } + /// Name of the compute device the (process-global) ggml backend resolved to: + /// `"cpu"` for the CPU backend, or the device name of the selected GPU + /// backend (e.g. `"Metal"`, `"CUDA0"`, `"Vulkan0"`). Under the + /// `dynamic-backends` feature this reflects which loadable backend module was + /// dlopen'd and selected. The backend is created lazily on first model load / + /// transcribe; calling this forces and returns the resolved name. Returns an + /// empty string if the C getter yields NULL. + #[must_use] + pub fn backend_name(&self) -> String { + // SAFETY: `self.ctx` is non-null for the lifetime of `Model` (set in + // `load`, cleared only in `Drop`). The returned pointer is owned by the + // process-global backend and stays valid for the process lifetime — we + // copy it into an owned String and must NOT free it (unlike take_string). + let p = unsafe { sys::parakeet_capi_backend_name(self.ctx) }; + if p.is_null() { + return String::new(); + } + unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned() + } + /// Offline one-shot transcription of 16 kHz mono f32 PCM. pub fn transcribe( &mut self, diff --git a/parakeet-cpp/tests/dl_metal.rs b/parakeet-cpp/tests/dl_metal.rs new file mode 100644 index 0000000..c7842b9 --- /dev/null +++ b/parakeet-cpp/tests/dl_metal.rs @@ -0,0 +1,107 @@ +//! De-risk proof for the dynamic-backends path on macOS: load a real model with +//! the ggml backends provided as dlopen'd MODULES (not statically linked) and +//! assert the active compute device is Metal — i.e. the Metal backend module was +//! discovered + selected at runtime. Gated to macOS + the `dynamic-backends` +//! feature; skips gracefully when no test model is provided. +#![cfg(all(target_os = "macos", feature = "dynamic-backends"))] + +use parakeet_cpp::{Model, TranscribeOptions}; +use std::path::Path; + +/// Decode a 16 kHz mono WAV (int or float) to f32 PCM. Mirrors the helper in +/// `integration.rs` so the DL test can force a transcribe (which forces backend +/// creation). +fn load_wav_16k_mono(path: &str) -> Vec { + let mut r = hound::WavReader::open(path).expect("open wav"); + let spec = r.spec(); + assert_eq!(spec.channels, 1, "fixture must be mono"); + assert_eq!(spec.sample_rate, 16_000, "fixture must be 16 kHz"); + match spec.sample_format { + hound::SampleFormat::Float => r + .samples::() + .map(|s| s.expect("decode wav sample")) + .collect(), + hound::SampleFormat::Int => { + let max = (1i64 << (spec.bits_per_sample - 1)) as f32; + r.samples::() + .map(|s| s.expect("decode wav sample") as f32 / max) + .collect() + } + } +} + +#[test] +fn dl_backend_is_metal() { + // The loadable-backend-modules dir, emitted by parakeet-cpp/build.rs from the + // sys crate's `DEP_PARAKEET_BACKENDS_DIR` metadata. Must be set BEFORE the + // model loads: the patched global_backend() runs ggml_backend_load_all_*() + // once, on first backend creation, and reads PARAKEET_BACKENDS_DIR then. + let Some(backends_dir) = option_env!("PARAKEET_DL_BACKENDS_DIR") else { + eprintln!("skipping: PARAKEET_DL_BACKENDS_DIR not emitted by build.rs"); + return; + }; + // SAFETY: single-threaded test setup, before any backend is created. + unsafe { std::env::set_var("PARAKEET_BACKENDS_DIR", backends_dir) }; + eprintln!("PARAKEET_BACKENDS_DIR = {backends_dir}"); + + let (Ok(model_path), Ok(wav)) = ( + std::env::var("PARAKEET_TEST_MODEL"), + std::env::var("PARAKEET_TEST_WAV"), + ) else { + eprintln!("skipping: set PARAKEET_TEST_MODEL and PARAKEET_TEST_WAV"); + return; + }; + if !Path::new(&model_path).exists() { + eprintln!("skipping: PARAKEET_TEST_MODEL does not exist: {model_path}"); + return; + } + + let mut model = Model::load(Path::new(&model_path)).expect("load model"); + // Force backend creation by running a real transcribe over the fixture. + let pcm = load_wav_16k_mono(&wav); + let t = model + .transcribe(&pcm, 16_000, &TranscribeOptions::default()) + .expect("transcribe"); + eprintln!("transcript: {}", t.text); + + let backend = model.backend_name(); + eprintln!("resolved backend (DL): {backend}"); + let lower = backend.to_ascii_lowercase(); + // ggml's Metal backend registers its device as "MTL" (e.g. "MTL0"); the + // human-facing backend name is "Metal". Accept either spelling so the test is + // robust to the registry naming, while still proving Metal (not CPU) was the + // dlopen'd module that got selected. + assert!( + lower.contains("metal") || lower.contains("mtl"), + "expected the Metal backend under dynamic-backends, got {backend:?}" + ); + assert_ne!( + lower, "cpu", + "Metal module should have been selected over the CPU fallback, got {backend:?}" + ); + + // The de-risk assertions have all passed at this point. Terminate the process + // with the libc `_exit` syscall, which skips atexit handlers AND C++ static + // destructors, to dodge a PRE-EXISTING ggml-Metal teardown abort that those + // destructors trigger at normal process exit: + // ggml-metal-device.m: GGML_ASSERT([rsets->data count] == 0) failed + // (the Metal residency set is not drained before the device is destroyed). + // This is NOT specific to the dynamic-backends path — the static-build + // `transcribe_real_model` integration test aborts at exit the same way once a + // real Metal model is loaded; `std::process::exit` does NOT help because it + // still runs those destructors via libc `exit()`. Without `_exit`, `cargo + // test` would see SIGABRT and report failure even though the test body + // succeeded. This is the only test in this binary, so terminating here skips + // no other test. + println!("dl_backend_is_metal: OK (backend={backend})"); + use std::io::Write as _; + std::io::stdout().flush().ok(); + std::io::stderr().flush().ok(); + extern "C" { + fn _exit(code: i32) -> !; + } + // SAFETY: `_exit` is the POSIX immediate-termination syscall; it never returns + // and touches no Rust state. Called only after all assertions passed and all + // output is flushed. + unsafe { _exit(0) } +}