Skip to content
Merged
26 changes: 24 additions & 2 deletions src/lib/inference/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,19 +554,21 @@ describe("local inference helpers", () => {

it("falls back to bootstrap model options when no Ollama models are installed", () => {
expect(getBootstrapOllamaModelOptions(null)).toEqual(["qwen2.5:7b"]);
// Below every registry entry's required memory: small only.
expect(
getBootstrapOllamaModelOptions({
type: "nvidia",
totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB - 1,
totalMemoryMB: 10_000,
}),
).toEqual(["qwen2.5:7b"]);
// Comfortably above every registry entry's required memory: all options.
expect(
getBootstrapOllamaModelOptions({
type: "nvidia",
totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB,
}),
).toEqual(["qwen2.5:7b", DEFAULT_OLLAMA_MODEL, QWEN3_6_OLLAMA_MODEL]);
expect(getDefaultOllamaModel({ type: "nvidia", totalMemoryMB: 16384 }, () => "")).toBe(
expect(getDefaultOllamaModel({ type: "nvidia", totalMemoryMB: 10_000 }, () => "")).toBe(
"qwen2.5:7b",
);
expect(
Expand All @@ -577,6 +579,26 @@ describe("local inference helpers", () => {
).toBe(QWEN3_6_OLLAMA_MODEL);
});

it("downgrades the bootstrap menu when currently available memory is low (#4113)", () => {
// DGX Spark with another GPU workload eating the system pool: 128 GiB
// total, ~12 GiB currently free. The 23 GiB qwen3.6:35b model would
// crash the runner mid-load, so the bootstrap menu must only offer the
// small model.
expect(
getBootstrapOllamaModelOptions({
type: "nvidia",
totalMemoryMB: 131_072,
availableMemoryMB: 12_000,
}),
).toEqual(["qwen2.5:7b"]);
expect(
getDefaultOllamaModel(
{ type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 12_000 },
() => "",
),
).toBe("qwen2.5:7b");
});

it("offers the large Ollama model on Apple Silicon with sufficient unified memory", () => {
expect(
getBootstrapOllamaModelOptions({
Expand Down
42 changes: 19 additions & 23 deletions src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const { shellQuote, runCapture, runCaptureEx } = require("../runner");

import { OLLAMA_PORT, OLLAMA_PROXY_PORT, VLLM_PORT } from "../core/ports";
import { sleepSeconds } from "../core/wait";
import { fittableOllamaModelTags } from "./ollama-model-registry";

const { containerCanReachHostLoopback, inferContainerRuntime, isWsl } = require("../platform");
const { dockerInfo } = require("../adapters/docker/info");
Expand Down Expand Up @@ -125,6 +126,13 @@ export interface GpuInfo {
// does not get sized as if it were confirmed NVIDIA / Apple Silicon
// (#3510).
type?: string;
// Currently free GPU memory at probe time. Populated by `detectGpu` from
// `nvidia-smi memory.free` (or `MemAvailable` on unified-memory hosts).
// Used by the bootstrap-model selector so an idle 128 GiB Spark and a
// 128 GiB Spark with another GPU workload eating 116 GiB do not get the
// same model recommendation. Absent => the selector falls back to
// `totalMemoryMB`, preserving the pre-#4113 behaviour.
availableMemoryMB?: number;
}

export interface ValidationResult {
Expand Down Expand Up @@ -734,26 +742,13 @@ export function getOllamaModelOptions(runCaptureImpl?: RunCaptureFn): string[] {
return parseOllamaList(listOutput);
}

function isLargeOllamaCapableGpu(gpu: GpuInfo | null): boolean {
// Only confirmed-NVIDIA and Apple-Silicon devices get the large-model
// default. Other detection outcomes (null, missing `type`, or a partial
// result that fell through the NVIDIA path with type set to something
// else) fall back to the smaller model so we never download a 22 GB
// model onto a host whose acceleration is unconfirmed (#3510).
return (
!!gpu &&
(gpu.type === "nvidia" || gpu.type === "apple") &&
gpu.totalMemoryMB >= LARGE_OLLAMA_MIN_MEMORY_MB
);
}

export function getBootstrapOllamaModelOptions(gpu: GpuInfo | null): string[] {
const options = [SMALL_OLLAMA_MODEL];
if (isLargeOllamaCapableGpu(gpu)) {
options.push(DEFAULT_OLLAMA_MODEL);
options.push(QWEN3_6_OLLAMA_MODEL);
}
return options;
// Delegate to the registry so the menu reflects what the host can
// actually load right now (#4113). Only confirmed-NVIDIA and
// Apple-Silicon devices get larger options; ambiguous types fall back to
// the smallest model so we never download a 22 GB model onto a host
// whose acceleration is unconfirmed (#3510).
return fittableOllamaModelTags(gpu);
}

export function getDefaultOllamaModel(
Expand All @@ -762,10 +757,11 @@ export function getDefaultOllamaModel(
): string {
const models = getOllamaModelOptions(runCaptureImpl);
if (models.length === 0) {
if (isLargeOllamaCapableGpu(gpu)) {
return QWEN3_6_OLLAMA_MODEL;
}
return SMALL_OLLAMA_MODEL;
// No installed models — pick the largest registry entry that fits the
// host's currently available memory (the last element of the
// smallest-first list returned by fittableOllamaModelTags).
const fittable = fittableOllamaModelTags(gpu);
return fittable[fittable.length - 1];
}
return models.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : models[0];
}
Expand Down
23 changes: 13 additions & 10 deletions src/lib/inference/nim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,16 +196,18 @@ describe("nim", () => {
});

it("populates name and memory from primary nvidia-smi path", () => {
// Primary path returns name+memory.total in a single CSV line per GPU.
// Regression guard for #2669: the GB300 preflight line was missing the
// GPU model because only memory.total was being queried.
// Primary path returns name+memory.total+memory.free in a single CSV
// line per GPU. Regression guard for #2669: the GB300 preflight line
// was missing the GPU model because only memory.total was being
// queried. memory.free was added in #4113 so the bootstrap-model
// selector can size against currently free memory, not just total.
const runCapture = vi.fn((cmd: string | string[]) => {
if (!Array.isArray(cmd)) throw new Error("expected argv array");
if (
cmd[0] === "nvidia-smi" &&
cmd.some((a: string) => a.includes("name,memory.total"))
) {
return "NVIDIA GB300, 284208\n";
return "NVIDIA GB300, 284208, 280000\n";
}
return "";
});
Expand All @@ -217,6 +219,7 @@ describe("nim", () => {
name: "NVIDIA GB300",
count: 1,
totalMemoryMB: 284208,
availableMemoryMB: 280000,
perGpuMB: 284208,
});
} finally {
Expand All @@ -231,7 +234,7 @@ describe("nim", () => {
cmd[0] === "nvidia-smi" &&
cmd.some((a: string) => a.includes("name,memory.total"))
) {
return "NVIDIA H100 80GB HBM3, 81920\nNVIDIA H100 80GB HBM3, 81920\n";
return "NVIDIA H100 80GB HBM3, 81920, 81000\nNVIDIA H100 80GB HBM3, 81920, 60000\n";
}
return "";
});
Expand Down Expand Up @@ -265,7 +268,7 @@ describe("nim", () => {
cmd[0] === "nvidia-smi" &&
cmd.some((a: string) => a.includes("name,memory.total"))
) {
return "NVIDIA RTX A,B, 81920\n";
return "NVIDIA RTX A,B, 81920, 80000\n";
}
return "";
});
Expand Down Expand Up @@ -295,7 +298,7 @@ describe("nim", () => {
cmd[0] === "nvidia-smi" &&
cmd.some((a: string) => a.includes("name,memory.total"))
) {
return "NVIDIA RTX PRO 6000 Blackwell Max-Q, 97887\nNVIDIA GB300, 256703\n";
return "NVIDIA RTX PRO 6000 Blackwell Max-Q, 97887, 90000\nNVIDIA GB300, 256703, 250000\n";
}
return "";
});
Expand Down Expand Up @@ -329,7 +332,7 @@ describe("nim", () => {
cmd[0] === "nvidia-smi" &&
cmd.some((a: string) => a.includes("name,memory.total"))
) {
return "JMJWOA-Generic-GPU, 65471\n";
return "JMJWOA-Generic-GPU, 65471, 65000\n";
}
return "";
});
Expand Down Expand Up @@ -359,7 +362,7 @@ describe("nim", () => {
cmd[0] === "nvidia-smi" &&
cmd.some((a: string) => a.includes("name,memory.total"))
) {
return "NVIDIA JMJWOA-Generic-GPU, 65471\n";
return "NVIDIA JMJWOA-Generic-GPU, 65471, 65000\n";
}
return "";
});
Expand All @@ -385,7 +388,7 @@ describe("nim", () => {
cmd[0] === "nvidia-smi" &&
cmd.some((a: string) => a.includes("name,memory.total"))
) {
return "JMJWOA-Generic-GPU, 131072\n";
return "JMJWOA-Generic-GPU, 131072, 12000\n";
}
return "";
});
Expand Down
75 changes: 65 additions & 10 deletions src/lib/inference/nim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export interface GpuDetection {
gpus?: NimGpu[];
count: number;
totalMemoryMB: number;
// Currently free GPU memory at probe time. NVIDIA: summed from
// `nvidia-smi memory.free`. Unified-memory (Spark/Jetson) and macOS:
// approximated from host `MemAvailable` / `vm_stat` since GPU memory is
// the system pool. Absent when no probe was able to produce a usable
// figure; downstream callers fall back to `totalMemoryMB` (#4113).
availableMemoryMB?: number;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
perGpuMB: number;
cores?: number | null;
nimCapable: boolean;
Expand Down Expand Up @@ -180,6 +186,28 @@ function readHostMemoryMB(): number {
return 0;
}

// `free -m` columns: total used free shared buff/cache available.
// "available" (column 6) is the kernel's estimate of memory that can be
// reclaimed without swapping — the right signal for "is there room for a
// 22 GB Ollama load right now?" on unified-memory hosts (#4113). Returns 0
// when the column cannot be parsed; the caller treats 0 as "unknown" and
// falls back to total memory.
function readHostAvailableMemoryMB(): number {
try {
const freeOut = runCapture(["free", "-m"], { ignoreError: true });
if (freeOut) {
const memLine = freeOut.split("\n").find((l: string) => l.includes("Mem:"));
if (memLine) {
const parts = memLine.split(/\s+/);
return parseInt(parts[6], 10) || 0;
}
}
} catch {
/* ignored */
}
return 0;
}

function hostPathExists(path: string): boolean {
try {
return fs.existsSync(path);
Expand Down Expand Up @@ -279,26 +307,41 @@ export function canRunNimWithMemory(totalMemoryMB: number): boolean {
}

export function detectGpu(): GpuDetection | null {
// Try NVIDIA first — query name and VRAM in a single call so the preflight
// line can show the GPU model alongside the memory size.
// Try NVIDIA first — query name, total, and free VRAM in a single call so
// the preflight line can show the GPU model alongside the memory size and
// the bootstrap-model selector can pick a model that fits currently
// available memory (#4113), not just the headline total.
try {
const output = runCapture(
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
[
"nvidia-smi",
"--query-gpu=name,memory.total,memory.free",
"--format=csv,noheader,nounits",
],
{ ignoreError: true },
);
if (output) {
type ParsedGpu = { name: string; memoryMB: number };
type ParsedGpu = { name: string; memoryMB: number; freeMemoryMB: number };
const parsed: ParsedGpu[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
// Split on the LAST comma — GPU names can contain commas in rare cases.
const idx = line.lastIndexOf(",");
if (idx === -1) continue;
const name = line.slice(0, idx).trim();
const memoryMB = parseInt(line.slice(idx + 1).trim(), 10);
// Split on commas from the RIGHT: free MB, then total MB; the
// remainder is the GPU name (which can itself contain commas).
const lastIdx = line.lastIndexOf(",");
if (lastIdx === -1) continue;
const freeMemoryMB = parseInt(line.slice(lastIdx + 1).trim(), 10);
const beforeFree = line.slice(0, lastIdx);
const totalIdx = beforeFree.lastIndexOf(",");
if (totalIdx === -1) continue;
const memoryMB = parseInt(beforeFree.slice(totalIdx + 1).trim(), 10);
const name = beforeFree.slice(0, totalIdx).trim();
if (isNaN(memoryMB)) continue;
parsed.push({ name, memoryMB });
parsed.push({
name,
memoryMB,
freeMemoryMB: isNaN(freeMemoryMB) ? 0 : freeMemoryMB,
});
}
if (parsed.length > 0) {
const platform = detectNvidiaPlatform();
Expand All @@ -319,6 +362,10 @@ export function detectGpu(): GpuDetection | null {
(sum: number, p: ParsedGpu) => sum + p.memoryMB,
0,
);
const availableMemoryMB = trusted.reduce(
(sum: number, p: ParsedGpu) => sum + p.freeMemoryMB,
0,
);
const firstName = trusted[0].name;
// Only surface a single name when every GPU reports the same model;
// a mixed-GPU host would otherwise be misreported as `Nx <firstName>`.
Expand All @@ -330,6 +377,7 @@ export function detectGpu(): GpuDetection | null {
gpus: trusted.map((p) => ({ name: p.name, memoryMB: p.memoryMB })),
count: trusted.length,
totalMemoryMB,
...(availableMemoryMB > 0 ? { availableMemoryMB } : {}),
perGpuMB: trusted[0].memoryMB,
nimCapable: canRunNimWithMemory(totalMemoryMB),
platform,
Expand Down Expand Up @@ -388,12 +436,17 @@ export function detectGpu(): GpuDetection | null {
// Memory.total is not available on unified-memory devices, so we split
// the host RAM evenly across the named GPUs for the per-GPU breakdown.
// Approximation, but the only number nvidia-smi gives us in this path.
// `availableMemoryMB` mirrors that approximation using MemAvailable so
// the bootstrap-model selector reacts to concurrent GPU workloads
// eating into the shared system pool (#4113).
const availableMemoryMB = readHostAvailableMemoryMB();
return {
type: "nvidia",
...(allUnifiedSameName ? { name: firstUnifiedName } : {}),
gpus: unifiedGpuNames.map((name: string) => ({ name, memoryMB: perGpuMB })),
count,
totalMemoryMB,
...(availableMemoryMB > 0 ? { availableMemoryMB } : {}),
perGpuMB: perGpuMB || totalMemoryMB,
nimCapable: canRunNimWithMemory(totalMemoryMB),
unifiedMemory: true,
Expand All @@ -410,12 +463,14 @@ export function detectGpu(): GpuDetection | null {
const tegraGpu = detectTegraHostGpu();
if (tegraGpu) {
const totalMemoryMB = readHostMemoryMB();
const availableMemoryMB = readHostAvailableMemoryMB();
return {
type: "nvidia",
name: tegraGpu.name,
gpus: [{ name: tegraGpu.name, memoryMB: totalMemoryMB }],
count: 1,
totalMemoryMB,
...(availableMemoryMB > 0 ? { availableMemoryMB } : {}),
perGpuMB: totalMemoryMB,
nimCapable: canRunNimWithMemory(totalMemoryMB),
unifiedMemory: true,
Expand Down
Loading
Loading