Skip to content

Commit bf4bb6e

Browse files
committed
Make the GPU backend picker vendor-aware (CUDA/Vulkan/Metal/ROCm guidance)
Classify detected GPUs by vendor (NVIDIA/AMD/Intel/Apple) across all detection paths, add an lspci fallback so AMD/Intel GPUs on Linux are no longer invisible, and have the llama.cpp backend picker recommend the right backend for the hardware — CUDA for NVIDIA, Vulkan for AMD/Intel, Metal for Apple — with an explicit note that native ROCm means using the Ollama backend (node-llama-cpp ships no ROCm/SYCL prebuilds).
1 parent ba002b0 commit bf4bb6e

6 files changed

Lines changed: 208 additions & 17 deletions

File tree

app/src/system-specs.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { recommendModels, type SystemSpecs } from "./system-specs";
2+
import { classifyGpuVendor, recommendModels, type SystemSpecs } from "./system-specs";
33

44
function baseSpecs(overrides: Partial<SystemSpecs> = {}): SystemSpecs {
55
return {
@@ -16,6 +16,34 @@ function baseSpecs(overrides: Partial<SystemSpecs> = {}): SystemSpecs {
1616
};
1717
}
1818

19+
describe("classifyGpuVendor", () => {
20+
it("identifies NVIDIA cards by common product names", () => {
21+
expect(classifyGpuVendor("NVIDIA GeForce RTX 4070")).toBe("nvidia");
22+
expect(classifyGpuVendor("GTX 1660 Super")).toBe("nvidia");
23+
expect(classifyGpuVendor("Tesla T4")).toBe("nvidia");
24+
});
25+
26+
it("identifies AMD cards", () => {
27+
expect(classifyGpuVendor("AMD Radeon RX 7900 XTX")).toBe("amd");
28+
expect(classifyGpuVendor("Radeon Vega 8")).toBe("amd");
29+
expect(classifyGpuVendor("Advanced Micro Devices, Inc. [AMD/ATI] Navi 31")).toBe("amd");
30+
});
31+
32+
it("identifies Intel GPUs including integrated graphics", () => {
33+
expect(classifyGpuVendor("Intel Arc A770")).toBe("intel");
34+
expect(classifyGpuVendor("Intel(R) Iris(R) Xe Graphics")).toBe("intel");
35+
expect(classifyGpuVendor("Intel(R) UHD Graphics 630")).toBe("intel");
36+
});
37+
38+
it("identifies Apple GPUs", () => {
39+
expect(classifyGpuVendor("Apple M3 Pro")).toBe("apple");
40+
});
41+
42+
it("returns unknown for unrecognized names", () => {
43+
expect(classifyGpuVendor("Matrox G200eW")).toBe("unknown");
44+
});
45+
});
46+
1947
describe("recommendModels", () => {
2048
it("falls back to RAM-based sizing when there's no GPU", () => {
2149
const result = recommendModels(baseSpecs({ totalRAMGB: 16 }));

app/src/system-specs.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,21 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
6868
{ name: "llama3.1:70b", label: "Llama 3.1 70B", minRAMGB: 48, description: "Near top-tier quality, requires a workstation-class PC.", supportsTools: true },
6969
];
7070

71+
export type GpuVendor = "nvidia" | "amd" | "intel" | "apple" | "unknown";
72+
73+
// Classifies a GPU by its marketing name — the only identity most detection
74+
// paths give us (the Windows WMI path and macOS system_profiler report names,
75+
// not vendor IDs). Drives which llama.cpp backend gets recommended: CUDA is
76+
// NVIDIA-only, Metal is Apple-only, and AMD/Intel accelerate via Vulkan
77+
// (node-llama-cpp ships no ROCm/SYCL prebuilds — native ROCm means Ollama).
78+
export function classifyGpuVendor(name: string): GpuVendor {
79+
if (/nvidia|geforce|\brtx\b|\bgtx\b|quadro|tesla/i.test(name)) return "nvidia";
80+
if (/\bamd\b|radeon|\brx\s?\d{3,4}\b|vega|firepro|instinct/i.test(name)) return "amd";
81+
if (/intel|\barc\b|iris|uhd graphics|hd graphics/i.test(name)) return "intel";
82+
if (/apple/i.test(name)) return "apple";
83+
return "unknown";
84+
}
85+
7186
function execFileP(cmd: string, args: string[]): Promise<string | null> {
7287
return new Promise((resolve) => {
7388
execFile(cmd, args, { timeout: 3000 }, (err, stdout) => {
@@ -117,7 +132,7 @@ async function detectGpus(): Promise<GpuInfo[]> {
117132
gpus.push({
118133
name: gpu.Name,
119134
vramGB: ramGB > 0 && ramGB < 64 ? +ramGB.toFixed(1) : null,
120-
vendor: "unknown",
135+
vendor: classifyGpuVendor(gpu.Name),
121136
});
122137
}
123138
if (gpus.length > 0) return gpus;
@@ -127,6 +142,23 @@ async function detectGpus(): Promise<GpuInfo[]> {
127142
}
128143
}
129144

145+
// Linux without NVIDIA drivers (AMD/Intel boxes) previously detected
146+
// nothing at all — lspci names the display controllers even when no
147+
// vendor tooling is installed.
148+
if (os.platform() === "linux") {
149+
const out = await execFileP("lspci", []);
150+
if (out) {
151+
const gpus: GpuInfo[] = [];
152+
for (const line of out.split("\n")) {
153+
if (/VGA compatible controller|3D controller|Display controller/i.test(line)) {
154+
const name = line.split(":").slice(2).join(":").trim();
155+
if (name) gpus.push({ name, vramGB: null, vendor: classifyGpuVendor(name) });
156+
}
157+
}
158+
if (gpus.length > 0) return gpus;
159+
}
160+
}
161+
130162
if (os.platform() === "darwin") {
131163
const out = await execFileP("system_profiler", ["SPDisplaysDataType"]);
132164
if (out) {

frontend/src/lib/gpu.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect } from "vitest";
2+
import { recommendGpuBackend, gpuBackendNote } from "./gpu";
3+
4+
describe("recommendGpuBackend", () => {
5+
it("recommends CUDA for NVIDIA when CUDA is available", () => {
6+
expect(recommendGpuBackend(["nvidia"], ["cuda", "vulkan"])).toBe("cuda");
7+
});
8+
9+
it("falls back to Vulkan for NVIDIA when CUDA is not available", () => {
10+
expect(recommendGpuBackend(["nvidia"], ["vulkan"])).toBe("vulkan");
11+
});
12+
13+
it("recommends Vulkan for AMD (no ROCm prebuilds exist)", () => {
14+
expect(recommendGpuBackend(["amd"], ["cuda", "vulkan"])).toBe("vulkan");
15+
});
16+
17+
it("recommends Vulkan for Intel", () => {
18+
expect(recommendGpuBackend(["intel"], ["vulkan"])).toBe("vulkan");
19+
});
20+
21+
it("recommends Metal for Apple", () => {
22+
expect(recommendGpuBackend(["apple"], ["metal"])).toBe("metal");
23+
});
24+
25+
it("tries Vulkan for an unidentified GPU rather than dropping to CPU", () => {
26+
expect(recommendGpuBackend(["unknown"], ["vulkan"])).toBe("vulkan");
27+
});
28+
29+
it("recommends CPU when no backend fits", () => {
30+
expect(recommendGpuBackend([], [])).toBe("cpu");
31+
expect(recommendGpuBackend(["amd"], [])).toBe("cpu");
32+
});
33+
});
34+
35+
describe("gpuBackendNote", () => {
36+
it("explains Vulkan for AMD", () => {
37+
expect(gpuBackendNote(["amd"])).toBe("amdViaVulkan");
38+
});
39+
40+
it("explains Vulkan for Intel", () => {
41+
expect(gpuBackendNote(["intel"])).toBe("intelViaVulkan");
42+
});
43+
44+
it("notes when no GPU was detected", () => {
45+
expect(gpuBackendNote([])).toBe("noGpuDetected");
46+
});
47+
48+
it("returns null for NVIDIA/Apple where the default story needs no caveat", () => {
49+
expect(gpuBackendNote(["nvidia"])).toBeNull();
50+
expect(gpuBackendNote(["apple"])).toBeNull();
51+
});
52+
});

frontend/src/lib/gpu.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { LlamaCppGpuBackend } from "@/types/electron";
2+
3+
// Which llama.cpp backend to suggest for the user's hardware, constrained to
4+
// the backends node-llama-cpp actually reports as loadable on this machine.
5+
// CUDA is NVIDIA-only and Metal is Apple-only; AMD and Intel GPUs are served
6+
// by Vulkan, since node-llama-cpp ships no ROCm or SYCL prebuilt binaries —
7+
// AMD users who want native ROCm should run those models through Ollama,
8+
// which supports it directly.
9+
export function recommendGpuBackend(vendors: string[], available: string[]): LlamaCppGpuBackend {
10+
if (vendors.includes("nvidia") && available.includes("cuda")) return "cuda";
11+
if (vendors.includes("apple") && available.includes("metal")) return "metal";
12+
if ((vendors.includes("nvidia") || vendors.includes("amd") || vendors.includes("intel")) && available.includes("vulkan")) {
13+
return "vulkan";
14+
}
15+
// A GPU we couldn't identify is still better served by trying Vulkan than
16+
// silently falling back to CPU-only inference.
17+
if (vendors.includes("unknown") && available.includes("vulkan")) return "vulkan";
18+
return "cpu";
19+
}
20+
21+
export type GpuBackendNote = "amdViaVulkan" | "intelViaVulkan" | "noGpuDetected" | null;
22+
23+
// Which explanatory note (if any) the backend picker should show — returned
24+
// as a symbol rather than display text so the UI can translate it.
25+
export function gpuBackendNote(vendors: string[]): GpuBackendNote {
26+
if (vendors.includes("amd")) return "amdViaVulkan";
27+
if (vendors.includes("intel")) return "intelViaVulkan";
28+
if (vendors.length === 0) return "noGpuDetected";
29+
return null;
30+
}

frontend/src/lib/translations.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ export interface Dictionary {
157157
gpuBackendHint: string;
158158
gpuBackendAuto: string;
159159
gpuBackendCpu: string;
160+
gpuRecommended: string;
161+
gpuDetected: string;
162+
gpuAmdRocmNote: string;
163+
gpuIntelVulkanNote: string;
164+
gpuNoneDetectedNote: string;
160165
llamaCppNoModels: string;
161166
huggingFaceResults: string;
162167
huggingFaceResultsHint: string;
@@ -441,6 +446,12 @@ export const en: Dictionary = {
441446
gpuBackendHint: "Only backends detected on this machine are shown as selectable, besides Auto and CPU.",
442447
gpuBackendAuto: "Auto-detect",
443448
gpuBackendCpu: "CPU only",
449+
gpuRecommended: "Recommended",
450+
gpuDetected: "Detected",
451+
gpuAmdRocmNote:
452+
"AMD GPUs are accelerated through Vulkan here. For native ROCm acceleration, run your models through the Ollama backend instead — it supports ROCm directly.",
453+
gpuIntelVulkanNote: "Intel GPUs (Arc and integrated) are accelerated through Vulkan.",
454+
gpuNoneDetectedNote: "No GPU detected — inference will run on the CPU.",
444455
llamaCppNoModels: "No GGUF models downloaded yet — search Hugging Face below and choose \"Download for llama.cpp\".",
445456
huggingFaceResults: "Hugging Face results",
446457
huggingFaceResultsHint: "Real search results from huggingface.co — expand a model to see its GGUF files.",
@@ -733,6 +744,12 @@ export const tr: Dictionary = {
733744
gpuBackendHint: "Otomatik ve CPU dışında yalnızca bu makinede tespit edilen backend'ler seçilebilir olarak gösterilir.",
734745
gpuBackendAuto: "Otomatik algıla",
735746
gpuBackendCpu: "Yalnızca CPU",
747+
gpuRecommended: "Önerilen",
748+
gpuDetected: "Algılanan",
749+
gpuAmdRocmNote:
750+
"AMD GPU'lar burada Vulkan üzerinden hızlandırılır. Yerel ROCm hızlandırması için modellerinizi ROCm'u doğrudan destekleyen Ollama arka ucu üzerinden çalıştırın.",
751+
gpuIntelVulkanNote: "Intel GPU'lar (Arc ve tümleşik) Vulkan üzerinden hızlandırılır.",
752+
gpuNoneDetectedNote: "GPU algılanmadı — çıkarım CPU üzerinde çalışacak.",
736753
llamaCppNoModels: "Henüz indirilmiş GGUF modeli yok — aşağıdan Hugging Face'te arayın ve \"llama.cpp için indir\"i seçin.",
737754
huggingFaceResults: "Hugging Face sonuçları",
738755
huggingFaceResultsHint: "huggingface.co'dan gerçek arama sonuçları — GGUF dosyalarını görmek için bir modeli genişletin.",

frontend/src/pages/Settings.tsx

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import type {
6363
AppActivity,
6464
} from "@/types/electron";
6565
import { EXTRA_MODELS } from "@/lib/model-catalog";
66+
import { recommendGpuBackend, gpuBackendNote } from "@/lib/gpu";
6667
import {
6768
DEFAULT_KEYBINDINGS,
6869
KEYBINDING_ACTIONS,
@@ -868,21 +869,52 @@ export default function Settings() {
868869
{settings && (
869870
<SettingsSection title={t.llamaCppSection} description={t.llamaCppHint} className="mt-8">
870871
<SettingsRow label={t.gpuBackend} description={t.gpuBackendHint} stacked>
871-
<Select
872-
value={settings.llamaCppGpuBackend ?? "auto"}
873-
onValueChange={(v) => changeLlamaCppGpuBackend(v as LlamaCppGpuBackend)}
874-
>
875-
<SelectTrigger size="sm" className="w-48">
876-
<SelectValue />
877-
</SelectTrigger>
878-
<SelectContent>
879-
<SelectItem value="auto">{t.gpuBackendAuto}</SelectItem>
880-
{llamaCppGpuBackends.includes("vulkan") && <SelectItem value="vulkan">Vulkan</SelectItem>}
881-
{llamaCppGpuBackends.includes("cuda") && <SelectItem value="cuda">CUDA</SelectItem>}
882-
{llamaCppGpuBackends.includes("metal") && <SelectItem value="metal">Metal</SelectItem>}
883-
<SelectItem value="cpu">{t.gpuBackendCpu}</SelectItem>
884-
</SelectContent>
885-
</Select>
872+
{(() => {
873+
const vendors = specs?.gpus.map((g) => g.vendor) ?? [];
874+
const recommended = recommendGpuBackend(vendors, llamaCppGpuBackends);
875+
const note = gpuBackendNote(vendors);
876+
const rec = (backend: string, label: string) =>
877+
backend === recommended ? `${label} (${t.gpuRecommended})` : label;
878+
return (
879+
<>
880+
<Select
881+
value={settings.llamaCppGpuBackend ?? "auto"}
882+
onValueChange={(v) => changeLlamaCppGpuBackend(v as LlamaCppGpuBackend)}
883+
>
884+
<SelectTrigger size="sm" className="w-56">
885+
<SelectValue />
886+
</SelectTrigger>
887+
<SelectContent>
888+
<SelectItem value="auto">{t.gpuBackendAuto}</SelectItem>
889+
{llamaCppGpuBackends.includes("cuda") && (
890+
<SelectItem value="cuda">{rec("cuda", "CUDA (NVIDIA)")}</SelectItem>
891+
)}
892+
{llamaCppGpuBackends.includes("vulkan") && (
893+
<SelectItem value="vulkan">{rec("vulkan", "Vulkan (NVIDIA / AMD / Intel)")}</SelectItem>
894+
)}
895+
{llamaCppGpuBackends.includes("metal") && (
896+
<SelectItem value="metal">{rec("metal", "Metal (Apple)")}</SelectItem>
897+
)}
898+
<SelectItem value="cpu">{rec("cpu", t.gpuBackendCpu)}</SelectItem>
899+
</SelectContent>
900+
</Select>
901+
{specs && specs.gpus.length > 0 && (
902+
<p className="text-xs text-muted-foreground">
903+
{t.gpuDetected}: {specs.gpus.map((g) => g.name).join(", ")}
904+
</p>
905+
)}
906+
{note === "amdViaVulkan" && (
907+
<p className="text-xs text-muted-foreground">{t.gpuAmdRocmNote}</p>
908+
)}
909+
{note === "intelViaVulkan" && (
910+
<p className="text-xs text-muted-foreground">{t.gpuIntelVulkanNote}</p>
911+
)}
912+
{note === "noGpuDetected" && (
913+
<p className="text-xs text-muted-foreground">{t.gpuNoneDetectedNote}</p>
914+
)}
915+
</>
916+
);
917+
})()}
886918
</SettingsRow>
887919
<SettingsRow label={t.modelsDir} stacked>
888920
<div className="flex flex-wrap items-center gap-2">

0 commit comments

Comments
 (0)