This commit is contained in:
yi111 2026-10-07 15:54:44 +08:00 committed by GitHub
commit e81d5078d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 145 additions and 7 deletions

View File

@ -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

View File

@ -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?
*

View File

@ -140,6 +140,18 @@ export default function SettingsPage(props: {
},
]}
/>
{info?.gpuHealth?.status === 'unsupported' && (
<div className="lg:col-span-2">
<Alert
type="info"
title="No GPU acceleration available"
message={`${
info.gpuHealth.gpuVendor === 'intel' ? 'An Intel' : 'A'
} GPU is present, but NOMAD can only accelerate the AI Assistant on NVIDIA (CUDA) and AMD (ROCm) hardware. Inference will run on the CPU, so expect lower throughput than the hardware suggests.`}
variant="bordered"
/>
</div>
)}
{info?.gpuHealth?.status === 'passthrough_failed' && (
<div className="lg:col-span-2">
<GpuPassthroughAlert gpuHealth={info.gpuHealth} assistantName="AI Assistant" />

View File

@ -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))
}
})

View File

@ -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