fix: made the macos app smaller
This commit is contained in:
@@ -32,6 +32,11 @@ defmodule BDS.Embeddings.Backends.Neural do
|
|||||||
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
|
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
|
||||||
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
|
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:`
|
The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
|
||||||
to `:auto` (default), `:emlx`, or `:exla`.
|
to `:auto` (default), `:emlx`, or `:exla`.
|
||||||
"""
|
"""
|
||||||
@@ -171,7 +176,20 @@ defmodule BDS.Embeddings.Backends.Neural do
|
|||||||
@doc false
|
@doc false
|
||||||
@spec current_accelerator() :: :emlx | :exla
|
@spec current_accelerator() :: :emlx | :exla
|
||||||
def current_accelerator do
|
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
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
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
|
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
|
degrade to the other backend when the requested one is not available so a
|
||||||
host still gets working CPU inference instead of crashing.
|
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
|
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean(), boolean()) ::
|
||||||
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla
|
:emlx | :exla | :none
|
||||||
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx
|
def select_accelerator(_configured, false, false, _apple_silicon?), do: :none
|
||||||
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla
|
def select_accelerator(:exla, _emlx?, true, _apple_silicon?), do: :exla
|
||||||
def select_accelerator(:auto, true, true), do: :emlx
|
def select_accelerator(:exla, true, false, _apple_silicon?), do: :emlx
|
||||||
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla
|
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
|
defp configured_accelerator do
|
||||||
config() |> Keyword.get(:accelerator, @default_accelerator)
|
config() |> Keyword.get(:accelerator, @default_accelerator)
|
||||||
end
|
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
|
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
|
end
|
||||||
|
|
||||||
defp apple_silicon? do
|
defp apple_silicon? do
|
||||||
|
|||||||
@@ -70,17 +70,20 @@ defmodule BDS.MacBundle do
|
|||||||
frameworks = Path.join(contents, "Frameworks")
|
frameworks = Path.join(contents, "Frameworks")
|
||||||
rel = Path.join(resources, "rel")
|
rel = Path.join(resources, "rel")
|
||||||
|
|
||||||
File.rm_rf!(app)
|
# Validate before rm_rf! so a refused build leaves any previous bundle intact.
|
||||||
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1)
|
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),
|
with {:ok, _} <- File.cp_r(release_dir, rel),
|
||||||
:ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")),
|
: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, "Info.plist"), info_plist(version: version)),
|
||||||
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
|
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
|
||||||
:ok <- write_launcher(Path.join(macos, @executable)),
|
:ok <- write_launcher(Path.join(macos, @executable)),
|
||||||
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
|
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
|
||||||
:ok <- maybe_codesign(app, opts) do
|
:ok <- maybe_codesign(app, opts) do
|
||||||
{:ok, app}
|
{:ok, app}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ defmodule BDS.ReleasePackaging do
|
|||||||
app_release_source = Keyword.fetch!(opts, :app_release_source)
|
app_release_source = Keyword.fetch!(opts, :app_release_source)
|
||||||
mcp_release_source = Keyword.fetch!(opts, :mcp_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(app_release_source, metadata.app_root),
|
||||||
:ok <- copy_release(mcp_release_source, metadata.resources_root),
|
:ok <- copy_release(mcp_release_source, metadata.resources_root),
|
||||||
:ok <- write_manifest(metadata),
|
:ok <- write_manifest(metadata),
|
||||||
@@ -65,6 +66,20 @@ defmodule BDS.ReleasePackaging do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Rejects half-assembled releases. A failed `mix release` run can leave `lib/`
|
||||||
|
copied but no `releases/<version>/` (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(platform) when platform in [:macos, :linux, :windows], do: platform
|
||||||
defp normalize_platform(:darwin), do: :macos
|
defp normalize_platform(:darwin), do: :macos
|
||||||
|
|
||||||
|
|||||||
18
mix.exs
18
mix.exs
@@ -62,11 +62,21 @@ defmodule BDS.MixProject do
|
|||||||
{:ex_ratatui, "~> 0.11"},
|
{:ex_ratatui, "~> 0.11"},
|
||||||
{:image, "~> 0.67"},
|
{:image, "~> 0.67"},
|
||||||
{:nx, "~> 0.10"},
|
{: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
|
# Apple Silicon GPU (Metal) acceleration for embedding inference. Ships
|
||||||
# precompiled MLX binaries; the Neural backend prefers it on arm64 macOS
|
# precompiled MLX binaries; the Neural backend prefers it on arm64 macOS
|
||||||
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c).
|
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c). Apple-only, so
|
||||||
{:emlx, "~> 0.2.0"},
|
# 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"},
|
{:bumblebee, "~> 0.6.3"},
|
||||||
{:hnswlib, "~> 0.1.7"},
|
{:hnswlib, "~> 0.1.7"},
|
||||||
{:stemex, "~> 0.2.1"},
|
{:stemex, "~> 0.2.1"},
|
||||||
@@ -121,4 +131,6 @@ defmodule BDS.MixProject do
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp macos?, do: :os.type() == {:unix, :darwin}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -241,7 +241,12 @@ invariant NativeAcceleratedExecution {
|
|||||||
-- On Apple Silicon the model runs on the Apple GPU via EMLX (MLX/Metal,
|
-- 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
|
-- 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
|
-- 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).
|
-- Neighbour search is HNSW (hnswlib).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,29 +38,45 @@ defmodule BDS.Embeddings.Backends.NeuralTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "accelerator selection (NativeAcceleratedExecution)" do
|
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
|
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
|
end
|
||||||
|
|
||||||
test "auto falls back to EXLA-CPU off Apple Silicon" do
|
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
|
end
|
||||||
|
|
||||||
test "auto falls back to EXLA-CPU when EMLX is unavailable" do
|
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
|
end
|
||||||
|
|
||||||
test "explicit :exla is honoured even on Apple Silicon with EMLX present" do
|
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
|
end
|
||||||
|
|
||||||
test "explicit :emlx is honoured when available" do
|
test "explicit :emlx is honoured when available" do
|
||||||
assert Neural.select_accelerator(:emlx, true, true) == :emlx
|
assert Neural.select_accelerator(:emlx, true, true, true) == :emlx
|
||||||
assert Neural.select_accelerator(:emlx, true, false) == :emlx
|
assert Neural.select_accelerator(:emlx, true, true, false) == :emlx
|
||||||
end
|
end
|
||||||
|
|
||||||
test "explicit :emlx degrades to EXLA when EMLX is unavailable" do
|
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
|
end
|
||||||
|
|
||||||
test "defn options map each accelerator to its native compiler" do
|
test "defn options map each accelerator to its native compiler" do
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ defmodule BDS.MacBundleTest do
|
|||||||
File.write!(Path.join(release, "bin/bds"), "#!/bin/sh\necho fake release\n")
|
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.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.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")
|
icns = Path.join(tmp, "AppIcon.icns")
|
||||||
File.write!(icns, "fake-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/rel/bin/bds"))
|
||||||
assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns"))
|
assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "refuses a half-assembled release and keeps the previous bundle", %{app: app} do
|
||||||
|
# A failed `mix release` leaves lib/ but no releases/<version>/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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ defmodule BDS.ReleasePackagingTest do
|
|||||||
File.mkdir_p!(Path.join(mcp_release, "mcp/bin"))
|
File.mkdir_p!(Path.join(mcp_release, "mcp/bin"))
|
||||||
File.write!(Path.join(app_release, "bin/bds"), "app-release")
|
File.write!(Path.join(app_release, "bin/bds"), "app-release")
|
||||||
File.write!(Path.join(mcp_release, "mcp/bin/bds-mcp"), "mcp-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)
|
on_exit(fn -> File.rm_rf(base_dir) end)
|
||||||
|
|
||||||
@@ -66,6 +68,23 @@ defmodule BDS.ReleasePackagingTest do
|
|||||||
}
|
}
|
||||||
end
|
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/<version>/;
|
||||||
|
# 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
|
test "package creates a platform archive for redistribution", context do
|
||||||
assert {:ok, metadata} =
|
assert {:ok, metadata} =
|
||||||
ReleasePackaging.package(
|
ReleasePackaging.package(
|
||||||
|
|||||||
Reference in New Issue
Block a user