Skip to content

Commit ac2b49e

Browse files
committed
feat: harden llama.cpp GPU lifecycle
1 parent b8a3b3e commit ac2b49e

2 files changed

Lines changed: 75 additions & 6 deletions

File tree

app/src/llamacpp-manager.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,52 @@ const modelLoads = new Map<string, Promise<LlamaModel>>();
3939
const modelLastUsed = new Map<string, number>();
4040
const activeModelUsers = new Map<string, number>();
4141
let maxCachedModels = 2;
42+
// Keep warm weights for fast follow-up prompts, then release their RAM/VRAM
43+
// after inactivity. Headless deployments can override the default without a
44+
// new UI setting; 0 disables time-based eviction.
45+
const configuredIdleMinutes = Number(process.env.OLLAMA_CUSTOM_UI_LLAMA_IDLE_MINUTES ?? 15);
46+
const modelIdleTimeoutMs = Number.isFinite(configuredIdleMinutes)
47+
? Math.max(0, configuredIdleMinutes) * 60_000
48+
: 15 * 60_000;
49+
let idleEvictionTimer: NodeJS.Timeout | null = null;
50+
51+
function clearIdleEvictionTimer(): void {
52+
if (!idleEvictionTimer) return;
53+
clearTimeout(idleEvictionTimer);
54+
idleEvictionTimer = null;
55+
}
56+
57+
function scheduleIdleEviction(): void {
58+
clearIdleEvictionTimer();
59+
if (modelIdleTimeoutMs === 0 || modelCache.size === 0) return;
60+
61+
const now = Date.now();
62+
const nextExpiry = [...modelCache.keys()]
63+
.filter((key) => (activeModelUsers.get(key) ?? 0) === 0)
64+
.map((key) => (modelLastUsed.get(key) ?? now) + modelIdleTimeoutMs)
65+
.sort((a, b) => a - b)[0];
66+
if (nextExpiry === undefined) return;
67+
68+
idleEvictionTimer = setTimeout(() => {
69+
idleEvictionTimer = null;
70+
void evictExpiredModels();
71+
}, Math.max(1_000, nextExpiry - now));
72+
idleEvictionTimer.unref();
73+
}
74+
75+
async function evictExpiredModels(): Promise<void> {
76+
const cutoff = Date.now() - modelIdleTimeoutMs;
77+
const expiredModels: LlamaModel[] = [];
78+
for (const [key, model] of modelCache) {
79+
if ((activeModelUsers.get(key) ?? 0) > 0) continue;
80+
if ((modelLastUsed.get(key) ?? 0) > cutoff) continue;
81+
modelCache.delete(key);
82+
modelLastUsed.delete(key);
83+
expiredModels.push(model);
84+
}
85+
await Promise.allSettled(expiredModels.map((model) => model.dispose()));
86+
scheduleIdleEviction();
87+
}
4288

4389
export function setModelCacheLimit(limit: number): void {
4490
if (!Number.isFinite(limit)) return;
@@ -55,6 +101,9 @@ export async function setGpuBackend(backend: GpuBackend): Promise<void> {
55101
throw new Error(`Unsupported llama.cpp GPU backend: ${String(backend)}`);
56102
}
57103
if (backend === activeBackend) return;
104+
if ([...activeModelUsers.values()].some((users) => users > 0)) {
105+
throw new Error("The GPU backend cannot be changed while a llama.cpp response is being generated.");
106+
}
58107
const oldModels = [...modelCache.values()];
59108
const oldLlama = llamaInstance;
60109
activeBackend = backend;
@@ -68,6 +117,7 @@ export async function setGpuBackend(backend: GpuBackend): Promise<void> {
68117
modelLoads.clear();
69118
modelLastUsed.clear();
70119
activeModelUsers.clear();
120+
clearIdleEvictionTimer();
71121

72122
// Native model buffers can outlive their JS references. Explicitly
73123
// dispose them so a backend switch returns VRAM before reallocating it.
@@ -116,6 +166,7 @@ async function loadModel(modelPath: string, gpuLayers?: number): Promise<LlamaMo
116166
const cached = modelCache.get(key);
117167
if (cached) {
118168
modelLastUsed.set(key, Date.now());
169+
scheduleIdleEviction();
119170
return cached;
120171
}
121172
const pending = modelLoads.get(key);
@@ -134,6 +185,7 @@ async function loadModel(modelPath: string, gpuLayers?: number): Promise<LlamaMo
134185
modelCache.set(key, model);
135186
modelLastUsed.set(key, Date.now());
136187
await evictIdleModels(key);
188+
scheduleIdleEviction();
137189
return model;
138190
})();
139191
modelLoads.set(key, load);
@@ -155,10 +207,12 @@ async function evictIdleModels(protectedKey?: string): Promise<void> {
155207
modelLastUsed.delete(candidate);
156208
if (model) await model.dispose();
157209
}
210+
scheduleIdleEviction();
158211
}
159212

