From 593f1646a10549633c4b286aa533e270afdc0213 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 18 Jul 2026 10:20:54 +0200 Subject: [PATCH] fix: made the macos app smaller --- lib/bds/embeddings/backends/neural.ex | 59 ++++++++++++++++---- lib/bds/mac_bundle.ex | 23 ++++---- lib/bds/release_packaging.ex | 17 +++++- mix.exs | 18 +++++- specs/embedding.allium | 7 ++- test/bds/embeddings/backends/neural_test.exs | 30 +++++++--- test/bds/mac_bundle_test.exs | 25 +++++++++ test/bds/release_packaging_test.exs | 19 +++++++ 8 files changed, 166 insertions(+), 32 deletions(-) diff --git a/lib/bds/embeddings/backends/neural.ex b/lib/bds/embeddings/backends/neural.ex index e57e155..310e6b8 100644 --- a/lib/bds/embeddings/backends/neural.ex +++ b/lib/bds/embeddings/backends/neural.ex @@ -32,6 +32,11 @@ defmodule BDS.Embeddings.Backends.Neural do * Everywhere else — and as a fallback when EMLX is unavailable or explicitly disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`). + Packaged releases contain only the backend that can run on their platform + (`runtime:` conditional deps in mix.exs): macOS releases ship EMLX and no + EXLA, Linux/Windows ship EXLA and no EMLX (MLX is Apple-only). The selected + backend is therefore started on demand here, never assumed at boot. + The accelerator can be pinned with `config :bds, :embeddings, accelerator:` to `:auto` (default), `:emlx`, or `:exla`. """ @@ -171,7 +176,20 @@ defmodule BDS.Embeddings.Backends.Neural do @doc false @spec current_accelerator() :: :emlx | :exla def current_accelerator do - select_accelerator(configured_accelerator(), emlx_available?(), apple_silicon?()) + case select_accelerator( + configured_accelerator(), + emlx_available?(), + exla_available?(), + apple_silicon?() + ) do + :none -> + raise RuntimeError, + "no embeddings accelerator available: neither the EMLX (Apple GPU) nor the " <> + "EXLA (CPU) application could be started on this machine" + + accelerator -> + accelerator + end end @doc """ @@ -179,22 +197,43 @@ defmodule BDS.Embeddings.Backends.Neural do Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but - degrade a forced `:emlx` to EXLA when EMLX is not loaded so a misconfigured - host still gets working CPU inference instead of crashing. + degrade to the other backend when the requested one is not available so a + misconfigured host still gets working inference instead of crashing. Only + one backend ships per platform (macOS releases exclude EXLA, Linux/Windows + exclude EMLX), so the unavailable side is the norm in production, not an + edge case. """ - @spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean()) :: :emlx | :exla - def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla - def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx - def select_accelerator(:emlx, false, _apple_silicon?), do: :exla - def select_accelerator(:auto, true, true), do: :emlx - def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla + @spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean(), boolean()) :: + :emlx | :exla | :none + def select_accelerator(_configured, false, false, _apple_silicon?), do: :none + def select_accelerator(:exla, _emlx?, true, _apple_silicon?), do: :exla + def select_accelerator(:exla, true, false, _apple_silicon?), do: :emlx + def select_accelerator(:emlx, true, _exla?, _apple_silicon?), do: :emlx + def select_accelerator(:emlx, false, true, _apple_silicon?), do: :exla + def select_accelerator(:auto, true, _exla?, true), do: :emlx + def select_accelerator(:auto, _emlx?, true, _apple_silicon?), do: :exla + def select_accelerator(:auto, true, false, _apple_silicon?), do: :emlx defp configured_accelerator do config() |> Keyword.get(:accelerator, @default_accelerator) end + # The backend apps are runtime: false deps on the platforms that can't run + # them, so releases exclude one of them entirely and neither is auto-started + # in dev/test. Availability therefore means "the app actually starts here": + # ensure_all_started errors when the app is absent (or its NIF can't load). + # Starting the app is also exactly what the chosen backend needs before + # building the serving. defp emlx_available? do - Code.ensure_loaded?(EMLX) and Code.ensure_loaded?(EMLX.Backend) + started?(:emlx) and Code.ensure_loaded?(EMLX.Backend) + end + + defp exla_available? do + started?(:exla) + end + + defp started?(app) do + match?({:ok, _apps}, Application.ensure_all_started(app)) end defp apple_silicon? do diff --git a/lib/bds/mac_bundle.ex b/lib/bds/mac_bundle.ex index 7459d05..20da407 100644 --- a/lib/bds/mac_bundle.ex +++ b/lib/bds/mac_bundle.ex @@ -70,17 +70,20 @@ defmodule BDS.MacBundle do frameworks = Path.join(contents, "Frameworks") rel = Path.join(resources, "rel") - File.rm_rf!(app) - Enum.each([macos, resources, frameworks], &File.mkdir_p!/1) + # Validate before rm_rf! so a refused build leaves any previous bundle intact. + with :ok <- BDS.ReleasePackaging.validate_release_source(release_dir) do + File.rm_rf!(app) + Enum.each([macos, resources, frameworks], &File.mkdir_p!/1) - with {:ok, _} <- File.cp_r(release_dir, rel), - :ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")), - :ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)), - :ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"), - :ok <- write_launcher(Path.join(macos, @executable)), - :ok <- maybe_relocate_dylibs(rel, frameworks, opts), - :ok <- maybe_codesign(app, opts) do - {:ok, app} + with {:ok, _} <- File.cp_r(release_dir, rel), + :ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")), + :ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)), + :ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"), + :ok <- write_launcher(Path.join(macos, @executable)), + :ok <- maybe_relocate_dylibs(rel, frameworks, opts), + :ok <- maybe_codesign(app, opts) do + {:ok, app} + end end end diff --git a/lib/bds/release_packaging.ex b/lib/bds/release_packaging.ex index b222085..2ce06c2 100644 --- a/lib/bds/release_packaging.ex +++ b/lib/bds/release_packaging.ex @@ -56,7 +56,8 @@ defmodule BDS.ReleasePackaging do app_release_source = Keyword.fetch!(opts, :app_release_source) mcp_release_source = Keyword.fetch!(opts, :mcp_release_source) - with :ok <- reset_output(metadata), + with :ok <- validate_release_source(app_release_source), + :ok <- reset_output(metadata), :ok <- copy_release(app_release_source, metadata.app_root), :ok <- copy_release(mcp_release_source, metadata.resources_root), :ok <- write_manifest(metadata), @@ -65,6 +66,20 @@ defmodule BDS.ReleasePackaging do end end + @doc """ + Rejects half-assembled releases. A failed `mix release` run can leave `lib/` + copied but no `releases//` (no `env.sh`, no boot scripts) — packaging + that produces an app that dies on launch. Refuse it up front instead. + """ + def validate_release_source(release_dir) do + if release_dir |> Path.join("releases/*/env.sh") |> Path.wildcard() == [] do + {:error, + {:incomplete_release, "#{release_dir} has no releases/*/env.sh — did `mix release` fail?"}} + else + :ok + end + end + defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform defp normalize_platform(:darwin), do: :macos diff --git a/mix.exs b/mix.exs index 6b453d9..d9cacde 100644 --- a/mix.exs +++ b/mix.exs @@ -62,11 +62,21 @@ defmodule BDS.MixProject do {:ex_ratatui, "~> 0.11"}, {:image, "~> 0.67"}, {:nx, "~> 0.10"}, - {:exla, "~> 0.10"}, + # Only one ML inference backend ships per platform; NIF releases are + # host-built, so the build OS is the target OS. EXLA (283 MB XLA CPU + # payload) never runs in the macOS app — EMLX always wins on Apple + # Silicon — but `runtime: false` alone can't exclude it there because + # the `image` dep declares exla as an optional application, which drags + # it into the release whenever it exists in the prod dep tree. Scoping + # it to dev/test on macOS keeps it fully out of macOS releases while + # Linux/Windows ship it as their production inference backend. + {:exla, "~> 0.10", if(macos?(), do: [only: [:dev, :test]], else: [])}, # Apple Silicon GPU (Metal) acceleration for embedding inference. Ships # precompiled MLX binaries; the Neural backend prefers it on arm64 macOS - # and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c). - {:emlx, "~> 0.2.0"}, + # and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c). Apple-only, so + # non-macOS releases exclude it (runtime: false deps stay out of + # releases — nothing pulls emlx in optionally, unlike exla above). + {:emlx, "~> 0.2.0", runtime: macos?()}, {:bumblebee, "~> 0.6.3"}, {:hnswlib, "~> 0.1.7"}, {:stemex, "~> 0.2.1"}, @@ -121,4 +131,6 @@ defmodule BDS.MixProject do ] ] end + + defp macos?, do: :os.type() == {:unix, :darwin} end diff --git a/specs/embedding.allium b/specs/embedding.allium index 44eabc5..66f007d 100644 --- a/specs/embedding.allium +++ b/specs/embedding.allium @@ -241,7 +241,12 @@ invariant NativeAcceleratedExecution { -- On Apple Silicon the model runs on the Apple GPU via EMLX (MLX/Metal, -- params placed on the EMLX.Backend GPU device); everywhere else, and as a -- fallback when EMLX is unavailable, it runs on optimised native CPU via - -- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto). + -- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto); + -- a request for an unavailable backend degrades to the other one. + -- Packaged releases ship only the backend their platform can run (runtime: + -- conditional deps): macOS releases exclude EXLA, Linux/Windows releases + -- exclude the Apple-only EMLX. The selected backend is started on demand + -- by the Neural backend, never assumed started at boot. -- Neighbour search is HNSW (hnswlib). } diff --git a/test/bds/embeddings/backends/neural_test.exs b/test/bds/embeddings/backends/neural_test.exs index df7546f..207106c 100644 --- a/test/bds/embeddings/backends/neural_test.exs +++ b/test/bds/embeddings/backends/neural_test.exs @@ -38,29 +38,45 @@ defmodule BDS.Embeddings.Backends.NeuralTest do end describe "accelerator selection (NativeAcceleratedExecution)" do + # select_accelerator(configured, emlx_available?, exla_available?, apple_silicon?) test "auto prefers Apple GPU (EMLX) when available on Apple Silicon" do - assert Neural.select_accelerator(:auto, true, true) == :emlx + assert Neural.select_accelerator(:auto, true, true, true) == :emlx end test "auto falls back to EXLA-CPU off Apple Silicon" do - assert Neural.select_accelerator(:auto, true, false) == :exla + assert Neural.select_accelerator(:auto, true, true, false) == :exla end test "auto falls back to EXLA-CPU when EMLX is unavailable" do - assert Neural.select_accelerator(:auto, false, true) == :exla + assert Neural.select_accelerator(:auto, false, true, true) == :exla + end + + test "auto uses EMLX when EXLA is not shipped (pruned macOS bundle)" do + assert Neural.select_accelerator(:auto, true, false, true) == :emlx + assert Neural.select_accelerator(:auto, true, false, false) == :emlx end test "explicit :exla is honoured even on Apple Silicon with EMLX present" do - assert Neural.select_accelerator(:exla, true, true) == :exla + assert Neural.select_accelerator(:exla, true, true, true) == :exla + end + + test "explicit :exla degrades to EMLX when EXLA is unavailable" do + assert Neural.select_accelerator(:exla, true, false, true) == :emlx end test "explicit :emlx is honoured when available" do - assert Neural.select_accelerator(:emlx, true, true) == :emlx - assert Neural.select_accelerator(:emlx, true, false) == :emlx + assert Neural.select_accelerator(:emlx, true, true, true) == :emlx + assert Neural.select_accelerator(:emlx, true, true, false) == :emlx end test "explicit :emlx degrades to EXLA when EMLX is unavailable" do - assert Neural.select_accelerator(:emlx, false, true) == :exla + assert Neural.select_accelerator(:emlx, false, true, true) == :exla + end + + test "no accelerator available selects :none" do + assert Neural.select_accelerator(:auto, false, false, true) == :none + assert Neural.select_accelerator(:exla, false, false, false) == :none + assert Neural.select_accelerator(:emlx, false, false, false) == :none end test "defn options map each accelerator to its native compiler" do diff --git a/test/bds/mac_bundle_test.exs b/test/bds/mac_bundle_test.exs index cd0ae60..804ec0f 100644 --- a/test/bds/mac_bundle_test.exs +++ b/test/bds/mac_bundle_test.exs @@ -436,6 +436,8 @@ defmodule BDS.MacBundleTest do File.write!(Path.join(release, "bin/bds"), "#!/bin/sh\necho fake release\n") File.mkdir_p!(Path.join(release, "lib/wx-2.4/priv")) File.write!(Path.join(release, "lib/wx-2.4/priv/wxe_driver.so"), "fake-nif") + File.mkdir_p!(Path.join(release, "releases/0.1.0")) + File.write!(Path.join(release, "releases/0.1.0/env.sh"), "# env") icns = Path.join(tmp, "AppIcon.icns") File.write!(icns, "fake-icns") @@ -489,5 +491,28 @@ defmodule BDS.MacBundleTest do assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds")) assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns")) end + + test "refuses a half-assembled release and keeps the previous bundle", %{app: app} do + # A failed `mix release` leaves lib/ but no releases//env.sh. + tmp = Path.join(System.tmp_dir!(), "bds-macbundle-broken-#{System.unique_integer([:positive])}") + broken = Path.join(tmp, "rel") + File.mkdir_p!(Path.join(broken, "bin")) + File.mkdir_p!(Path.join(broken, "lib/wx-2.4/priv")) + on_exit(fn -> File.rm_rf!(tmp) end) + + assert {:error, {:incomplete_release, message}} = + MacBundle.assemble( + release_dir: broken, + output_dir: Path.dirname(app), + icns_path: Path.join(tmp, "missing.icns"), + version: "0.1.0", + skip_dylibs: true, + skip_codesign: true + ) + + assert message =~ "mix release" + # the refused build must not have wiped the existing bundle + assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds")) + end end end diff --git a/test/bds/release_packaging_test.exs b/test/bds/release_packaging_test.exs index c109d9c..8fe0558 100644 --- a/test/bds/release_packaging_test.exs +++ b/test/bds/release_packaging_test.exs @@ -15,6 +15,8 @@ defmodule BDS.ReleasePackagingTest do File.mkdir_p!(Path.join(mcp_release, "mcp/bin")) File.write!(Path.join(app_release, "bin/bds"), "app-release") File.write!(Path.join(mcp_release, "mcp/bin/bds-mcp"), "mcp-release") + File.mkdir_p!(Path.join(app_release, "releases/0.1.0")) + File.write!(Path.join(app_release, "releases/0.1.0/env.sh"), "# env") on_exit(fn -> File.rm_rf(base_dir) end) @@ -66,6 +68,23 @@ defmodule BDS.ReleasePackagingTest do } end + test "package refuses a half-assembled release (no releases/*/env.sh)", context do + # A failed `mix release` can leave lib/ copied but no releases//; + # packaging that produces an app that dies on launch. + File.rm_rf!(Path.join(context.app_release, "releases")) + + assert {:error, {:incomplete_release, message}} = + ReleasePackaging.package( + platform: :macos, + version: "0.1.0", + output_dir: context.output_dir, + app_release_source: context.app_release, + mcp_release_source: context.mcp_release + ) + + assert message =~ "mix release" + end + test "package creates a platform archive for redistribution", context do assert {:ok, metadata} = ReleasePackaging.package(