build: package universal Windows runtime and verify automatic CPU fallback
This commit is contained in:
1 parent
0fcc8ec21c
commit
278b09f82c
16 files changed
+695
-34
No files matched your search
@@ -48,6 +48,8 @@ class Encoder {
|
||||
}
|
||||
bool open(bool gpu) {
|
||||
auto mp = llama_model_default_params();
|
||||
static ggml_backend_dev_t cpu_devices[] = {nullptr};
|
||||
if (!gpu) mp.devices = cpu_devices;
|
||||
mp.n_gpu_layers = gpu ? 999 : 0;
|
||||
mp.progress_callback = [](float,void *) {return true;};
|
||||
model = llama_model_load_from_file(model_path.c_str(),mp);
|
||||
@@ -55,6 +57,7 @@ class Encoder {
|
||||
auto cp = llama_context_default_params();
|
||||
cp.n_ctx = context_tokens; cp.n_batch = context_tokens; cp.n_ubatch = context_tokens;
|
||||
cp.n_seq_max = 1; cp.kv_unified = true; cp.embeddings = true;
|
||||
cp.offload_kqv = gpu; cp.op_offload = gpu;
|
||||
cp.pooling_type = LLAMA_POOLING_TYPE_MEAN;
|
||||
cp.n_threads = threads; cp.n_threads_batch = threads;
|
||||
context = llama_init_from_model(model,cp);
|
||||
@@ -327,6 +330,16 @@ int main(int argc, char ** argv) {
|
||||
require(!path.empty() && threads >= 1 && threads <= 8,"Model path required; threads must be 1–8");
|
||||
require(device == "cpu" || device == "auto","Device must be cpu or auto");
|
||||
bool force_cpu = std::getenv("SEARCHI_FORCE_CPU") && std::string(std::getenv("SEARCHI_FORCE_CPU")) == "1";
|
||||
// Disable backend registration before llama initializes its registry.
|
||||
// A GPU-capable distribution can therefore be tested on CPU without
|
||||
// probing/enabling the Vulkan provider in the Windows VM.
|
||||
if (force_cpu || device == "cpu") {
|
||||
#ifdef _WIN32
|
||||
require(_putenv_s("GGML_DISABLE_VULKAN", "1") == 0, "Cannot disable Vulkan for CPU startup");
|
||||
#else
|
||||
require(setenv("GGML_DISABLE_VULKAN", "1", 1) == 0, "Cannot disable Vulkan for CPU startup");
|
||||
#endif
|
||||
}
|
||||
Encoder encoder(path,threads,device == "auto" && !force_cpu);
|
||||
std::cout << json({{"ready",true},{"model","google/embeddinggemma-2"},{"dimensions",dimensions},
|
||||
{"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG},{"media_supported",true},{"code_query_supported",true}}).dump() << '\n' << std::flush;
|
||||
|
||||
@@ -78,6 +78,27 @@ impl Drop for GemmaEmbedder {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StartupTransportFailure(String);
|
||||
impl std::fmt::Display for StartupTransportFailure {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for StartupTransportFailure {}
|
||||
|
||||
/// Retry only process/transport failure, never an incompatible protocol or model.
|
||||
fn start_with_cpu_fallback<T>(cpu: bool, mut start: impl FnMut(bool) -> Result<T>) -> Result<T> {
|
||||
match start(cpu) {
|
||||
Err(error) if !cpu && error.downcast_ref::<StartupTransportFailure>().is_some() => {
|
||||
start(true).with_context(|| {
|
||||
format!("CPU initialization failed after automatic worker startup failure: {error}")
|
||||
})
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
impl GemmaEmbedder {
|
||||
/// Load the verified Google model and explicitly supplied local worker.
|
||||
/// Production selects available acceleration with CPU fallback. Tests set
|
||||
@@ -113,7 +134,16 @@ impl GemmaEmbedder {
|
||||
);
|
||||
let force_cpu = std::env::var("SEARCHI_FORCE_CPU").is_ok_and(|value| value == "1");
|
||||
let cpu = force_cpu || matches!(device, Device::Cpu);
|
||||
let mut command = Command::new(&worker);
|
||||
start_with_cpu_fallback(cpu, |cpu| {
|
||||
Self::start_verified(&model_dir, &worker, threads, cpu)
|
||||
})
|
||||
}
|
||||
|
||||
// Both attempts share the already validated model/worker paths. CPU fallback
|
||||
// does not repeat the expensive hash check or weaken the handshake contract.
|
||||
fn start_verified(model_dir: &Path, worker: &Path, threads: usize, cpu: bool) -> Result<Self> {
|
||||
let model = model_dir.join(MODEL_FILE);
|
||||
let mut command = Command::new(worker);
|
||||
command
|
||||
.args(["--model"])
|
||||
.arg(model)
|
||||
@@ -132,9 +162,14 @@ impl GemmaEmbedder {
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
let mut child = command.spawn().context("Cannot start local Gemma worker")?;
|
||||
let stdin = child.stdin.take().context("Worker stdin missing")?;
|
||||
let stdout = child.stdout.take().context("Worker stdout missing")?;
|
||||
let mut child = command.spawn().map_err(|error| {
|
||||
StartupTransportFailure(format!("Cannot start local Gemma worker: {error}"))
|
||||
})?;
|
||||
let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("Gemma worker protocol pipes missing");
|
||||
};
|
||||
let (sender, responses) = mpsc::sync_channel(1);
|
||||
thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
@@ -151,7 +186,16 @@ impl GemmaEmbedder {
|
||||
other => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("Gemma worker did not initialize: {other:?}");
|
||||
// Malformed/oversized JSON is a protocol violation, not evidence
|
||||
// that hardware acceleration failed. Do not mask it with retry.
|
||||
if matches!(&other, Ok(Err(error)) if error.starts_with("Invalid Gemma worker JSON:") || error == "Gemma worker response exceeds protocol byte budget")
|
||||
{
|
||||
anyhow::bail!("Invalid Gemma worker startup protocol: {other:?}");
|
||||
}
|
||||
return Err(StartupTransportFailure(format!(
|
||||
"Gemma worker did not initialize: {other:?}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let info: WorkerInfo = match serde_json::from_value(ready) {
|
||||
@@ -181,8 +225,8 @@ impl GemmaEmbedder {
|
||||
info,
|
||||
healthy: true,
|
||||
force_cpu: cpu,
|
||||
model_dir,
|
||||
worker_path: worker,
|
||||
model_dir: model_dir.to_path_buf(),
|
||||
worker_path: worker.to_path_buf(),
|
||||
projector_verified: false,
|
||||
query_profile: None,
|
||||
})
|
||||
@@ -666,6 +710,73 @@ fn verify_artifact(path: &Path, bytes: u64, sha: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn automatic_startup_transport_failure_retries_cpu_once() {
|
||||
let mut attempts = Vec::new();
|
||||
let result = start_with_cpu_fallback(false, |cpu| {
|
||||
attempts.push(cpu);
|
||||
if cpu {
|
||||
Ok("cpu ready")
|
||||
} else {
|
||||
Err(StartupTransportFailure("worker closed its output before ready".into()).into())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result, "cpu ready");
|
||||
assert_eq!(attempts, [false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_failure_never_loops_or_retries_explicit_cpu() {
|
||||
for cpu in [false, true] {
|
||||
let mut attempts = Vec::new();
|
||||
let result: Result<()> = start_with_cpu_fallback(cpu, |selected_cpu| {
|
||||
attempts.push(selected_cpu);
|
||||
Err(StartupTransportFailure("worker exited".into()).into())
|
||||
});
|
||||
let error = format!("{:#}", result.unwrap_err());
|
||||
assert_eq!(attempts, if cpu { vec![true] } else { vec![false, true] });
|
||||
assert!(error.contains("worker exited"));
|
||||
if !cpu {
|
||||
assert!(error.contains("CPU initialization failed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_startup_protocol_is_not_retried() {
|
||||
let mut attempts = 0;
|
||||
let result: Result<()> = start_with_cpu_fallback(false, |_| {
|
||||
attempts += 1;
|
||||
let mut response = BufReader::new(&b"not JSON\n"[..]);
|
||||
let error = read_response(&mut response).unwrap_err();
|
||||
assert!(error.starts_with("Invalid Gemma worker JSON:"));
|
||||
anyhow::bail!("Invalid Gemma worker startup protocol: {error}");
|
||||
});
|
||||
assert!(result.unwrap_err().to_string().contains("startup protocol"));
|
||||
assert_eq!(attempts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_startup_contract_is_not_retried() {
|
||||
let mut attempts = 0;
|
||||
let result: Result<()> = start_with_cpu_fallback(false, |_| {
|
||||
attempts += 1;
|
||||
let handshake = json!({"ready":true,"model":"other-model","dimensions":768,"device":"cpu","threads":1,"runtime":"other-runtime"});
|
||||
let info: WorkerInfo = serde_json::from_value(handshake)?;
|
||||
ensure!(
|
||||
info.model == "google/embeddinggemma-2",
|
||||
"Gemma worker handshake violates configured model/device contract"
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("model/device contract"));
|
||||
assert_eq!(attempts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_text_worker_handshake_does_not_claim_media_support() {
|
||||
let mut handshake = json!({"ready":true,"model":"google/embeddinggemma-2","dimensions":768,"device":"cpu","threads":1,"runtime":"b11461-no-logits-v1"});
|
||||
|
||||
Reference in new issue
Block a user