Let a machine have more than one llama.cpp

A model whose kernels are not upstream needs the fork that has them, and
the ordinary models still want the ordinary build. Anything under
`~/.local/share/ai-app/llama/<name>/` -- `llama-server`, or the
`bin/llama-server` a `cmake --install --prefix` leaves -- is now
discovered beside the one on PATH and becomes a provider called
`llama-cpp-<name>`, with its own router, preset and model settings.

That keeps the module's security property rather than bending it: the
phone still names no command, because what runs is still decided by what
somebody put on the machine. Each probe answer is tagged with what was
asked for, since two of these are now the same program under different
paths.

Flash attention joins the model settings (`flash-attn` in the preset).
llama.cpp's `auto` stays the default; the control is for a model whose
publisher asks for `on` outright, which Prism ML's ternary Bonsai does.

Verified against the fork built into that directory: discovery answers
`llama-cpp-prism`, the child server is started with `--flash-attn on`,
and Ternary-Bonsai-2-27B PTQ1_0 loads and answers through a session.
This commit is contained in:
iris-ai committed 2026-09-21 01:37:36 -04:00
1 parent df48a334f7
commit 7e7910083c
6 files changed
+220 -16

No files matched your search

+32
View File
@@ -174,6 +174,38 @@ Two models are downloaded under `~/.local/share/ai-app/models`:
projector being kept out of the models a provider offers. Qwen3-0.6B beside projector being kept out of the models a provider offers. Qwen3-0.6B beside
it is the other half of that rig -- the model that answers `refused`. it is the other half of that rig -- the model that answers `refused`.
**A second llama.cpp is installed here, and it is the rig for a custom
build.** `~/.local/share/ai-app/llama/prism/` is Prism ML's fork
(`prism` branch, `~/repos/llama.cpp-prism`, Vulkan, `cmake --install
--prefix`), so discovery finds it as a provider called `llama-cpp-prism`
beside the ordinary `llama-cpp`. It is what exercises that mechanism at all,
and it serves `prism-ml/Ternary-Bonsai-2-27B-gguf` -- ternary packings stock
llama.cpp rejects as unknown types. Rebuild it with
`cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON
-DCMAKE_INSTALL_RPATH='$ORIGIN/../lib'`, about six minutes at `-j8`.
**Which Bonsai packing runs on the GPU is the backend's question, not the
model's.** Measured 2026-09-21 with `llama-bench -p 512 -n 64 -r 2 -fa 1
-ngl 99` on the free card:
| packing | backend | pp512 | tg64 |
| --- | --- | ---: | ---: |
| `PTQ1_0`, 5.53 GiB | Vulkan | 519 t/s | 7.5 t/s |
| `PQ2_0`, 7.21 GiB | **CPU**, 8 cores | unfinished after 9 min | -- |
The fork's Vulkan port covers `PTQ1_0` only -- shaders, a `mul_mat_vec` and
the FWHT included -- so `PQ2_0` has no kernel there and every matmul falls
back to the CPU. That reads exactly like a stuck load: the process sits at
700% CPU for minutes with the card idle. On CUDA and HIP it is the other way
round, since `mmq.cu` guards `PTQ1_0` out of the HIP build (it wants Turing
MMA) and leaves `PQ2_0` in. **ROCm cannot be tested in this VM**: there is no
`/dev/kfd`, because the GPU here is virtio-gpu rather than a passed-through
card.
7.5 tok/s is the honest speed of that Vulkan kernel, against 42 for the
IQ3_S 27B beside it -- smaller weights, slower decode. Nothing is
misconfigured; the fork's fast kernels are CUDA and Metal.
**Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B produces fluent **Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B produces fluent
nonsense, which reads exactly like a broken driver — `llama-cli` produces the nonsense, which reads exactly like a broken driver — `llama-cli` produces the
same from the file directly, which is how to tell the two apart in a hurry. same from the file directly, which is how to tell the two apart in a hurry.
+10
View File
@@ -52,6 +52,16 @@ Module-by-module intent is in PLAN.md's "Backend layout".
that model; and the preset is read back before every edit, because a router that model; and the preset is read back before every edit, because a router
adopted from an earlier run is serving sections this process has never seen adopted from an earlier run is serving sections this process has never seen
and rewriting without them unloads those. and rewriting without them unloads those.
**A machine can have more than one llama.cpp** (2026-09-21,
`machines.rs`): anything at `~/.local/share/ai-app/llama/<name>/llama-server`
or `.../<name>/bin/llama-server` is discovered beside the one on PATH and
becomes a provider called `llama-cpp-<name>`, with its own router, preset
and model settings. That is how a model whose kernels are not upstream is
served -- Prism ML's ternary Bonsai is the one here, built from the
`prism` branch into `~/.local/share/ai-app/llama/prism` -- without the
phone ever naming a command, which is the property this module exists for.
Both providers offer the machine's whole models directory, since which
build reads which packing is not answerable from the file.
**A llama.cpp session runs on its configured machine** (built **A llama.cpp session runs on its configured machine** (built
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the 2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
port the server binds *there* and the port that reaches it *here*, and port the server binds *there* and the port that reaches it *here*, and
+29
View File
@@ -74,6 +74,17 @@ a control that silently did nothing).
enrolled token cannot introduce a command. The escape hatch for a binary enrolled token cannot introduce a command. The escape hatch for a binary
somewhere unusual is editing `config.ron`, deliberately the one authority somewhere unusual is editing `config.ron`, deliberately the one authority
the phone does not have. the phone does not have.
- **A machine may have more than one llama.cpp** (2026-09-21). Anything under
`~/.local/share/ai-app/llama/<name>/``llama-server`, or the
`bin/llama-server` a `cmake --install --prefix` leaves — is discovered
beside the one on PATH and becomes a provider called `llama-cpp-<name>`.
This is the same authority as before, not a new one: what runs is still
decided by what somebody put on the machine, and the phone still names no
command. It exists because one build is not enough — a model whose kernels
are not upstream needs the fork that has them, and the ordinary models
should keep being served by the ordinary build. A provider of its own is
what that has to be, because a router process, its preset file and a
model's load settings all hang off the provider.
- **Migration code is deleted once the update carrying it is received.** The - **Migration code is deleted once the update carrying it is received.** The
providers/hosts migration ran on the one host there is and is gone. A file providers/hosts migration ran on the one host there is and is gone. A file
in the old shape now fails to parse, which is correct because no such file in the old shape now fails to parse, which is correct because no such file
@@ -556,6 +567,24 @@ deliberate and easy to undo by accident:
server that cannot load it, and it stays in the machine's own model list, server that cannot load it, and it stays in the machine's own model list,
which is where a file on a disk is managed. which is where a file on a disk is managed.
- **A build that is not the machine's `llama-server` is another provider**
(2026-09-21, the case being Prism ML's ternary Bonsai, whose `PTQ1_0` and
`PQ2_0` packings stock llama.cpp rejects as unknown types). Nothing about
the fork is named anywhere: it is a directory under
`~/.local/share/ai-app/llama/`, found by the same probe that finds the
binaries on PATH, and everything downstream — the router, the preset file,
a model's load settings, a session — already hangs off the provider, so
there is nothing for a second build to special-case. The consequence worth
knowing is that both providers offer the machine's whole models directory,
including the files the other one is the answer for: which build reads
which packing is not something this side can tell from the file, and a
model that will not load says so in the transcript.
- **Flash attention is a model setting** (`flashAttention`, written as the
preset's `flash-attn`). llama.cpp's own `auto` remains the default and is
the right one; the control exists because a publisher can ask for `on`
outright, which is a statement about the file rather than about the
backend that would be deciding.
### Models (2026-08-28, rebuilt per machine 2026-09-19) ### Models (2026-08-28, rebuilt per machine 2026-09-19)
- **A download belongs to the model, not to the request.** Keyed by - **A download belongs to the model, not to the request.** Keyed by
+13
View File
@@ -550,6 +550,19 @@ pub const LLAMA_MODEL_PARAMS: &[ParamSpec] = &[
kind: ParamKind::Integer, kind: ParamKind::Integer,
restart: true, restart: true,
}, },
ParamSpec {
key: "flashAttention",
label: "Flash attention",
// llama.cpp's `auto` is the right default and stays it. The control
// is here because a model's publisher can ask for `on` outright --
// Prism ML's ternary Bonsai does -- which is a statement about the
// file that `auto` would be taking from the backend instead.
unset: "llama.cpp's own choice",
kind: ParamKind::Choice {
options: &["auto", "on", "off"],
},
restart: true,
},
ParamSpec { ParamSpec {
key: "speculative", key: "speculative",
label: "Speculative decoding", label: "Speculative decoding",
+135 -16
View File
@@ -12,7 +12,9 @@
//! //!
//! The cost is that a program somewhere unusual is invisible. The escape hatch //! The cost is that a program somewhere unusual is invisible. The escape hatch
//! is editing `config.ron` on the backend, which is exactly the authority the //! is editing `config.ron` on the backend, which is exactly the authority the
//! phone is not being given. //! phone is not being given -- and for the case that keeps arising, a second
//! llama.cpp built to serve a model the ordinary one cannot, there is a
//! directory a build is put in to be found: [`LLAMA_BUILDS`].
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::Serialize; use serde::Serialize;
@@ -38,17 +40,49 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
/// screen, not a restriction -- the field stays free text. /// screen, not a restriction -- the field stays free text.
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"]; const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
/// Asks `transport`'s machine which of [`PROBES`] it has. /// Where a machine keeps the llama.cpp builds it has besides the one on its
/// PATH: one directory per build, holding either `llama-server` itself or the
/// `bin/llama-server` that `cmake --install` puts there.
///
/// A model whose kernels are not upstream -- Prism ML's ternary Bonsai
/// packings are the case this was built for -- needs the fork that has them,
/// while the ordinary models still want the ordinary build. So each directory
/// found here is a provider of its own, with its own router process, its own
/// model settings and its own sessions.
///
/// **A directory rather than a path the phone could type**, which is the whole
/// design of this module: no route accepts a command to run, so putting a
/// build here is a decision somebody makes on the machine itself. The name of
/// the directory is what the provider is called, so it is worth choosing.
const LLAMA_BUILDS: &str = "$HOME/.local/share/ai-app/llama";
/// The word a [`LLAMA_BUILDS`] line is tagged with, which is not a program
/// name and so cannot collide with one.
const BUILD: &str = "build";
/// Asks `transport`'s machine which of [`PROBES`] it has, and which llama.cpp
/// builds are in [`LLAMA_BUILDS`].
/// ///
/// One round trip rather than one per program: over ssh each would be a separate /// One round trip rather than one per program: over ssh each would be a separate
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it /// connection and handshake. `command -v` is POSIX and a shell builtin, so it
/// works whatever is installed -- and `|| true` keeps a missing program from /// works whatever is installed -- and each answer is tagged with what was asked
/// ending the loop, since the caller wants the whole answer. /// for, since two of these are the same program under different paths.
///
/// Each test is an `if`'s condition rather than the loop body's last command,
/// so that finding nothing is not the script's exit status -- the caller reads
/// a non-zero exit as a machine it could not reach. And a build has to be a
/// *file*, since a directory carries the executable bit too.
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> { pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect(); let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
let script = format!( let script = format!(
"for p in {}; do command -v \"$p\" || true; done", "for p in {wanted}; do \
wanted.join(" ") if q=$(command -v \"$p\"); then printf '%s\\t%s\\n' \"$p\" \"$q\"; fi; \
done; \
d={LLAMA_BUILDS}; \
for p in \"$d\"/*/llama-server \"$d\"/*/bin/llama-server; do \
if [ -f \"$p\" ] && [ -x \"$p\" ]; then printf '{BUILD}\\t%s\\n' \"$p\"; fi; \
done",
wanted = wanted.join(" "),
); );
let launch = Launch::new("sh", vec!["-c".to_string(), script], None); let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
let found = transport.capture(&launch).await.map_err(explain)?; let found = transport.capture(&launch).await.map_err(explain)?;
@@ -60,17 +94,38 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
if matches!(transport, Transport::Here) { if matches!(transport, Transport::Here) {
providers.push(crate::config::Config::echo_provider()); providers.push(crate::config::Config::echo_provider());
} }
for (name, binary, kind) in PROBES { providers.extend(probed(&found));
let path = found Ok(providers)
.lines() }
.map(str::trim)
.find(|line| line.rsplit('/').next() == Some(*binary)); /// What the probe script's output says is installed.
let Some(path) = path else { ///
/// Separated from the round trip so it can be exercised without a machine.
fn probed(found: &str) -> Vec<ProviderConfig> {
let mut providers: Vec<ProviderConfig> = Vec::new();
for line in found.lines() {
let Some((key, path)) = line.trim().split_once('\t') else {
continue; continue;
}; };
let (name, kind) = if key == BUILD {
let Some(name) = build_name(path) else {
continue;
};
(name, DriverKind::LlamaCpp)
} else {
let Some((name, _, kind)) = PROBES.iter().find(|(_, binary, _)| *binary == key) else {
continue;
};
((*name).to_string(), *kind)
};
// A directory holding both shapes is matched by both globs. Two
// providers of one name is a config that silently loses one of them.
if providers.iter().any(|already| already.name == name) {
continue;
}
providers.push(ProviderConfig { providers.push(ProviderConfig {
name: (*name).to_string(), name,
kind: *kind, kind,
// The resolved path rather than the bare name: PATH under a // The resolved path rather than the bare name: PATH under a
// non-interactive ssh session is not the one a person sees when they // non-interactive ssh session is not the one a person sees when they
// log in, so "it is on my PATH" is not enough. // log in, so "it is on my PATH" is not enough.
@@ -79,7 +134,7 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(), DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
_ => Vec::new(), _ => Vec::new(),
}, },
mcp_servers: mcp_defaults(*kind), mcp_servers: mcp_defaults(kind),
// What a probe cannot know: how this machine's models are loaded // What a probe cannot know: how this machine's models are loaded
// is configured after the fact, and a re-probe keeps it -- see // is configured after the fact, and a re-probe keeps it -- see
// `SessionManager::update_machine`. // `SessionManager::update_machine`.
@@ -87,7 +142,21 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
max_loaded: None, max_loaded: None,
}); });
} }
Ok(providers) providers
}
/// What to call the provider for a build found at `path`: the name of its own
/// directory, under the `llama-cpp` the plain one already has.
///
/// The `bin/` a `cmake --install` produces is not part of the name -- a build
/// installed as a prefix and one that is a single binary in a directory are
/// the same build, and naming them differently would make moving between them
/// lose the model settings kept against the name.
fn build_name(path: &str) -> Option<String> {
let dir = path.rsplit_once('/')?.0;
let dir = dir.strip_suffix("/bin").unwrap_or(dir);
let name = dir.rsplit('/').next()?;
(!name.is_empty()).then(|| format!("llama-cpp-{name}"))
} }
/// One model a picker can offer, and what to call it there. /// One model a picker can offer, and what to call it there.
@@ -316,6 +385,56 @@ mod tests {
assert_eq!(tidy(" "), None); assert_eq!(tidy(" "), None);
} }
#[test]
fn a_llama_build_is_a_provider_named_for_its_directory() {
let found = "llama-server\t/usr/bin/llama-server\n\
build\t/home/me/.local/share/ai-app/llama/prism/bin/llama-server\n\
build\t/home/me/.local/share/ai-app/llama/nightly/llama-server\n";
let providers = probed(found);
let named: Vec<(&str, &str)> = providers
.iter()
.map(|p| (p.name.as_str(), p.program()))
.collect();
assert_eq!(
named,
vec![
("llama-cpp", "/usr/bin/llama-server"),
(
"llama-cpp-prism",
"/home/me/.local/share/ai-app/llama/prism/bin/llama-server"
),
(
"llama-cpp-nightly",
"/home/me/.local/share/ai-app/llama/nightly/llama-server"
),
]
);
assert!(providers.iter().all(|p| p.kind == DriverKind::LlamaCpp));
}
#[test]
fn one_build_matched_twice_is_one_provider() {
let found = "build\t/home/me/.local/share/ai-app/llama/prism/llama-server\n\
build\t/home/me/.local/share/ai-app/llama/prism/bin/llama-server\n";
let providers = probed(found);
assert_eq!(providers.len(), 1);
assert_eq!(
providers[0].program(),
"/home/me/.local/share/ai-app/llama/prism/llama-server"
);
}
#[test]
fn a_line_this_does_not_understand_is_not_a_provider() {
let found = "warning: something on stderr\n\
\n\
ruby\t/usr/bin/ruby\n\
codex\t/usr/bin/codex\n";
let providers = probed(found);
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].name, "codex-cli");
}
#[test] #[test]
fn codex_models_are_the_selectable_non_hidden_catalog_entries() { fn codex_models_are_the_selectable_non_hidden_catalog_entries() {
let answer = json!({"result": {"data": [ let answer = json!({"result": {"data": [
+1
View File
@@ -675,6 +675,7 @@ fn section(found: &Model, settings: &BTreeMap<String, String>) -> String {
("contextSize", "ctx-size"), ("contextSize", "ctx-size"),
("gpuLayers", "n-gpu-layers"), ("gpuLayers", "n-gpu-layers"),
("threads", "threads"), ("threads", "threads"),
("flashAttention", "flash-attn"),
// How far ahead the draft head guesses. Not defaulted: 2 measured 7% // How far ahead the draft head guesses. Not defaulted: 2 measured 7%
// faster than llama.cpp's 3 on this machine's GPU, once, which is a // faster than llama.cpp's 3 on this machine's GPU, once, which is a
// reason to make the knob reachable and not a reason to move it for // reason to make the knob reachable and not a reason to move it for