Skip to content

Commit 8bf06fb

Browse files
committed
feat(s032): exla loaded on demand in the release, the smoke probe after the tree, EXLA scoring in the brute store
Package run 35600216451: on Linux the EXLA NIF does not load in Burrito's musl ERTS (__libc_single_threaded: symbol not found) and exla's application start took the release down; the release now loads exla without starting it and Bumblebee.exla/0 starts it on demand, remembering a failure as the tier's reason. On macOS the NIF loaded (EXLA=ok). The vector check ran before the Repo on every OS; it is now a probe child before the endpoint. Brute.search scores 10,000 rows as one EXLA product (72 ms p50, was 531 ms in Elixir); the Elixir path stays for machines without the backend, held to the same numbers by a test. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 4f47964 commit 8bf06fb

14 files changed

Lines changed: 167 additions & 14 deletions

File tree

‎lib/trinity/application.ex‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ defmodule Trinity.Application do
5454
Trinity.Tools.Supervisor,
5555
# Slice 021: approval requests and their decisions, with pending rows reloaded.
5656
Trinity.Permissions.Gate,
57-
Trinity.Sessions.Supervisor,
57+
Trinity.Sessions.Supervisor
58+
] ++
59+
Trinity.Smoke.probe(Trinity.Smoke.argv()) ++
60+
[
5861
# Start to serve requests, typically the last entry
5962
TrinityWeb.Endpoint
6063
] ++ Trinity.Smoke.children(Trinity.Smoke.argv())

‎lib/trinity/memory/embedders/bumblebee.ex‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,41 @@ defmodule Trinity.Memory.Embedders.Bumblebee do
4848
cond do
4949
match?({:win32, _}, :os.type()) -> {:off, :no_local_backend}
5050
not Code.ensure_loaded?(EXLA) -> {:off, {:exla, :not_compiled}}
51+
match?({:error, _}, exla()) -> {:off, exla()}
5152
not model_present?() -> {:off, :model_missing}
5253
true -> :ok
5354
end
5455
end
5556

57+
@doc """
58+
Starts the exla application if it is loaded and not running, once: the release loads it
59+
without starting it (mix.exs), because its start loads the NIF and that load fails in
60+
Burrito's Linux ERTS. `:ok`, or `{:error, {:exla, reason}}` remembered for the run (a NIF
61+
that did not load will not load on the next call either).
62+
"""
63+
@spec exla() :: :ok | {:error, {:exla, term()}}
64+
def exla do
65+
case :persistent_term.get({__MODULE__, :exla}, nil) do
66+
nil ->
67+
result =
68+
case Application.ensure_all_started(:exla) do
69+
{:ok, _} -> :ok
70+
{:error, reason} -> {:error, {:exla, summarise(reason)}}
71+
end
72+
73+
:persistent_term.put({__MODULE__, :exla}, result)
74+
result
75+
76+
known ->
77+
known
78+
end
79+
end
80+
81+
# The loader's message, not the whole start_link failure tree.
82+
defp summarise({:exla, {reason, _}}), do: summarise(reason)
83+
defp summarise(reason) when is_binary(reason), do: String.slice(reason, 0, 200)
84+
defp summarise(reason), do: reason |> inspect() |> String.slice(0, 200)
85+
5686
@doc "True when the weights and tokenizer are in the cache (checked offline, no request made)."
5787
@spec model_present?() :: boolean()
5888
def model_present? do

‎lib/trinity/memory/vector_stores/brute.ex‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ defmodule Trinity.Memory.VectorStores.Brute do
44
@moduledoc """
55
The SQLite vector store (slice 032): vectors as float32 bytes on `memories.embedding`,
66
a search loads the filter's rows (a persona's semantic tier, the named scopes, the
7-
embedder in force's model) and scores them in Elixir by cosine. Measured at G3 for 10,000
8-
rows; docs/02 puts brute force at fine to about 10^5, and hnswlib is the step after.
7+
embedder in force's model) and scores them by cosine: as one matrix product on EXLA when
8+
the NIF is loaded (the local embedder's own backend), in Elixir over decoded lists when
9+
it is not (the hosted embedder on a machine without EXLA). Measured at G3 over 10,000
10+
rows (docs/perf.md): loading the rows 93 ms, the Elixir cosine 571 ms, the EXLA product
11+
6 ms after its first compile for that row count (102 ms). docs/02's "fine to about 10^5"
12+
holds for the product, not for the Elixir path; hnswlib is the step after.
913
"""
1014
@behaviour Trinity.Memory.VectorStore
1115

