Cartucce di modelli di ML: download dei modelli tramite chiamate peer, caricamento dei modelli con keepalive, inferenza con avanzamento in streaming.
Architettura delle cartucce di modelli
Tutte le cartucce di ML condividono un’architettura in tre fasi:
graph LR
DL["1. Download<br/>(chiamata peer a<br/>modelcartridge)"] --> LD["2. Caricamento<br/>(FFI bloccante tramite<br/>run_with_keepalive)"]
LD --> INF["3. Inferenza<br/>(output in streaming<br/>con avanzamento)"]
DL -.- P1["[0.00 – 0.25]"]
LD -.- P2["[0.25 – 0.35]"]
INF -.- P3["[0.35 – 0.95]"]
- Download: delega a
modelcartridgetramite una chiamata peer. La cartuccia dei modelli gestisce i download HTTP, la cache locale e la verifica degli hash. Restituisce un percorso di file locale. - Caricamento: carica il modello in memoria tramite FFI bloccante (llama.cpp, Candle, MLX). Usa
run_with_keepaliveper evitare i timeout di attività. - Inferenza: esegue il modello sui dati di input. Emette l’avanzamento per token o per passo.
Sorgente: handler di ggufcartridge, candlecartridge, mlxcartridge.
Cartucce di modelli esistenti
ggufcartridge (Rust, llama.cpp)
Fornisce l’inferenza di modelli in formato GGUF tramite la FFI di llama.cpp:
- Generazione di testo: generazione in streaming e vincolata (basata su grammatica).
- Visione: descrizione di immagini tramite modelli multimodali CLIP/MTMD.
- Embedding: embedding di testo e dimensioni degli embedding.
- Vocabolario: estrazione del vocabolario di token.
- Informazioni sul modello: metadati del modello (numero di parametri, dimensione del contesto, architettura).
Usa GgufModelManager per la gestione del ciclo di vita dei modelli e LlamaBackend per il livello FFI di llama.cpp.
Sorgente: ggufcartridge/src/main.rs, model.rs, vision.rs.
candlecartridge (Rust, Candle)
Fornisce l’inferenza tramite il framework Candle di Hugging Face:
- Embedding di testo: sentence embedding BERT.
- Dimensioni degli embedding: per i modelli BERT e CLIP.
- Embedding di immagini: embedding di immagini CLIP.
- Descrizione di immagini: didascalie di immagini con BLIP.
- Trascrizione del parlato: da audio a testo con Whisper.
Sorgente: candlecartridge/src/main.rs.
mlxcartridge (Swift, MLX)
Fornisce l’inferenza tramite il framework MLX di Apple su Apple Silicon:
- Generazione di testo: generazione di testo LLM.
- Visione: descrizione di immagini tramite modelli multimodali.
- Embedding di testo: sentence embedding.
- Dimensioni degli embedding.
Sorgente: mlxcartridge/Sources/mlxcartridge/main.swift.
Download dei modelli tramite chiamata peer
Tutte e tre le cartucce usano lo stesso pattern per scaricare i modelli. L’handler chiama modelcartridge tramite l’invocatore peer:
let model_spec_json = serde_json::json!({
"repo_id": "TheBloke/Llama-2-7B-GGUF",
"filename": "llama-2-7b.Q4_K_M.gguf"
});
let spec_bytes = serde_json::to_vec(&model_spec_json)?;
let response = req.peer().call_with_bytes(
"cap:in=\"media:model-spec;enc=utf-8\";out=\"media:file-path;enc=utf-8\";download-model",
&[("media:model-spec;enc=utf-8", &spec_bytes)],
).await?;
La risposta contiene:
- frame
PeerResponseItem::Logcon l’avanzamento del download (numero di file, numero di byte). PeerResponseItem::Datacon un risultato JSON contenente il percorso locale del modello.
Sorgente: ggufcartridge model.rs (resolve_model_path), candlecartridge (ensure_model_available), mlxcartridge (ensureModelAvailable).
Inoltro dell’avanzamento
L’handler inoltra l’avanzamento del download del peer al proprio stream di avanzamento, mappato nell’intervallo [0.0, 0.25]:
let mut model_path = String::new();
let mut response = call.finish().await?;
while let Some(item) = response.recv().await {
match item {
PeerResponseItem::Data(Ok(value), _meta) => {
// Estrae il percorso del modello dalla risposta
model_path = extract_path(&value)?;
}
// Un altro output della cap del peer: non ciò che questo chiamante ha chiesto.
PeerResponseItem::Output { .. } => {}
PeerResponseItem::Log(frame) => {
if let Some(peer_progress) = frame.log_progress() {
let mapped = peer_progress * 0.25; // intervallo [0.0, 0.25]
req.output().progress(mapped, frame.log_message().unwrap_or("Downloading"));
}
}
PeerResponseItem::Data(Err(e), _) => return Err(e.into()),
}
}
Vedi Avanzamento e logging per il pattern generale di inoltro dell’avanzamento.
Caricamento dei modelli con keepalive
Perché serve il keepalive
Caricare un modello di grandi dimensioni tramite FFI (ad es. llama_model_load() per un GGUF da 7B parametri) può richiedere 30–120 secondi. È una chiamata sincrona e bloccante che occupa il thread su cui viene eseguita.
Se questa chiamata viene eseguita su un thread worker di tokio, il task di scrittura dei frame (che ha anch’esso bisogno di un worker di tokio) non può scaricare i frame su stdout. Il motore non vede alcun frame e, dopo 120 secondi, fa scattare il suo timeout di attività.
run_with_keepalive sposta la chiamata bloccante in tokio::task::spawn_blocking (un pool di thread separato) ed emette frame di avanzamento ogni 30 secondi sul runtime asincrono così liberato. Vedi Avanzamento e logging per il meccanismo.
Pattern in Rust
req.output().progress(0.25, "Loading model...");
let model = req.output().run_with_keepalive(0.25, "Loading model...", move || {
LlamaModel::load_from_file(&model_path, ¶ms)
}).await;
let model = model.map_err(|e| OpError::ExecutionFailed(format!("Model load failed: {}", e)))?;
req.output().progress(0.35, "Model loaded");
Sorgente: handler di ggufcartridge, candlecartridge.
Pattern in Swift
output.progress(0.25, message: "Loading model...")
let container = try await output.runWithKeepalive(progress: 0.25, message: "Loading model...") {
try await ModelFactory.shared.loadContainer(configuration: config)
}
output.progress(0.35, message: "Model loaded")
Sorgente: handler di mlxcartridge.
Il caso speciale della pipeline di visione
La pipeline di visione di ggufcartridge (handle_describe_image) ha un vincolo particolare: VisionEngine<'a> prende in prestito da &GgufModelManager e &LoadedModel, quindi ha parametri di lifetime e non può essere restituito da spawn_blocking.
La pipeline di visione viene quindi eseguita interamente in un’unica closure spawn_blocking (caricamento del modello di testo, caricamento del modello mmproj e inferenza). Alla closure viene passato un ProgressSender per l’avanzamento per token:
let ps = req.output().progress_sender();
let result = tokio::task::spawn_blocking(move || {
ps.progress(0.25, "Loading text model...");
let text_model = load_text_model(&path)?;
ps.progress(0.30, "Loading vision model...");
let vision = VisionEngine::new(&manager, &text_model, &mmproj_path)?;
ps.progress(0.35, "Running inference...");
vision.describe(&image, |token_idx, total| {
let p = 0.35 + 0.6 * (token_idx as f32 / total as f32);
ps.progress(p, "Generating description...");
})
}).await??;
Sorgente: ggufcartridge/src/main.rs (handle_describe_image), vision.rs.
Soppressione delle callback di log FFI
Le librerie FFI hanno spesso un logging interno che scrive su stderr. Nella sandbox di un’app con interfaccia grafica, stderr non viene svuotato, e fputs(text, stderr) si blocca per sempre.
Servono due meccanismi di soppressione separati, perché llama.cpp e CLIP/MTMD hanno variabili globali di callback di log indipendenti:
Log di llama.cpp:
backend.void_logs(); // Chiama llama_log_set() con una callback che non fa nulla
Log di CLIP/MTMD (variabile globale separata):
unsafe {
unsafe extern "C" fn void_log(
_level: ggml_log_level,
_text: *const c_char,
_user_data: *mut c_void,
) {}
mtmd_log_set(Some(void_log), std::ptr::null_mut());
}
Entrambi devono essere chiamati prima di caricare i modelli. backend.void_logs() sopprime solo i log di llama, non i log di CLIP.
Sorgente: ggufcartridge/src/model.rs, vision.rs.
Inferenza con avanzamento in streaming
Durante l’inferenza, gli handler emettono l’avanzamento per token mappato nell’intervallo [0.35, 0.95]:
let progress_base = 0.35;
let progress_range = 0.60; // da 0.35 a 0.95
let mut last_reported = 0;
for (i, token) in tokens.iter().enumerate() {
let pct = (i * 100) / max_tokens;
if pct >= last_reported + 5 { // limita ai confini del 5%
let p = progress_base + progress_range * (i as f32 / max_tokens as f32);
req.output().progress(p, "Generating...");
last_reported = pct;
}
// ... emette il token ...
}
req.output().progress(0.95, "Complete");
Limitare la frequenza ai confini del 5% evita di inondare il motore di frame di avanzamento. Con 200 token, questo produce circa 20 frame di avanzamento invece di 200.
L’inferenza viene sempre eseguita all’interno di spawn_blocking (tramite run_with_keepalive, oppure un worker dedicato alimentato attraverso un canale per il lavoro per chunk); è lavoro bloccante esattamente come il caricamento del modello, e se eseguita in linea sul runtime asincrono affama il ciclo del runtime finché l’host non termina la cartuccia per heartbeat mancanti. Al suo interno usa un ProgressSender invece di output.progress(). Vedi Avanzamento e logging.
Argomenti di specifica del modello
Gli argomenti di specifica del modello seguono una convenzione standard: il media URN media:model-spec;enc=utf-8 (con un qualificatore di framework facoltativo come gguf, candle, mlx) trasporta come testo UTF-8 un ID di repository HuggingFace o un percorso locale.
I valori predefiniti sono impostati nei valori predefiniti degli argomenti della definizione della cap. Per esempio, una cap di generazione di testo GGUF potrebbe avere come predefinito "TheBloke/Llama-2-7B-GGUF". Gli utenti possono sovrascriverlo tramite i valori degli slot nel piano.
Quando l’argomento è assente a runtime, la cartuccia legge il valore predefinito dalle proprie definizioni di cap incorporate, il cap-snapshots.json che la build della cartuccia rigenera e verifica rispetto al fabric live prima della pubblicazione (DeclaredDefaults in ggufcartridge e candlecartridge). Quella copia è il valore predefinito pubblicato dal fabric, trasportato dal binario che è stato pubblicato con essa; leggerla non richiede né rete né chiamate peer. Viene letta in modo rigoroso, all’avvio: una definizione che non dichiara alcun valore predefinito stringa per un argomento che l’handler richiederà è un difetto di pubblicazione e arresta la cartuccia indicandola per nome. Non esiste una seconda copia del valore in una costante né alcun ripiego fra copie.