Status
Accepted
Context
The library could load and serve one model at a time: Model.load/2 returns a %Model{} handle, and LlamaCppEx.Server (ADR 006) wraps a single model + context for batched inference. Running two or more models simultaneously — e.g. a chat model and an embedding model, or hot-swapping models on a fixed-memory box — required the caller to track handles, pids, and memory by hand.
We wanted a layer that keeps several models resident, routes requests to them by id, and exposes a clean load/unload abstraction, while reusing what already exists: the Hub HuggingFace downloader/cache and the batching Server.
Key constraints from the existing design:
- A
%Model{}is a refcounted, read-only, thread-shareable NIF resource. Inference NIFs run on dirty schedulers and do not block the BEAM. Concurrency does not require one process per model — many callers can hit one model at once. - Model cleanup is GC-only: the RAII destructor (ADR 002) runs when the last reference is dropped. There is no explicit free NIF.
Serverloads its own model + context internally and has no embedding path (Embedding.embed/3builds its ownembeddings: truecontext).- The library has no Application/supervision tree, and shouldn't impose one on dependents that don't use this feature.
Decision
Add an opt-in LlamaCppEx.ModelManager (a singleton GenServer) plus a LlamaCppEx.ModelSupervisor that starts a Registry, a DynamicSupervisor, and the manager.
ETS ownership — writes serialize, reads bypass
The manager owns a named, :protected, read_concurrency: true ETS table of model entries. Load/unload writes serialize through the GenServer; inference-time lookups (generate/stream/chat/embed) read the ETS table directly from the caller process. This keeps the manager off the inference hot path — a generation request never enters the manager's mailbox, so the singleton is not a throughput bottleneck.
The slow part of a load (Hub download + native model load) runs in a supervised Task, not on the manager process, so a long load does not head-of-line-block other lifecycle calls (unload, set_default, concurrent loads). Only the budget reservation and the ETS commit happen on the GenServer; a load in flight is recorded with status: :loading so a duplicate load of the same id is refused while it runs.
last_used (for diagnostics/LRU) is kept in a separate :public table so callers can bump it with :ets.insert/2 without writing to the protected entries table.
Two backing modes
:server(default for generation/chat) — backs the model with a supervisedLlamaCppEx.Serverunder theDynamicSupervisor, named in theRegistryby id. Gets continuous batching, streaming, prefix caching, and telemetry for free.:direct— the manager holds the%Model{}and callers run statelessLlamaCppEx.generate/3/Embedding.embed/2. No standing KV-cache memory. Auto-selected when:embedis in:capabilities, because the server has no embedding path; this is a structural constraint, not a preference.
Routing
By explicit id, or :default (set at load with default: true or via set_default/1). Capability gating is enforced for embeddings (embed/3 requires an :embed-capable, :direct model).
Memory budget — placement-aware, refuse, don't evict
Loads are checked against an advisory, placement-aware budget. Footprint is estimated from GGUF file size (plus a coarse KV-cache estimate for :server mode) and distributed across RAM and GPUs from the load's :n_gpu_layers/:split_mode/:tensor_split/:main_gpu. Three budget shapes: :infinity; an integer (a single combined RAM+VRAM pool, backward-compatible); and :auto/explicit map (per-device — RAM pool plus per-GPU VRAM pools checked independently). :auto reads each GPU's free VRAM via a new backend-agnostic device_list NIF (ggml_backend_dev_*, exposed as LlamaCppEx.devices/0). Over-budget loads are refused, naming the device: {:error, {:insufficient_memory, device: :total | :ram | {:gpu, i}, required:, available:}}. There is no automatic eviction — yanking a model another caller is mid-stream on is worse than a clear refusal.
Estimation is coarse and advisory: partial offload (0 < n_gpu_layers < n_layers) is treated as fully offloaded, and compute buffers/fragmentation aren't modeled.
GC-based unload
unload/1 stops the backing server (dropping its context and model refs), deletes the ETS entry, and forces a GC. Because reclamation is by garbage collection, a caller still holding a %Model{} (e.g. from fetch_model/1) keeps the model alive until that reference is dropped. This is documented; list/0/info/1 return sanitized, ref-free views to avoid accidentally leaking handles.
Opt-in supervision
mix.exs application/0 is unchanged — there is no auto-started Application. Users add {LlamaCppEx.ModelSupervisor, opts} to their own tree (or call start_link/1 for scripts/IEx). The supervisor starts the Registry and DynamicSupervisor before the manager.
Consequences
- Zero impact on existing single-model usage — the manager is additive and opt-in, with no new dependencies.
- The singleton GenServer is not a bottleneck: inference bypasses it via direct ETS reads.
- A backing-server crash is isolated by the
DynamicSupervisor; the manager monitors each server and marks the entry:erroron:DOWN(it does not auto-reload, to avoid crash loops without backoff). - Unload is eventual, not deterministic — adequate for swapping a few models, but a fixed-VRAM box needing hard, immediate reclamation would want an explicit
model_freeNIF (deferred; see below). - Model I/O is behind an injectable
Backendbehaviour, so lifecycle tests run without real GGUF files.
Alternatives Considered
One process per model
Rejected — models are read-only and shareable and inference runs on dirty schedulers, so a process per model buys no concurrency. It would only add supervision overhead. The :server mode still uses a process per model, but for batching, not for safe concurrent access.
Auto-started Application
Rejected — this is a library. Auto-starting a process tree would impose it on every dependent whether or not they use multi-model. Opt-in via ModelSupervisor keeps users in control.
Automatic LRU eviction on over-budget
Rejected as the default — eviction can terminate a model another caller is actively streaming from (the busy-check is advisory and racy). Refusing is safer and predictable; the caller decides what to unload.
Deterministic model_free NIF
Deferred. A NIF that nulls the llama_model* would make unload's memory reclamation immediate, but any live llama_context still references that model — freeing it out from under a context is a use-after-free that crashes the BEAM. It would need refcount gating in every accessor. GC-based reclamation plus the documented caveat is sufficient for now.