160213
export async function dispose(): Promise<void> {
161214
backendRevision++;
215+
clearIdleEvictionTimer();
162216
const models = [...modelCache.values()];
163217
const llama = llamaInstance;
164218
modelCache.clear();
@@ -195,20 +249,34 @@ export function listLoadedModels(): string[] {
195249
return [...new Set([...modelCache.keys()].map((key) => key.split("\0", 1)[0]))];
196250
}
197251

198-
export function deleteModel(modelsDir: string, name: string): void {
252+
export async function deleteModel(modelsDir: string, name: string): Promise<void> {
199253
const root = path.resolve(modelsDir);
200254
const target = path.resolve(root, name);
201-
if (target !== root && !target.startsWith(root + path.sep)) {
255+
if (path.basename(name) !== name || !name.toLowerCase().endsWith(".gguf")) {
202256
throw new Error("Invalid model file name.");
203257
}
204-
fs.rmSync(target, { force: true });
258+
if (target === root || !target.startsWith(root + path.sep)) {
259+
throw new Error("Invalid model file name.");
260+
}
261+
const matchingKeys = [...modelCache.keys()].filter((key) => key.startsWith(`${target}\0`));
262+
if (matchingKeys.some((key) => (activeModelUsers.get(key) ?? 0) > 0)) {
263+
throw new Error("This model cannot be deleted while it is generating a response.");
264+
}
265+
if ([...modelLoads.keys()].some((key) => key.startsWith(`${target}\0`))) {
266+
throw new Error("This model cannot be deleted while it is still loading.");
267+
}
268+
269+
const modelsToDispose: LlamaModel[] = [];
205270
for (const [key, model] of modelCache) {
206271
if (key.startsWith(`${target}\0`)) {
207272
modelCache.delete(key);
208273
modelLastUsed.delete(key);
209-
void model.dispose();
274+
modelsToDispose.push(model);
210275
}
211276
}
277+
await Promise.allSettled(modelsToDispose.map((model) => model.dispose()));
278+
fs.rmSync(target, { force: true });
279+
scheduleIdleEviction();
212280
}
213281

214282
// Maps this app's provider-agnostic ChatMessage[] (system/user/assistant,
@@ -288,5 +356,6 @@ export async function chat(
288356
else activeModelUsers.set(cacheKey, users);
289357
modelLastUsed.set(cacheKey, Date.now());
290358
await evictIdleModels();
359+
scheduleIdleEviction();
291360
}
292361
}

app/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,8 @@ function registerIpcHandlers(): void {
294294
);
295295

296296
ipcMain.handle("llamacpp:listModels", () => llamacpp.listModels(getLlamaCppModelsDir()));
297-
ipcMain.handle("llamacpp:deleteModel", (_event: IpcMainInvokeEvent, name: string) => {
298-
llamacpp.deleteModel(getLlamaCppModelsDir(), requireString(name, "model name"));
297+
ipcMain.handle("llamacpp:deleteModel", async (_event: IpcMainInvokeEvent, name: string) => {
298+
await llamacpp.deleteModel(getLlamaCppModelsDir(), requireString(name, "model name"));
299299
});
300300
ipcMain.handle("llamacpp:getAvailableGpuBackends", () => llamacpp.getAvailableGpuBackends());
301301
ipcMain.handle("llamacpp:setGpuBackend", async (_event: IpcMainInvokeEvent, backend: llamacpp.GpuBackend) => {

0 commit comments

Comments
 (0)