From 1f34d175618a49422b47064e05e11e254a4d1ec1 Mon Sep 17 00:00:00 2001 From: Yi-111-a <153097222+Yi-111-a@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:53:19 +0800 Subject: [PATCH] fix(gpu): report an Intel GPU as unsupported instead of healthy The health probe's fallback branch set status 'ok' and ollamaGpuAccessible: true purely because si.graphics() returned a controller. On an Intel-only box that is the Core Ultra case in #1325: the controller is there, but NOMAD only ships CUDA and ROCm paths and Ollama has no Intel backend, so inference runs CPU-only while Settings > System reports a healthy GPU. Adds an 'unsupported' status, set only when no reported controller belongs to a vendor NOMAD can accelerate. NVIDIA and AMD boxes keep the existing behaviour, and an unrecognised vendor string is deliberately not flagged so a healthy card behind a newer pci.ids is never turned into a false alarm. --- admin/app/services/system_service.ts | 22 +++++++-- admin/app/utils/gpu_model.ts | 48 +++++++++++++++++++ admin/inertia/pages/settings/system.tsx | 12 +++++ admin/tests/unit/gpu_model.spec.ts | 62 ++++++++++++++++++++++++- admin/types/system.ts | 8 +++- 5 files changed, 145 insertions(+), 7 deletions(-) diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 2e620c7f..93f154a4 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -21,7 +21,7 @@ import env from '#start/env' import KVStore from '#models/kv_store' import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js' import { isNewerVersion } from '../utils/version.js' -import { isUnresolvedGpuModel } from '../utils/gpu_model.js' +import { findUnacceleratedGpuVendor, isUnresolvedGpuModel } from '../utils/gpu_model.js' import { classifyOllamaComputeBackend, diagnoseAmdCpuFallback, @@ -635,9 +635,23 @@ export class SystemService { } } else { // si.graphics() returned usable controllers and no GPU runtime is expected - // (host install, not Docker). Still unprobed, see #1325. - gpuHealth.status = 'ok' - gpuHealth.ollamaGpuAccessible = true + // (host install, not Docker). A controller here says a GPU exists, not + // that anything can use it: NOMAD only has CUDA and ROCm paths, so an + // Intel-only box (Core Ultra iGPU, for example) can never accelerate + // Ollama. Reporting 'ok' from the bare presence of a controller is what + // let such a box show a healthy GPU while inference ran CPU-only (#1325). + const unacceleratedVendor = findUnacceleratedGpuVendor(graphics.controllers) + if (unacceleratedVendor) { + gpuHealth.status = 'unsupported' + gpuHealth.gpuVendor = unacceleratedVendor + gpuHealth.ollamaGpuAccessible = false + logger.warn( + `GPU present (${unacceleratedVendor}) but NOMAD has no acceleration path for it; the AI Assistant will run on CPU` + ) + } else { + gpuHealth.status = 'ok' + gpuHealth.ollamaGpuAccessible = true + } } } catch { // Docker info query failed, skip host-level enrichment diff --git a/admin/app/utils/gpu_model.ts b/admin/app/utils/gpu_model.ts index 27bc93c8..df7652e7 100644 --- a/admin/app/utils/gpu_model.ts +++ b/admin/app/utils/gpu_model.ts @@ -1,3 +1,51 @@ +/** GPU vendors named by the acceleration paths NOMAD actually ships. */ +export type GpuVendorKey = 'nvidia' | 'amd' | 'intel' + +/** + * Which of the known GPU vendors is this `si.graphics()` vendor string? + * + * The AMD arm is deliberately loose: pci.ids reports "Advanced Micro Devices, + * Inc." on some cards and "ATI Technologies Inc." on older ones, and lspci output + * reaches us with whatever casing the host had. + * + * Returns null for a vendor that is none of the three. Callers must treat that as + * "no opinion" rather than "unsupported" — an unidentified card may well be a + * future NVIDIA part behind a newer pci.ids. + */ +export function classifyGpuVendor(vendor: string | null | undefined): GpuVendorKey | null { + const v = (vendor ?? '').trim() + if (/intel/i.test(v)) return 'intel' + if (/nvidia/i.test(v)) return 'nvidia' + if (/advanced micro devices|\bamd\b|\bati\b/i.test(v)) return 'amd' + return null +} + +/** + * The GPU vendor on this box that NOMAD has no acceleration path for, if any. + * + * NOMAD accelerates Ollama over CUDA (NVIDIA) or ROCm (AMD) and nothing else, so + * a box whose only visible controller is an Intel iGPU can never run inference + * on it — Ollama has no Intel backend. Returning null when an NVIDIA or AMD + * controller is present keeps hybrid laptops (Intel iGPU + NVIDIA dGPU) on the + * existing path: those boxes do have a working route, so the runtime probe above + * stays authoritative for them and this must not pre-empt it. + * + * An unidentified vendor is deliberately not reported. This runs on the branch + * where nothing was probed, so guessing "unsupported" from an unrecognised vendor + * string would turn a healthy NVIDIA box into a false alarm. + */ +export function findUnacceleratedGpuVendor( + controllers: ReadonlyArray<{ vendor?: string | null }> | null | undefined +): GpuVendorKey | null { + let unaccelerated: GpuVendorKey | null = null + for (const controller of controllers ?? []) { + const vendor = classifyGpuVendor(controller?.vendor) + if (vendor === 'nvidia' || vendor === 'amd') return null + if (vendor === 'intel' && !unaccelerated) unaccelerated = 'intel' + } + return unaccelerated +} + /** * Is this GPU "model" a placeholder rather than a real name? * diff --git a/admin/inertia/pages/settings/system.tsx b/admin/inertia/pages/settings/system.tsx index 895c9f88..b7a48a31 100644 --- a/admin/inertia/pages/settings/system.tsx +++ b/admin/inertia/pages/settings/system.tsx @@ -140,6 +140,18 @@ export default function SettingsPage(props: { }, ]} /> + {info?.gpuHealth?.status === 'unsupported' && ( +
+ +
+ )} {info?.gpuHealth?.status === 'passthrough_failed' && (
diff --git a/admin/tests/unit/gpu_model.spec.ts b/admin/tests/unit/gpu_model.spec.ts index 651dd45f..6ce4f67b 100644 --- a/admin/tests/unit/gpu_model.spec.ts +++ b/admin/tests/unit/gpu_model.spec.ts @@ -1,7 +1,11 @@ import * as assert from 'node:assert/strict' import { test } from 'node:test' -import { isUnresolvedGpuModel } from '../../app/utils/gpu_model.js' +import { + classifyGpuVendor, + findUnacceleratedGpuVendor, + isUnresolvedGpuModel, +} from '../../app/utils/gpu_model.js' test('unresolved PCI ids are rejected', () => { // The #1165 shape: a card newer than the container's pci.ids database comes @@ -55,3 +59,59 @@ test('a hex-like id that is not the placeholder shape is kept', () => { assert.equal(isUnresolvedGpuModel(s), false, s) } }) + +test('the vendors we ship acceleration paths for are recognised', () => { + // The pci.ids spellings that reach si.graphics() in the wild. + for (const s of ['NVIDIA Corporation', 'nvidia corporation']) { + assert.equal(classifyGpuVendor(s), 'nvidia', s) + } + for (const s of [ + 'Advanced Micro Devices, Inc. [AMD/ATI]', + 'AMD', + 'ati', + 'ATI Technologies Inc.', + ]) { + assert.equal(classifyGpuVendor(s), 'amd', s) + } + assert.equal(classifyGpuVendor('Intel Corporation'), 'intel') +}) + +test('an unidentifiable vendor is no opinion, not a verdict', () => { + // A card behind a newer pci.ids may be any vendor, so the classifier must not + // guess. Guessing here would let a healthy NVIDIA box be called unsupported. + for (const s of ['', ' ', undefined, null, 'ASPEED Technology', 'Matrox Electronics']) { + assert.equal(classifyGpuVendor(s), null, JSON.stringify(s)) + } +}) + +test('an Intel-only box is reported as having no acceleration path', () => { + // The #1325 shape: a Core Ultra iGPU. Nothing on this box can run Ollama, and + // the old code called that 'ok' because a controller was present. + assert.equal(findUnacceleratedGpuVendor([{ vendor: 'Intel Corporation' }]), 'intel') + assert.equal( + findUnacceleratedGpuVendor([{ vendor: 'Intel Corporation' }, { vendor: 'Intel Corporation' }]), + 'intel' + ) +}) + +test('an Intel iGPU beside an accelerable card is not flagged', () => { + // Hybrid laptops expose both. The dGPU is enough to accelerate, so the runtime + // probe stays authoritative and this helper must keep quiet. + assert.equal( + findUnacceleratedGpuVendor([{ vendor: 'Intel Corporation' }, { vendor: 'NVIDIA Corporation' }]), + null + ) + assert.equal( + findUnacceleratedGpuVendor([ + { vendor: 'Intel Corporation' }, + { vendor: 'Advanced Micro Devices, Inc. [AMD/ATI]' }, + ]), + null + ) +}) + +test('no controllers and unknown-only vendors are left alone', () => { + for (const c of [[], null, undefined, [{ vendor: '' }], [{ vendor: 'ASPEED Technology' }]]) { + assert.equal(findUnacceleratedGpuVendor(c), null, JSON.stringify(c)) + } +}) diff --git a/admin/types/system.ts b/admin/types/system.ts index 3f9893df..dff6a5bd 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -1,11 +1,15 @@ import { Systeminformation } from 'systeminformation' export type GpuHealthStatus = { - status: 'ok' | 'passthrough_failed' | 'no_gpu' | 'ollama_not_installed' + status: 'ok' | 'passthrough_failed' | 'no_gpu' | 'ollama_not_installed' | 'unsupported' hasNvidiaRuntime: boolean hasRocmRuntime: boolean ollamaGpuAccessible: boolean - gpuVendor?: 'nvidia' | 'amd' + /** + * 'intel' only ever accompanies status 'unsupported': a GPU is present but + * NOMAD ships no acceleration path for it, so inference runs on CPU. + */ + gpuVendor?: 'nvidia' | 'amd' | 'intel' /** * AMD passthrough_failed only. Whether a reinstall would change the running * container. When false, a reinstall rebuilds the same CPU-bound container and