@@ -37,13 +41,55 @@ defmodule Trinity.Memory.VectorStores.Brute do
3741
e.embedding_model == ^model and not is_nil(e.embedding)
3842
)
3943
|> Repo.all()
40-
|> Enum.map(fn e ->
41-
%{id: e.id, score: Embedder.cosine(query, Embedder.from_binary(e.embedding)), entry: e}
42-
end)
44+
|> score(query)
4345
|> Enum.sort_by(& &1.score, :desc)
4446
|> Enum.take(k)
4547
end
4648

49+
@doc "Cosine of the query against every row's vector, as hits; the EXLA product when the NIF is loaded, Elixir otherwise."
50+
@spec score([Entry.t()], Embedder.vector()) :: [Trinity.Memory.VectorStore.hit()]
51+
def score([], _query), do: []
52+
53+
def score(rows, query) do
54+
scores = if exla?(), do: exla_scores(rows, query), else: elixir_scores(rows, query)
55+
Enum.zip_with(rows, scores, fn e, s -> %{id: e.id, score: s, entry: e} end)
56+
end
57+
58+
@doc "Cosines in Elixir over decoded lists (the path without EXLA); public so the suite can hold the two paths to the same numbers."
59+
@spec elixir_scores([Entry.t()], Embedder.vector()) :: [float()]
60+
def elixir_scores(rows, query),
61+
do: Enum.map(rows, &Embedder.cosine(query, Embedder.from_binary(&1.embedding)))
62+
63+
@doc "Cosines as one (rows x dim) . dim product on the EXLA host client; a row of another width scores 0.0, as `cosine/2` refuses it."
64+
@spec exla_scores([Entry.t()], Embedder.vector()) :: [float()]
65+
def exla_scores(rows, query) do
66+
dim = length(query)
67+
fit = Enum.filter(rows, &(byte_size(&1.embedding) == dim * 4))
68+
scores = if fit == [], do: %{}, else: product(fit, query, dim)
69+
Enum.map(rows, &Map.get(scores, &1.id, 0.0))
70+
end
71+
72+
defp product(fit, query, dim) do
73+
m =
74+
fit
75+
|> Enum.map(& &1.embedding)
76+
|> IO.iodata_to_binary()
77+
|> Nx.from_binary(:f32, backend: EXLA.Backend)
78+
|> Nx.reshape({length(fit), dim})
79+
80+
q = Nx.tensor(query, type: :f32, backend: EXLA.Backend)
81+
norms = Nx.multiply(Nx.LinAlg.norm(m, axes: [1]), Nx.LinAlg.norm(q))
82+
83+
m
84+
|> Nx.dot(q)
85+
|> Nx.divide(Nx.select(Nx.equal(norms, 0.0), 1.0, norms))
86+
|> Nx.to_flat_list()
87+
|> then(&Map.new(Enum.zip(Enum.map(fit, fn e -> e.id end), &1)))
88+
end
89+
90+
defp exla?,
91+
do: Code.ensure_loaded?(EXLA.Backend) and Trinity.Memory.Embedders.Bumblebee.exla() == :ok
92+
4793
@impl true
4894
def delete(id) do
4995
Repo.update_all(from(e in Entry, where: e.id == ^id),

‎lib/trinity/smoke.ex‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ defmodule Trinity.Smoke do
8282
"""
8383
@spec exla_line() :: String.t()
8484
def exla_line do
85-
if Code.ensure_loaded?(EXLA) do
85+
if Code.ensure_loaded?(EXLA) and Trinity.Memory.Embedders.Bumblebee.exla() == :ok do
8686
try do
8787
t = Nx.tensor([1.0, 2.0], backend: EXLA.Backend)
8888
[3.0] = Nx.to_flat_list(Nx.sum(t))
@@ -94,7 +94,7 @@ defmodule Trinity.Smoke do
9494
"TRINITY_SMOKE_EXLA=failed:#{inspect({kind, reason}) |> String.slice(0, 200)}"
9595
end
9696
else
97-
"TRINITY_SMOKE_EXLA=failed:not_compiled"
97+
"TRINITY_SMOKE_EXLA=failed:#{inspect(Trinity.Memory.Embedders.Bumblebee.exla()) |> String.slice(0, 200)}"
9898
end
9999
end
100100

@@ -168,13 +168,46 @@ defmodule Trinity.Smoke do
168168
def children(args) do
169169
if requested?(args) do
170170
markdown = markdown_line()
171-
# The 032 lines need the Repo and the memory supervisor, which have started by now;
172-
# they are cheap (one tiny tensor, three rows rolled back) and computed here for the
173-
# same race the markdown line lost once.
174-
rest = [exla_line(), vec_line(), semantic_line()]
175-
[{Task, fn -> run(&IO.puts/1, &System.halt/1, markdown, rest) end}]
171+
# The 032 lines were computed by `probe/1`, a child placed after the Repo, the memory
172+
# supervisor and the sessions (package run 35600216451: computed here, before the
173+
# tree, the vector check found no Repo); the Task only reads them.
174+
[{Task, fn -> run(&IO.puts/1, &System.halt/1, markdown, probed()) end}]
176175
else
177176
[]
178177
end
179178
end
179+
180+
@doc """
181+
The child that computes the 032 lines (slice 032): placed in `Trinity.Application` just
182+
before the endpoint, so the Repo, the memory supervisor and the sessions are up. Its
183+
`start_link/1` does the work synchronously and answers `:ignore`, so the supervisor waits
184+
for it and starts no process; `children/1`'s Task reads the result.
185+
"""
186+
@spec probe([String.t()]) :: [Supervisor.child_spec()]
187+
def probe(args) do
188+
if requested?(args), do: [Trinity.Smoke.Probe], else: []
189+
end
190+
191+
@doc false
192+
@spec probed() :: [String.t()]
193+
def probed do
194+
:persistent_term.get({__MODULE__, :probed}, [
195+
"TRINITY_SMOKE_EXLA=failed:not_probed",
196+
"TRINITY_SMOKE_VEC=failed:not_probed",
197+
"TRINITY_SMOKE_SEMANTIC=off:not_probed"
198+
])
199+
end
200+
201+
defmodule Probe do
202+
@moduledoc false
203+
use Boundary, top_level?: true, deps: [Trinity, Trinity.Smoke]
204+
205+
def child_spec(_), do: %{id: __MODULE__, start: {__MODULE__, :start_link, []}}
206+
207+
def start_link do
208+
lines = [Trinity.Smoke.exla_line(), Trinity.Smoke.vec_line(), Trinity.Smoke.semantic_line()]
209+
:persistent_term.put({Trinity.Smoke, :probed}, lines)
210+
:ignore
211+
end
212+
end
180213
end

‎lib/trinity_web/live/memory_live.ex‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ defmodule TrinityWeb.MemoryLive do
319319
@status != :on && "bg-warning/10"
320320
]}
321321
>
322-
{Semantic.describe(@status)}
322+
{Semantic.describe(@status)}.
323323
<span :if={@status != :on}>{gettext("Full-text search still works.")}</span>
324324
</p>
325325
<div

‎mix.exs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ defmodule Trinity.MixProject do
4747
[
4848
desktop: [
4949
steps: [:assemble, &Burrito.wrap/1],
50+
# Slice 032: exla is loaded, not started, in the release. Its application start
51+
# loads the NIF, and in Burrito's musl ERTS on Linux that load fails
52+
# (`__libc_single_threaded: symbol not found`, package run 35600216451), which took
53+
# the whole release down at boot. `Trinity.Memory.Embedders.Bumblebee.exla/0`
54+
# starts it on demand and a failure is the tier off with that reason, not a crash.
55+
# On a Windows host exla is not declared at all (`exla_deps/0`).
56+
applications: exla_release_applications(),
5057
burrito: [
5158
targets: [
5259
linux_x86_64: [os: :linux, cpu: :x86_64],
@@ -96,6 +103,13 @@ defmodule Trinity.MixProject do
96103
# `{:off, :no_local_backend}`, and the semantic tier is off until a Windows backend exists
97104
# (NOTES decision 3). The lock file carries exla either way; `mix deps.get` leaves an
98105
# undeclared lock entry alone.
106+
defp exla_release_applications do
107+
case :os.type() do
108+
{:win32, _} -> []
109+
_ -> [exla: :load]
110+
end
111+
end
112+
99113
defp exla_deps do
100114
case :os.type() do
101115
{:win32, _} -> []
35 KB
Loading
114 KB
Loading
119 KB
Loading
73.9 KB
Loading

0 commit comments

Comments
 (0)