fix(gpu): detect Ollama CPU fallback on AMD and offer the right fix

GpuPassthroughRemediationProvider returned early unless the NVIDIA
runtime was registered with Docker. AMD doesn't register one, so the
log-based CPU-fallback detection added in v1.35.0-rc.1 never ran on
AMD boxes. On a Phoenix1 iGPU running ollama:rocm with /dev/kfd and
/dev/dri passed through, Ollama logged library=cpu while gpuHealth
reported status "ok".

The gpuHealth side had its own bug. hasNvidiaRuntime and
hasRocmRuntime were set only inside the lspci probe branch. When lspci
returned a plausible controller, the else branch reported "ok" without
probing and left both flags false. A blind reinstall would not have
helped either: gfx1103 needs HSA_OVERRIDE_GFX_VERSION=11.0.0, and
without the gfx marker or ai.amdHsaOverride a reinstall rebuilds the
same CPU-bound container.

- Add resolveExpectedGpuVendor(). It reads the nomad_ollama container
  config first (nvidia DeviceRequest, ROCm image tag, /dev/kfd), then
  the nvidia runtime, then the AMD GPU type when no container exists.
  /dev/dri alone is not treated as AMD. A CPU container on an AMD box
  is trusted as deliberate (#1232 fallback, acceleration disabled).
- Gate the provider on the expected vendor. NVIDIA auto-reinstall is
  unchanged but now skips when the container requests a GPU with no
  runtime registered. AMD is detect-only: the provider logs whether a
  reinstall would change the container and never reinstalls itself.
- In gpuHealth, set the runtime flags up front and let Ollama's
  startup log decide health whenever a GPU is expected, whatever lspci
  returned. A CPU-only log now reports passthrough_failed and skips
  the nvidia-smi exec. lspci stays the display source when clean.
- Parse the first GPU "inference compute" line instead of the last
  line, which could be the CPU fallback device, and recognize Vulkan.
  Move the startup-log reader into ollama_compute so both callers
  share it.
- Report amdReinstallWouldChange, amdGfxTarget, currentHsaOverride and
  suggestedHsaOverride on AMD passthrough_failed. The gfx target is
  parsed from Ollama's "amdgpu is not supported" line (best-effort).
- Add GpuPassthroughAlert, shared by Settings > System and Settings >
  Models. When a reinstall would rebuild the same AMD container, it
  offers "Fix: Set GFX Override" instead of a reinstall. The dialog
  saves ai.amdHsaOverride and then reinstalls.
- Expose ai.amdHsaOverride through the settings API. The value lands
  in the container env, so only x.y.z, "none" or empty is accepted.
- Silence the HSA override resolver's logging on the gpuHealth poll.

The #1325 case where no GPU runtime is expected and lspci reports a
controller still returns "ok" unprobed.

Fixes #1344
Refs #1325, #1232
This commit is contained in:
jakeaturner 2026-09-24 23:09:47 +00:00 committed by Jake Turner
parent 7dabc40c85
commit ac437f8429
12 changed files with 871 additions and 361 deletions

View File

@ -1550,6 +1550,15 @@ export class DockerService {
}
}
/**
* The HSA_OVERRIDE_GFX_VERSION an AMD install or reinstall would apply right now.
* Exposed so GpuPassthroughRemediationProvider can tell whether a reinstall would
* change the running container.
*/
async getAmdHsaOverride(options: { quiet?: boolean } = {}): Promise<string | null> {
return this._resolveAmdHsaOverride(options)
}
/**
* Resolve the HSA_OVERRIDE_GFX_VERSION value for the host's AMD GPU.
*
@ -1589,32 +1598,32 @@ export class DockerService {
return pepper
}
private async _resolveAmdHsaOverride(): Promise<string | null> {
private async _resolveAmdHsaOverride({ quiet = false }: { quiet?: boolean } = {}): Promise<string | null> {
const manualRaw = await KVStore.getValue('ai.amdHsaOverride')
if (manualRaw !== null && manualRaw !== undefined && String(manualRaw).trim() !== '') {
const manual = String(manualRaw).trim().toLowerCase()
if (manual === 'none' || manual === 'off' || manual === 'false') {
logger.info('[DockerService] HSA override disabled via ai.amdHsaOverride')
if (!quiet) logger.info('[DockerService] HSA override disabled via ai.amdHsaOverride')
return null
}
if (/^\d+\.\d+\.\d+$/.test(manual)) {
logger.info(`[DockerService] HSA override forced to ${manual} via ai.amdHsaOverride`)
if (!quiet) logger.info(`[DockerService] HSA override forced to ${manual} via ai.amdHsaOverride`)
return manual
}
logger.warn(`[DockerService] Ignoring invalid ai.amdHsaOverride value: ${manualRaw}`)
if (!quiet) logger.warn(`[DockerService] Ignoring invalid ai.amdHsaOverride value: ${manualRaw}`)
}
try {
const gfx = (await readFile('/app/storage/.nomad-amd-gfx', 'utf8')).trim()
const mapped = this._mapGfxToHsaOverride(gfx)
logger.info(`[DockerService] AMD gfx marker '${gfx}' → HSA override ${mapped ?? 'none'}`)
if (!quiet) logger.info(`[DockerService] AMD gfx marker '${gfx}' → HSA override ${mapped ?? 'none'}`)
return mapped
} catch {
// Marker absent — most likely an existing install upgraded without re-running
// install_nomad.sh. Fall through to the default.
}
logger.warn(
if (!quiet) logger.warn(
'[DockerService] AMD GPU configured but no gfx marker (/app/storage/.nomad-amd-gfx) and no ' +
'ai.amdHsaOverride KV; relying on native ROCm discovery. iGPUs not on the bundled rocblas ' +
'allowlist (e.g. 780M/gfx1103, 680M/gfx1035) will silently fall back to CPU. Set the ' +

View File

@ -1,8 +1,13 @@
import type Docker from 'dockerode'
import { mapGfxToHsaOverride } from '../utils/amd_hsa_override.js'
/**
* Pure helpers for classifying which compute backend Ollama actually loaded,
* parsed from its container logs. Extracted so both SystemService (GPU health
* display) and GpuPassthroughRemediationProvider (auto-reinstall decision) share
* one source of truth, and so the logic is unit-testable without Docker.
* readOllamaStartupLogs is the one Docker-facing helper, kept here so both
* callers read the same log window.
*
* Ollama writes one `inference compute` log line per detected device a few seconds
* after startup, e.g.:
@ -39,3 +44,200 @@ export function classifyOllamaComputeBackend(logText: string): OllamaComputeBack
return 'unknown'
}
export type OllamaGpuLibrary = 'CUDA' | 'ROCm' | 'Vulkan'
export type OllamaGpuInfo = {
library: OllamaGpuLibrary
name: string
vramMiB: number
}
/**
* Parse the GPU device Ollama reported at startup, or null when no GPU line is
* present. Takes the first GPU line rather than the last `inference compute`
* line: Ollama also lists a CPU fallback device, and when that line lands last
* a working GPU would otherwise read as no GPU at all.
*/
export function parseOllamaGpuFromLogs(logText: string): OllamaGpuInfo | null {
const line = logText
.split('\n')
.find((l) => l.includes(INFERENCE_LINE_MARKER) && GPU_LIBRARY_RE.test(l))
if (!line) return null
const library = line.match(GPU_LIBRARY_RE)![1] as OllamaGpuLibrary
const descMatch = line.match(/description="([^"]+)"/)
const totalMatch = line.match(/total="([0-9.]+)\s*GiB"/)
const fallbackName = library === 'CUDA' ? 'NVIDIA GPU' : library === 'ROCm' ? 'AMD GPU' : 'GPU'
return {
library,
name: descMatch?.[1] || fallbackName,
vramMiB: totalMatch ? Math.round(Number.parseFloat(totalMatch[1]) * 1024) : 0,
}
}
export type GpuVendor = 'nvidia' | 'amd'
/**
* The subset of `docker inspect nomad_ollama` the GPU checks read. Structural so
* dockerode's ContainerInspectInfo satisfies it and tests can pass plain objects.
*/
export type OllamaContainerSnapshot = {
Config?: { Image?: string; Env?: string[] | null }
HostConfig?: {
Devices?: Array<{ PathOnHost?: string }> | null
DeviceRequests?: Array<{ Driver?: string }> | null
}
}
function hasDevice(container: OllamaContainerSnapshot, path: string): boolean {
return (container.HostConfig?.Devices ?? []).some((d) => d.PathOnHost === path)
}
// Matches rocm in the tag only (ollama/ollama:rocm, :0.12.0-rocm), not the repo path.
function isRocmImage(image: string | undefined): boolean {
return /:[^/:]*rocm[^/:]*$/i.test(image ?? '')
}
/**
* Which GPU vendor, if any, Ollama is expected to run on.
*
* The container's own config comes first because it records what the install
* actually set up, independent of how the host is detected today:
* - an nvidia DeviceRequest means NVIDIA
* - a ROCm image tag or a /dev/kfd device means AMD (/dev/dri alone is not
* AMD-specific, Intel and Vulkan setups pass it too)
* A registered nvidia runtime comes next, which covers a container created
* CPU-only before the runtime was added.
*
* The persisted AMD GPU type counts only when no container exists. An AMD
* container without ROCm config is deliberate: either acceleration was turned
* off, or the daemon rejected /dev/kfd and the install fell back to CPU (#1232).
* Flagging it would offer a reinstall that rebuilds the same CPU container. AMD
* never registers a Docker runtime, so gating on the runtime alone skips every
* AMD box (#1344).
*/
export function resolveExpectedGpuVendor(input: {
hasNvidiaRuntime: boolean
amdConfigured: boolean
container: OllamaContainerSnapshot | null
}): GpuVendor | null {
const { container } = input
if (container) {
if ((container.HostConfig?.DeviceRequests ?? []).some((r) => r.Driver === 'nvidia')) {
return 'nvidia'
}
if (isRocmImage(container.Config?.Image) || hasDevice(container, '/dev/kfd')) {
return 'amd'
}
}
if (input.hasNvidiaRuntime) return 'nvidia'
if (!container && input.amdConfigured) return 'amd'
return null
}
/**
* Where a running AMD nomad_ollama container differs from what a reinstall would
* create. An empty list means a reinstall would rebuild the same container, so it
* cannot move Ollama off the CPU and the fix lies elsewhere (usually the HSA
* override for an iGPU missing from ROCm's allowlist).
*/
export function diffAmdOllamaConfig(
container: OllamaContainerSnapshot,
desiredHsaOverride: string | null
): string[] {
const drift: string[] = []
const env = container.Config?.Env ?? []
if (!isRocmImage(container.Config?.Image)) {
drift.push(`image is ${container.Config?.Image ?? 'unknown'}, not the ROCm build`)
}
if (!hasDevice(container, '/dev/kfd')) drift.push('/dev/kfd is not passed through')
if (!hasDevice(container, '/dev/dri')) drift.push('/dev/dri is not passed through')
const currentOverride =
env.find((e) => e.startsWith('HSA_OVERRIDE_GFX_VERSION='))?.split('=')[1] ?? null
if (currentOverride !== desiredHsaOverride) {
drift.push(
`HSA_OVERRIDE_GFX_VERSION is ${currentOverride ?? 'unset'}, expected ${desiredHsaOverride ?? 'unset'}`
)
}
if (!env.includes('OLLAMA_IGPU_ENABLE=1')) drift.push('OLLAMA_IGPU_ENABLE=1 is not set')
return drift
}
/**
* The gfx target Ollama named when it rejected an AMD GPU, e.g. 'gfx1103' from
* msg="amdgpu is not supported (supported types:[gfx1030 ...])" gpu_type=gfx1103 ...
* Best-effort: the exact wording varies across Ollama releases, so callers treat
* null as "unknown" and fall back to asking the user.
*/
export function parseAmdGfxTargetFromLogs(logText: string): string | null {
return logText.match(/gpu_type=(gfx[0-9a-f]+)\b/i)?.[1]?.toLowerCase() ?? null
}
/**
* Why an AMD container expected on the GPU is running on CPU, in the shape
* GpuHealthStatus reports to the UI.
*/
export function diagnoseAmdCpuFallback(
container: OllamaContainerSnapshot,
logText: string,
desiredHsaOverride: string | null
) {
const gfx = parseAmdGfxTargetFromLogs(logText)
const current = (container.Config?.Env ?? [])
.find((e) => e.startsWith('HSA_OVERRIDE_GFX_VERSION='))
?.split('=')[1]
const suggested = gfx ? mapGfxToHsaOverride(gfx) : null
return {
amdReinstallWouldChange: diffAmdOllamaConfig(container, desiredHsaOverride).length > 0,
...(gfx && { amdGfxTarget: gfx }),
...(current && { currentHsaOverride: current }),
...(suggested && { suggestedHsaOverride: suggested }),
}
}
/**
* Read nomad_ollama's logs from its startup window (the five minutes after it
* booted), where the "inference compute" line is emitted. A tail:N read is
* fragile here: under embedding load the container writes >1000 lines/min,
* which pushes the line out of any reasonable tail within minutes, while the
* startup window is bounded and never ages out. Falls back to tail:500 when
* StartedAt is missing. Returns '' on any error so callers classify the backend
* as 'unknown' rather than throwing.
*/
export async function readOllamaStartupLogs(
container: Docker.Container,
inspect?: { State?: { StartedAt?: string } }
): Promise<string> {
try {
const state = inspect ?? (await container.inspect())
const startedAtRaw = state?.State?.StartedAt
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : NaN
const logsOpts: {
stdout: true
stderr: true
follow: false
since?: number
until?: number
tail?: number
} = { stdout: true, stderr: true, follow: false }
if (Number.isFinite(startedAtMs) && startedAtMs > 0) {
const startedAtSec = Math.floor(startedAtMs / 1000)
logsOpts.since = startedAtSec
logsOpts.until = startedAtSec + 300
} else {
logsOpts.tail = 500
}
const buf = (await container.logs(logsOpts)) as unknown as Buffer
return buf.toString('utf8')
} catch {
return ''
}
}

View File

@ -22,6 +22,14 @@ 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 {
classifyOllamaComputeBackend,
diagnoseAmdCpuFallback,
parseOllamaGpuFromLogs,
readOllamaStartupLogs,
resolveExpectedGpuVendor,
type OllamaGpuInfo,
} from '#services/ollama_compute'
import { invalidateAssistantNameCache } from '../../config/inertia.js'
import { invalidateMinRelevanceCache } from '../utils/rag_relevance.js'
import { invalidateResponseStyleCache } from '../utils/response_style.js'
@ -103,6 +111,20 @@ export class SystemService {
return false
}
/**
* The running nomad_ollama container and its inspect data, or null when it is
* not running. The inspect data records the GPU config the install actually
* applied (DeviceRequests, /dev/kfd, the ROCm image), which is what the GPU
* health check keys on.
*/
private async _findRunningOllamaContainer() {
const containers = await this.dockerService.docker.listContainers({ all: false })
const summary = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!summary) return null
const container = this.dockerService.docker.getContainer(summary.Id)
return { container, inspect: await container.inspect() }
}
/**
* Probe Ollama startup logs for the canonical "inference compute" line that records
* which compute backend was selected. This catches silent CPU fallback (e.g. when
@ -114,70 +136,11 @@ export class SystemService {
* - the line has not been emitted (Ollama still starting up)
* - logs show CPU-only operation (no GPU detected)
*/
async getOllamaInferenceComputeFromLogs(): Promise<{
library: 'CUDA' | 'ROCm'
name: string
vramMiB: number
} | null> {
async getOllamaInferenceComputeFromLogs(): Promise<OllamaGpuInfo | null> {
try {
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!ollamaContainer) return null
const container = this.dockerService.docker.getContainer(ollamaContainer.Id)
// Read logs only from the first 5 minutes after container start. The
// "inference compute" line is written once during Ollama's GPU discovery
// phase, within seconds of startup. Using tail:N here is fragile: under
// active embedding workloads we've seen >1000 lines/min, which pushes the
// line past any reasonable tail in minutes. Pinning to the startup window
// is bounded (~5 min of logs regardless of container uptime) and never
// ages out.
//
// Fall back to the previous tail:500 strategy if StartedAt is missing or
// unparseable — we can't construct a since/until window without it, but
// tail:500 is still useful when the container just started and the line
// is still recent.
const inspect = await container.inspect()
const startedAtRaw = inspect?.State?.StartedAt
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : NaN
const hasValidStartedAt = Number.isFinite(startedAtMs) && startedAtMs > 0
const logsOpts: { stdout: true; stderr: true; follow: false; since?: number; until?: number; tail?: number } = {
stdout: true,
stderr: true,
follow: false,
}
if (hasValidStartedAt) {
const startedAtSec = Math.floor(startedAtMs / 1000)
logsOpts.since = startedAtSec
logsOpts.until = startedAtSec + 300 // 5-minute window
} else {
logger.warn(
`[SystemService] nomad_ollama State.StartedAt missing or invalid (${startedAtRaw ?? 'undefined'}); falling back to tail:500 for inference-compute probe`
)
logsOpts.tail = 500
}
const buf = (await container.logs(logsOpts)) as unknown as Buffer
const logs = buf.toString('utf8')
const lines = logs.split('\n').filter((l) => l.includes('msg="inference compute"'))
if (lines.length === 0) return null
const lastLine = lines[lines.length - 1]
const libraryMatch = lastLine.match(/library=(CUDA|ROCm)/)
if (!libraryMatch) return null
const descMatch = lastLine.match(/description="([^"]+)"/)
const totalMatch = lastLine.match(/total="([0-9.]+)\s*GiB"/)
return {
library: libraryMatch[1] as 'CUDA' | 'ROCm',
name:
descMatch?.[1] ||
(libraryMatch[1] === 'CUDA' ? 'NVIDIA GPU' : 'AMD GPU'),
vramMiB: totalMatch ? Math.round(Number.parseFloat(totalMatch[1]) * 1024) : 0,
}
const ollama = await this._findRunningOllamaContainer()
if (!ollama) return null
return parseOllamaGpuFromLogs(await readOllamaStartupLogs(ollama.container, ollama.inspect))
} catch (error) {
logger.warn(
`[SystemService] Failed to probe Ollama logs for inference compute line: ${error instanceof Error ? error.message : error}`
@ -526,91 +489,106 @@ export class SystemService {
// Run the probes when controllers are empty (common inside Docker),
// when lspci gave us bogus discrete-GPU BAR0 values that need replacing,
// or when it named a card it couldn't resolve.
if (
// or when it named a card it couldn't resolve. Only in those cases do
// probe results replace the lspci controllers; a clean lspci reading
// stays as the display source.
const lspciNeedsProbe =
!graphics.controllers ||
graphics.controllers.length === 0 ||
hasLspciBogusDgpuVram ||
hasUnresolvedGpuName
) {
const runtimes = dockerInfo.Runtimes || {}
gpuHealth.hasNvidiaRuntime = 'nvidia' in runtimes
const useProbeControllers = (controllers: typeof graphics.controllers) => {
if (lspciNeedsProbe) graphics.controllers = controllers
}
const toControllers = (gpus: Array<{ vendor: string; model: string; vram: number }>) =>
gpus.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
// AMD doesn't register a Docker runtime. Detection sources, in priority order:
// 1. KV 'gpu.type' (set by DockerService._detectGPUType after first Ollama install)
// 2. Marker file at /app/storage/.nomad-gpu-type (written by install_nomad.sh)
// The marker file matters because the System page should reflect AMD presence
// even before AI Assistant has been installed for the first time.
let savedGpuType: string | null | undefined = await KVStore.getValue('gpu.type') as string | undefined
if (!savedGpuType) {
try {
savedGpuType = (await readFile('/app/storage/.nomad-gpu-type', 'utf8')).trim()
} catch {}
}
const amdEnabledRaw = await KVStore.getValue('ai.amdGpuAcceleration')
const amdAccelerationEnabled = String(amdEnabledRaw) !== 'false'
gpuHealth.hasRocmRuntime = savedGpuType === 'amd' && amdAccelerationEnabled
// Runtime flags are reported whichever path runs below. They used to be set
// only inside the probe branch, so a box whose lspci reading looked clean
// reported hasRocmRuntime: false even with an AMD GPU configured (#1344).
const runtimes = dockerInfo.Runtimes || {}
gpuHealth.hasNvidiaRuntime = 'nvidia' in runtimes
if (gpuHealth.hasNvidiaRuntime || gpuHealth.hasRocmRuntime) {
gpuHealth.gpuVendor = gpuHealth.hasNvidiaRuntime ? 'nvidia' : 'amd'
// AMD doesn't register a Docker runtime. Detection sources, in priority order:
// 1. KV 'gpu.type' (set by DockerService._detectGPUType after first Ollama install)
// 2. Marker file at /app/storage/.nomad-gpu-type (written by install_nomad.sh)
// The marker file matters because the System page should reflect AMD presence
// even before AI Assistant has been installed for the first time.
let savedGpuType: string | null | undefined = await KVStore.getValue('gpu.type') as string | undefined
if (!savedGpuType) {
try {
savedGpuType = (await readFile('/app/storage/.nomad-gpu-type', 'utf8')).trim()
} catch {}
}
const amdEnabledRaw = await KVStore.getValue('ai.amdGpuAcceleration')
const amdAccelerationEnabled = String(amdEnabledRaw) !== 'false'
gpuHealth.hasRocmRuntime = savedGpuType === 'amd' && amdAccelerationEnabled
// When Ollama is expected on a GPU, its startup log decides health whatever
// lspci returned. A clean lspci reading says a GPU exists, not that Ollama
// uses it: an AMD iGPU silently on CPU used to land in the else branch below
// and report 'ok' (#1344).
const ollama = await this._findRunningOllamaContainer().catch(() => null)
const expectedVendor = resolveExpectedGpuVendor({
hasNvidiaRuntime: gpuHealth.hasNvidiaRuntime,
amdConfigured: gpuHealth.hasRocmRuntime,
container: ollama?.inspect ?? null,
})
if (expectedVendor || lspciNeedsProbe) {
if (expectedVendor) {
gpuHealth.gpuVendor = expectedVendor
// Primary probe: Ollama log parsing — works for both vendors and catches silent fallback
const logInfo = await this.getOllamaInferenceComputeFromLogs()
const ollamaLogs = ollama
? await readOllamaStartupLogs(ollama.container, ollama.inspect)
: ''
const logInfo = parseOllamaGpuFromLogs(ollamaLogs)
if (logInfo) {
graphics.controllers = [
useProbeControllers([
{
model: logInfo.name,
vendor: logInfo.library === 'CUDA' ? 'NVIDIA' : 'AMD',
vendor:
logInfo.library === 'CUDA' ? 'NVIDIA' : logInfo.library === 'ROCm' ? 'AMD' : '',
bus: '',
vram: logInfo.vramMiB,
vramDynamic: false,
},
]
])
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (gpuHealth.hasNvidiaRuntime) {
} else if (classifyOllamaComputeBackend(ollamaLogs) === 'cpu') {
// Ollama reported its devices and none was a GPU. Definitive for both
// vendors, so skip the nvidia-smi exec, which cannot run in a CPU-only
// container anyway.
gpuHealth.status = 'passthrough_failed'
logger.warn(
`${expectedVendor === 'amd' ? 'AMD' : 'NVIDIA'} GPU expected but Ollama reported a CPU-only backend at startup`
)
} else if (expectedVendor === 'nvidia') {
// NVIDIA secondary path: nvidia-smi exec preserves prior behavior when
// the log parser hasn't seen a startup line yet (e.g. log rotation,
// very fresh container). Distinguishes "no Ollama container" from
// "container exists but GPU broken".
const nvidiaInfo = await this.getNvidiaSmiInfo()
if (Array.isArray(nvidiaInfo)) {
graphics.controllers = nvidiaInfo.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
useProbeControllers(toControllers(nvidiaInfo))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'ollama_not_installed'
}
} else {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
useProbeControllers(toControllers(externalOllamaGpu))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
gpuHealth.status = 'ollama_not_installed'
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
@ -618,38 +596,41 @@ export class SystemService {
)
}
}
} else {
} else if (!ollama) {
// AMD path: no nvidia-smi equivalent worth running — log parser is authoritative.
// Distinguish "Ollama not running" from "Ollama running but no GPU log line".
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaRunning = containers.some((c) =>
c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)
)
if (!ollamaRunning) {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'ollama_not_installed'
}
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
useProbeControllers(toControllers(externalOllamaGpu))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
'AMD GPU detected but Ollama logs show no ROCm initialization — passthrough or HSA override may have failed'
)
gpuHealth.status = 'ollama_not_installed'
}
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
'AMD GPU detected but Ollama logs show no ROCm initialization — passthrough or HSA override may have failed'
)
}
// Tell the UI whether a reinstall would help or an HSA override is needed.
// Reinstalling an AMD container whose config already matches rebuilds the
// same CPU-bound container, so the banner must not offer that as the fix.
if (gpuHealth.status === 'passthrough_failed' && expectedVendor === 'amd' && ollama) {
Object.assign(
gpuHealth,
diagnoseAmdCpuFallback(
ollama.inspect,
ollamaLogs,
await this.dockerService.getAmdHsaOverride({ quiet: true })
)
)
}
}
} else {
// si.graphics() returned controllers (host install, not Docker) — GPU is working
// 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
}

View File

@ -118,6 +118,19 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string |
}
return null
}
case 'ai.amdHsaOverride': {
// Lands in the Ollama container env as HSA_OVERRIDE_GFX_VERSION, so only
// the forms DockerService._resolveAmdHsaOverride honours are accepted.
// Empty clears it and 'none' disables the gfx-marker default.
const raw = typeof value === 'string' ? value.trim().toLowerCase() : value
if (raw === '' || raw === undefined || raw === null || raw === 'none') {
return null
}
if (typeof raw !== 'string' || !/^\d{1,2}\.\d{1,2}\.\d{1,2}$/.test(raw)) {
return 'GFX override must be a version like "11.0.0", "none", or empty to use the detected default.'
}
return null
}
default:
return null
}

View File

@ -15,6 +15,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
'ai.contextWindow',
'ai.keepAlive',
'ai.responseStyle',
'ai.amdHsaOverride',
'rag.defaultIngestPolicy',
'rag.enabled',
'rag.minRelevance',

View File

@ -0,0 +1,217 @@
import { useState } from 'react'
import Alert from '~/components/Alert'
import StyledModal from '~/components/StyledModal'
import Input from '~/components/inputs/Input'
import { useNotifications } from '~/context/NotificationContext'
import { useModals } from '~/context/ModalContext'
import api from '~/lib/api'
import type { GpuHealthStatus } from '../../types/system'
const DISMISS_KEY = 'nomad:gpu-banner-dismissed'
const HSA_OVERRIDE_PATTERN = /^\d{1,2}\.\d{1,2}\.\d{1,2}$/
/**
* Banner for gpuHealth.status === 'passthrough_failed'.
*
* NVIDIA, and AMD whose container differs from what a reinstall would create, get
* the one-click reinstall. AMD whose container already matches gets an HSA override
* prompt instead: reinstalling it rebuilds the same CPU-bound container (#1344).
*/
export default function GpuPassthroughAlert({
gpuHealth,
assistantName,
className,
}: {
gpuHealth: GpuHealthStatus | undefined
assistantName: string
className?: string
}) {
const { addNotification } = useNotifications()
const { openModal, closeAllModals } = useModals()
const [reinstalling, setReinstalling] = useState(false)
const [dismissed, setDismissed] = useState(() => {
try {
return localStorage.getItem(DISMISS_KEY) === 'true'
} catch {
return false
}
})
if (gpuHealth?.status !== 'passthrough_failed' || dismissed) return null
const isAmd = gpuHealth.gpuVendor === 'amd'
const needsHsaOverride = isAmd && gpuHealth.amdReinstallWouldChange === false
const handleDismiss = () => {
setDismissed(true)
try {
localStorage.setItem(DISMISS_KEY, 'true')
} catch {}
}
const reinstall = async () => {
setReinstalling(true)
try {
const response = await api.forceReinstallService('nomad_ollama')
if (!response || !response.success) {
throw new Error(response?.message || 'Force reinstall failed')
}
addNotification({
message: `${assistantName} is being reinstalled with GPU support. This page will reload shortly.`,
type: 'success',
})
try {
localStorage.removeItem(DISMISS_KEY)
} catch {}
setTimeout(() => window.location.reload(), 5000)
} catch (error) {
addNotification({
message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: 'error',
})
setReinstalling(false)
}
}
const openReinstallModal = () => {
openModal(
<StyledModal
title={`Reinstall ${assistantName}?`}
onConfirm={() => {
closeAllModals()
reinstall()
}}
onCancel={closeAllModals}
open={true}
confirmText="Reinstall"
cancelText="Cancel"
>
<p className="text-text-primary">
This will recreate the {assistantName} container with GPU support enabled. Your
downloaded models will be preserved. The service will be briefly unavailable during
reinstall.
</p>
</StyledModal>,
'gpu-health-force-reinstall-modal'
)
}
const openHsaOverrideModal = () => {
openModal(
<HsaOverrideModal
gpuHealth={gpuHealth}
assistantName={assistantName}
onCancel={closeAllModals}
onSaved={() => {
closeAllModals()
reinstall()
}}
/>,
'gpu-health-hsa-override-modal'
)
}
const vendorName = isAmd ? 'an AMD' : 'an NVIDIA'
const message = needsHsaOverride
? `Your system has an AMD GPU, but ${assistantName} is running on CPU only. Reinstalling won't fix this: the GPU${gpuHealth.amdGfxTarget ? ` (${gpuHealth.amdGfxTarget})` : ''} isn't on ROCm's supported list and needs a GFX version override.`
: `Your system has ${vendorName} GPU, but ${assistantName} can't access it. AI is running on CPU only, which is significantly slower.`
return (
<Alert
type="warning"
variant="bordered"
title={`GPU Not Accessible to ${assistantName}`}
message={message}
className={className}
dismissible={true}
onDismiss={handleDismiss}
buttonProps={{
children: needsHsaOverride ? 'Fix: Set GFX Override' : `Fix: Reinstall ${assistantName}`,
icon: needsHsaOverride ? 'IconTool' : 'IconRefresh',
variant: 'action',
size: 'sm',
onClick: needsHsaOverride ? openHsaOverrideModal : openReinstallModal,
loading: reinstalling,
disabled: reinstalling,
}}
/>
)
}
function HsaOverrideModal({
gpuHealth,
assistantName,
onCancel,
onSaved,
}: {
gpuHealth: GpuHealthStatus
assistantName: string
onCancel: () => void
onSaved: () => void
}) {
const { addNotification } = useNotifications()
const [value, setValue] = useState(gpuHealth.suggestedHsaOverride ?? '')
const [saving, setSaving] = useState(false)
const valid = HSA_OVERRIDE_PATTERN.test(value.trim())
const save = async () => {
setSaving(true)
try {
const response = await api.updateSetting('ai.amdHsaOverride', value.trim())
// catchInternal already notified on a request failure and returned undefined.
if (!response) {
setSaving(false)
return
}
if (!response.success) throw new Error(response.message)
onSaved()
} catch (error) {
addNotification({
message: `Failed to save the GFX override: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: 'error',
})
setSaving(false)
}
}
return (
<StyledModal
title="Set AMD GFX Override"
onConfirm={save}
onCancel={onCancel}
open={true}
confirmText="Save and Reinstall"
cancelText="Cancel"
confirmLoading={saving}
confirmDisabled={!valid || saving}
>
<div className="space-y-4 text-text-primary">
<p>
ROCm ships GPU kernels for a fixed list of AMD chips. Integrated Radeon GPUs outside
that list run on CPU unless ROCm treats them as a supported chip, set through
HSA_OVERRIDE_GFX_VERSION.
</p>
<ul className="list-disc pl-5 space-y-1">
<li>Radeon 780M / 760M (gfx1103): 11.0.0</li>
<li>Radeon 680M / 660M and other RDNA 2 iGPUs (gfx1031 to gfx1036): 10.3.0</li>
</ul>
<p className="text-sm text-text-secondary">
Detected GPU: {gpuHealth.amdGfxTarget ?? 'not reported by Ollama'}. Current override:{' '}
{gpuHealth.currentHsaOverride ?? 'none'}.
{gpuHealth.suggestedHsaOverride
? ` Recommended for this GPU: ${gpuHealth.suggestedHsaOverride}.`
: ''}
</p>
<Input
name="amdHsaOverride"
label="GFX version override"
placeholder="11.0.0"
value={value}
onChange={(e) => setValue(e.target.value)}
error={value.trim() !== '' && !valid}
helpText={`Saving recreates the ${assistantName} container with this override. Downloaded models are preserved.`}
/>
</div>
</StyledModal>
)
}

View File

@ -23,6 +23,7 @@ import { formatBytes } from '~/lib/util'
import useDebounce from '~/hooks/useDebounce'
import ActiveModelDownloads from '~/components/ActiveModelDownloads'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import GpuPassthroughAlert from '~/components/GpuPassthroughAlert'
export default function ModelsPage(props: {
models: {
@ -41,62 +42,6 @@ export default function ModelsPage(props: {
const { data: systemInfo } = useSystemInfo({})
const queryClient = useQueryClient()
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {
try {
return localStorage.getItem('nomad:gpu-banner-dismissed') === 'true'
} catch {
return false
}
})
const [reinstalling, setReinstalling] = useState(false)
const handleDismissGpuBanner = () => {
setGpuBannerDismissed(true)
try {
localStorage.setItem('nomad:gpu-banner-dismissed', 'true')
} catch {}
}
const handleForceReinstallOllama = () => {
openModal(
<StyledModal
title="Reinstall AI Assistant?"
onConfirm={async () => {
closeAllModals()
setReinstalling(true)
try {
const response = await api.forceReinstallService('nomad_ollama')
if (!response || !response.success) {
throw new Error(response?.message || 'Force reinstall failed')
}
addNotification({
message: `${aiAssistantName} is being reinstalled with GPU support. This page will reload shortly.`,
type: 'success',
})
try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}
setTimeout(() => window.location.reload(), 5000)
} catch (error) {
addNotification({
message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: 'error',
})
setReinstalling(false)
}
}}
onCancel={closeAllModals}
open={true}
confirmText="Reinstall"
cancelText="Cancel"
>
<p className="text-text-primary">
This will recreate the {aiAssistantName} container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall.
</p>
</StyledModal>,
'gpu-health-force-reinstall-modal'
)
}
const [chatSuggestionsEnabled, setChatSuggestionsEnabled] = useState(
props.models.settings.chatSuggestionsEnabled
)
@ -345,24 +290,11 @@ export default function ModelsPage(props: {
className="!mt-6"
/>
)}
{isInstalled && systemInfo?.gpuHealth?.status === 'passthrough_failed' && !gpuBannerDismissed && (
<Alert
type="warning"
variant="bordered"
title="GPU Not Accessible"
message={`Your system has ${systemInfo?.gpuHealth?.gpuVendor === 'amd' ? 'an AMD' : 'an NVIDIA'} GPU, but ${aiAssistantName} can't access it. AI is running on CPU only, which is significantly slower.`}
{isInstalled && (
<GpuPassthroughAlert
gpuHealth={systemInfo?.gpuHealth}
assistantName={aiAssistantName}
className="!mt-6"
dismissible={true}
onDismiss={handleDismissGpuBanner}
buttonProps={{
children: `Fix: Reinstall ${aiAssistantName}`,
icon: 'IconRefresh',
variant: 'action',
size: 'sm',
onClick: handleForceReinstallOllama,
loading: reinstalling,
disabled: reinstalling,
}}
/>
)}

View File

@ -1,4 +1,3 @@
import { useState } from 'react'
import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { SystemInformationResponse } from '../../../types/system'
@ -8,11 +7,8 @@ import CircularGauge from '~/components/systeminfo/CircularGauge'
import HorizontalBarChart from '~/components/HorizontalBarChart'
import InfoCard from '~/components/systeminfo/InfoCard'
import Alert from '~/components/Alert'
import StyledModal from '~/components/StyledModal'
import GpuPassthroughAlert from '~/components/GpuPassthroughAlert'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import { useNotifications } from '~/context/NotificationContext'
import { useModals } from '~/context/ModalContext'
import api from '~/lib/api'
import StatusCard from '~/components/systeminfo/StatusCard'
import { IconCpu, IconDatabase, IconServer, IconDeviceDesktop, IconComponents } from '@tabler/icons-react'
@ -22,65 +18,6 @@ export default function SettingsPage(props: {
const { data: info } = useSystemInfo({
initialData: props.system.info,
})
const { addNotification } = useNotifications()
const { openModal, closeAllModals } = useModals()
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {
try {
return localStorage.getItem('nomad:gpu-banner-dismissed') === 'true'
} catch {
return false
}
})
const [reinstalling, setReinstalling] = useState(false)
const handleDismissGpuBanner = () => {
setGpuBannerDismissed(true)
try {
localStorage.setItem('nomad:gpu-banner-dismissed', 'true')
} catch {}
}
const handleForceReinstallOllama = () => {
openModal(
<StyledModal
title="Reinstall AI Assistant?"
onConfirm={async () => {
closeAllModals()
setReinstalling(true)
try {
const response = await api.forceReinstallService('nomad_ollama')
if (!response || !response.success) {
throw new Error(response?.message || 'Force reinstall failed')
}
addNotification({
message: 'AI Assistant is being reinstalled with GPU support. This page will reload shortly.',
type: 'success',
})
try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}
setTimeout(() => window.location.reload(), 5000)
} catch (error) {
addNotification({
message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: 'error',
})
setReinstalling(false)
}
}}
onCancel={closeAllModals}
open={true}
confirmText="Reinstall"
cancelText="Cancel"
>
<p className="text-text-primary">
This will recreate the AI Assistant container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall.
</p>
</StyledModal>,
'gpu-health-force-reinstall-modal'
)
}
// Use (total - available) to reflect actual memory pressure.
// mem.used includes reclaimable buff/cache on Linux, which inflates the number.
@ -203,25 +140,9 @@ export default function SettingsPage(props: {
},
]}
/>
{info?.gpuHealth?.status === 'passthrough_failed' && !gpuBannerDismissed && (
{info?.gpuHealth?.status === 'passthrough_failed' && (
<div className="lg:col-span-2">
<Alert
type="warning"
variant="bordered"
title="GPU Not Accessible to AI Assistant"
message={`Your system has ${info?.gpuHealth?.gpuVendor === 'amd' ? 'an AMD' : 'an NVIDIA'} GPU, but the AI Assistant can't access it. AI is running on CPU only, which is significantly slower.`}
dismissible={true}
onDismiss={handleDismissGpuBanner}
buttonProps={{
children: 'Fix: Reinstall AI Assistant',
icon: 'IconRefresh',
variant: 'action',
size: 'sm',
onClick: handleForceReinstallOllama,
loading: reinstalling,
disabled: reinstalling,
}}
/>
<GpuPassthroughAlert gpuHealth={info.gpuHealth} assistantName="AI Assistant" />
</div>
)}
{info?.graphics?.controllers && info.graphics.controllers.length > 0 && (

View File

@ -1,14 +1,14 @@
import logger from '@adonisjs/core/services/logger'
import type { ApplicationService } from '@adonisjs/core/types'
import type Docker from 'dockerode'
/**
* Auto-remediates NVIDIA GPU passthrough loss after admin / host restart.
* Detects Ollama's silent CPU fallback after admin / host restart, and auto-remediates
* it on NVIDIA.
*
* Detects the condition from Ollama's own "inference compute" startup log line:
* if the NVIDIA container runtime is registered with Docker but Ollama still
* loaded a CPU-only backend, passthrough is broken and nomad_ollama is recreated.
* This single signal covers two failure modes:
* if Ollama is expected on a GPU (see resolveExpectedGpuVendor) but loaded a CPU-only
* backend, passthrough is broken. On NVIDIA, nomad_ollama is recreated.
* This single signal covers two NVIDIA failure modes:
* 1. nomad_ollama was created CPU-only because the nvidia runtime was registered
* only AFTER the AI Assistant was first installed (DockerService attaches a
* GPU DeviceRequest only when 'nvidia' is in docker.info().Runtimes at install
@ -20,16 +20,19 @@ import type Docker from 'dockerode'
* PR #208 added detection + a one-click "Fix: Reinstall AI Assistant" banner; this
* provider performs that click automatically on admin boot.
*
* AMD is detect-only. The usual cause there is a missing HSA override for an iGPU
* outside ROCm's allowlist (e.g. gfx1103), which a reinstall rebuilds unchanged. The
* provider logs whether a reinstall would change the container and leaves the
* reinstall to the user; SystemService surfaces the same state as passthrough_failed.
*
* Guards:
* - NVIDIA-only. AMD passthrough_failed has a different fix path (HSA override
* handling in PR #804) and is left to the user.
* - One-shot per admin boot. The provider runs once on startup; if the recreate
* itself fails the banner remains as a fallback.
* - Cooldown: will not auto-reinstall more than once within
* AUTO_REMEDIATE_COOLDOWN_MS, to avoid a reinstall loop when the GPU cannot be
* accelerated (e.g. an architecture Ollama has no kernels for).
* - Opt-out via KV `ai.autoFixGpuPassthrough = false`.
* - Skipped entirely when no NVIDIA runtime is registered with Docker.
* - Skipped entirely when nomad_ollama is not expected on a GPU.
*/
export default class GpuPassthroughRemediationProvider {
constructor(protected app: ApplicationService) {}
@ -55,14 +58,6 @@ export default class GpuPassthroughRemediationProvider {
const docker = new Docker({ socketPath: '/var/run/docker.sock' })
const dockerInfo = await docker.info()
const runtimes = dockerInfo.Runtimes || {}
const hasNvidiaRuntime = 'nvidia' in runtimes
if (!hasNvidiaRuntime) {
logger.info(
'[GpuPassthroughRemediationProvider] No NVIDIA runtime registered — skipping.'
)
return
}
const containers = await docker.listContainers({ all: false })
const ollama = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
@ -74,20 +69,43 @@ export default class GpuPassthroughRemediationProvider {
return
}
const container = docker.getContainer(ollama.Id)
const inspect = await container.inspect()
const {
classifyOllamaComputeBackend,
diffAmdOllamaConfig,
readOllamaStartupLogs,
resolveExpectedGpuVendor,
} = await import('#services/ollama_compute')
// The container's own GPU config decides the vendor. The KV AMD type only
// matters when no container exists, which the return above rules out.
const vendor = resolveExpectedGpuVendor({
hasNvidiaRuntime: 'nvidia' in runtimes,
amdConfigured: false,
container: inspect,
})
if (!vendor) {
logger.info(
'[GpuPassthroughRemediationProvider] nomad_ollama is not configured for a GPU — skipping.'
)
return
}
// Probe: read Ollama's own "inference compute" startup line from its logs.
// This is the ground truth for whether Ollama loaded a GPU backend, and it
// catches every failure mode:
// - nomad_ollama created CPU-only because the nvidia runtime was registered
// only AFTER first install (Ollama logs library=cpu), and
// - DeviceRequests present but the toolkit binding tore after a recreate,
// where Ollama silently falls back to CPU (also library=cpu).
// where Ollama silently falls back to CPU (also library=cpu), and
// - a ROCm container on an AMD GPU that ROCm could not initialize.
// The previous implementation probed by exec'ing `nvidia-smi` inside the
// container, but a CPU-only container does not ship nvidia-smi, so the exec
// returned an error string that the alphabetic-output check mistook for
// "healthy" — a false negative that suppressed all auto-remediation.
const container = docker.getContainer(ollama.Id)
const { classifyOllamaComputeBackend } = await import('#services/ollama_compute')
const backend = classifyOllamaComputeBackend(await readOllamaStartupLogs(container))
const backend = classifyOllamaComputeBackend(await readOllamaStartupLogs(container, inspect))
if (backend === 'gpu') {
logger.info(
@ -103,6 +121,43 @@ export default class GpuPassthroughRemediationProvider {
return
}
if (vendor === 'amd') {
const amdEnabledRaw = await KVStore.getValue('ai.amdGpuAcceleration')
if (String(amdEnabledRaw) === 'false') {
logger.info(
'[GpuPassthroughRemediationProvider] Ollama is on CPU and AMD acceleration is disabled via ai.amdGpuAcceleration — skipping.'
)
return
}
const hsaOverride = await new DockerService().getAmdHsaOverride()
const drift = diffAmdOllamaConfig(inspect, hsaOverride)
if (drift.length > 0) {
logger.warn(
'[GpuPassthroughRemediationProvider] AMD GPU configured but Ollama fell back to CPU. ' +
`A reinstall would change the container (${drift.join('; ')}). ` +
'Use "Fix: Reinstall AI Assistant" to apply it.'
)
} else {
logger.warn(
'[GpuPassthroughRemediationProvider] AMD GPU configured but Ollama fell back to CPU, and a ' +
'reinstall would rebuild the same container. If this is an iGPU outside the ROCm ' +
'allowlist, use "Fix: Set GFX Override" in Settings (e.g. 11.0.0 for a 780M/gfx1103).'
)
}
return
}
// NVIDIA DeviceRequests without a registered runtime: a reinstall would come up
// CPU-only, since DockerService attaches the GPU only when the runtime is present.
if (!('nvidia' in runtimes)) {
logger.warn(
'[GpuPassthroughRemediationProvider] nomad_ollama requests an NVIDIA GPU but no NVIDIA runtime ' +
'is registered with Docker. Reinstall the NVIDIA Container Toolkit; skipping auto-reinstall.'
)
return
}
// backend === 'cpu': an NVIDIA runtime is registered but Ollama still runs on
// CPU. Cooldown: don't auto-reinstall more than once within the window. A
// reinstall that fails to move Ollama onto the GPU (e.g. an architecture
@ -151,39 +206,3 @@ export default class GpuPassthroughRemediationProvider {
* provider. Bounds a reinstall loop when the GPU cannot be accelerated.
*/
const AUTO_REMEDIATE_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
/**
* Read nomad_ollama's logs from its startup window (the five minutes after it
* booted), where the "inference compute" line is emitted. Mirrors the windowing
* in SystemService.getOllamaInferenceComputeFromLogs. Returns '' on any error so
* the caller classifies the backend as 'unknown' rather than throwing.
*/
async function readOllamaStartupLogs(container: Docker.Container): Promise<string> {
try {
const inspect = await container.inspect()
const startedAtRaw = inspect?.State?.StartedAt
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : NaN
const logsOpts: {
stdout: true
stderr: true
follow: false
since?: number
until?: number
tail?: number
} = { stdout: true, stderr: true, follow: false }
if (Number.isFinite(startedAtMs) && startedAtMs > 0) {
const startedAtSec = Math.floor(startedAtMs / 1000)
logsOpts.since = startedAtSec
logsOpts.until = startedAtSec + 300
} else {
logsOpts.tail = 500
}
const buf = (await container.logs(logsOpts)) as unknown as Buffer
return buf.toString('utf8')
} catch {
return ''
}
}

View File

@ -1,7 +1,14 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { classifyOllamaComputeBackend } from '../../app/services/ollama_compute.js'
import {
classifyOllamaComputeBackend,
diagnoseAmdCpuFallback,
diffAmdOllamaConfig,
parseAmdGfxTargetFromLogs,
parseOllamaGpuFromLogs,
resolveExpectedGpuVendor,
} from '../../app/services/ollama_compute.js'
test('CUDA inference line is classified as gpu', () => {
const log =
@ -43,3 +50,183 @@ test('ROCm and Vulkan are gpu backends', () => {
'gpu'
)
})
test('parseOllamaGpuFromLogs takes the GPU line even when the CPU line comes last', () => {
const log =
'msg="inference compute" id=0 library=ROCm compute=gfx1150 name=ROCm0 description="AMD Radeon 890M" total="16.0 GiB"\n' +
'msg="inference compute" id=cpu library=cpu name=cpu'
assert.deepEqual(parseOllamaGpuFromLogs(log), {
library: 'ROCm',
name: 'AMD Radeon 890M',
vramMiB: 16384,
})
})
test('parseOllamaGpuFromLogs recognizes Vulkan and falls back to a generic name', () => {
assert.deepEqual(parseOllamaGpuFromLogs('msg="inference compute" id=0 library=Vulkan name=V0'), {
library: 'Vulkan',
name: 'GPU',
vramMiB: 0,
})
})
test('parseOllamaGpuFromLogs returns null for a CPU-only log', () => {
assert.equal(parseOllamaGpuFromLogs('msg="inference compute" id=cpu library=cpu name=cpu'), null)
})
const ROCM_CONTAINER = {
Config: {
Image: 'ollama/ollama:rocm',
Env: ['OLLAMA_NO_CLOUD=1', 'HSA_OVERRIDE_GFX_VERSION=11.0.0', 'OLLAMA_IGPU_ENABLE=1'],
},
HostConfig: {
Devices: [{ PathOnHost: '/dev/kfd' }, { PathOnHost: '/dev/dri' }],
},
}
const CPU_CONTAINER = {
Config: { Image: 'ollama/ollama:0.18.2', Env: ['OLLAMA_NO_CLOUD=1'] },
HostConfig: {},
}
test('a ROCm container is expected on AMD with no Docker runtime registered (#1344)', () => {
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: false, amdConfigured: false, container: ROCM_CONTAINER }),
'amd'
)
})
test('a /dev/kfd device alone marks the container as AMD', () => {
const container = {
Config: { Image: 'ollama/ollama:0.18.2' },
HostConfig: { Devices: [{ PathOnHost: '/dev/kfd' }] },
}
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: false, amdConfigured: false, container }),
'amd'
)
})
test('a versioned ROCm tag marks the container as AMD, a rocm repo path does not', () => {
const tagged = { Config: { Image: 'ollama/ollama:0.12.0-rocm' }, HostConfig: {} }
const repoOnly = { Config: { Image: 'rocm/ollama:latest' }, HostConfig: {} }
const input = { hasNvidiaRuntime: false, amdConfigured: false }
assert.equal(resolveExpectedGpuVendor({ ...input, container: tagged }), 'amd')
assert.equal(resolveExpectedGpuVendor({ ...input, container: repoOnly }), null)
})
test('/dev/dri alone is not treated as AMD', () => {
const container = {
Config: { Image: 'ollama/ollama:0.18.2' },
HostConfig: { Devices: [{ PathOnHost: '/dev/dri' }] },
}
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: false, amdConfigured: false, container }),
null
)
})
test('an nvidia DeviceRequest is expected on NVIDIA even without the runtime', () => {
const container = {
Config: { Image: 'ollama/ollama:0.18.2' },
HostConfig: { DeviceRequests: [{ Driver: 'nvidia' }] },
}
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: false, amdConfigured: false, container }),
'nvidia'
)
})
test('a CPU container is expected on NVIDIA when the runtime was registered after install', () => {
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: true, amdConfigured: false, container: CPU_CONTAINER }),
'nvidia'
)
})
test('a CPU container on an AMD box is trusted as deliberate (#1232 fallback)', () => {
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: false, amdConfigured: true, container: CPU_CONTAINER }),
null
)
})
test('the AMD GPU type counts when no container exists', () => {
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: false, amdConfigured: true, container: null }),
'amd'
)
assert.equal(
resolveExpectedGpuVendor({ hasNvidiaRuntime: false, amdConfigured: false, container: null }),
null
)
})
test('diffAmdOllamaConfig is empty when a reinstall would rebuild the same container', () => {
assert.deepEqual(diffAmdOllamaConfig(ROCM_CONTAINER, '11.0.0'), [])
})
test('diffAmdOllamaConfig reports an HSA override the container is missing', () => {
const container = {
...ROCM_CONTAINER,
Config: { ...ROCM_CONTAINER.Config, Env: ['OLLAMA_NO_CLOUD=1', 'OLLAMA_IGPU_ENABLE=1'] },
}
assert.deepEqual(diffAmdOllamaConfig(container, '11.0.0'), [
'HSA_OVERRIDE_GFX_VERSION is unset, expected 11.0.0',
])
})
test('diffAmdOllamaConfig reports a stale override when none is wanted', () => {
assert.deepEqual(diffAmdOllamaConfig(ROCM_CONTAINER, null), [
'HSA_OVERRIDE_GFX_VERSION is 11.0.0, expected unset',
])
})
test('diffAmdOllamaConfig reports missing image, devices and iGPU flag', () => {
assert.deepEqual(diffAmdOllamaConfig(CPU_CONTAINER, null), [
'image is ollama/ollama:0.18.2, not the ROCm build',
'/dev/kfd is not passed through',
'/dev/dri is not passed through',
'OLLAMA_IGPU_ENABLE=1 is not set',
])
})
const UNSUPPORTED_GFX1103_LOG =
'level=WARN source=amd_linux.go msg="amdgpu is not supported (supported types:[gfx1030 gfx1100])" gpu_type=gfx1103 gpu=0 library=/usr/lib/ollama/rocm\n' +
'msg="inference compute" id=cpu library=cpu name=cpu'
test('parseAmdGfxTargetFromLogs reads the gfx target Ollama rejected', () => {
assert.equal(parseAmdGfxTargetFromLogs(UNSUPPORTED_GFX1103_LOG), 'gfx1103')
assert.equal(parseAmdGfxTargetFromLogs('msg="inference compute" id=cpu library=cpu'), null)
})
test('diagnoseAmdCpuFallback on the #1344 box: matching config, override needed', () => {
const container = {
...ROCM_CONTAINER,
Config: { ...ROCM_CONTAINER.Config, Env: ['OLLAMA_NO_CLOUD=1', 'OLLAMA_IGPU_ENABLE=1'] },
}
// No gfx marker and no KV override, so a reinstall resolves no override either.
assert.deepEqual(diagnoseAmdCpuFallback(container, UNSUPPORTED_GFX1103_LOG, null), {
amdReinstallWouldChange: false,
amdGfxTarget: 'gfx1103',
suggestedHsaOverride: '11.0.0',
})
})
test('diagnoseAmdCpuFallback flags a reinstall when a newly set override is missing', () => {
const container = {
...ROCM_CONTAINER,
Config: { ...ROCM_CONTAINER.Config, Env: ['OLLAMA_NO_CLOUD=1', 'OLLAMA_IGPU_ENABLE=1'] },
}
assert.equal(
diagnoseAmdCpuFallback(container, UNSUPPORTED_GFX1103_LOG, '11.0.0').amdReinstallWouldChange,
true
)
})
test('diagnoseAmdCpuFallback reports the current override and omits unknown fields', () => {
assert.deepEqual(diagnoseAmdCpuFallback(ROCM_CONTAINER, '', '11.0.0'), {
amdReinstallWouldChange: false,
currentHsaOverride: '11.0.0',
})
})

View File

@ -0,0 +1,16 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { validateSettingValue } from '../../app/validators/settings.js'
test('ai.amdHsaOverride accepts a gfx version, none, or empty', () => {
for (const value of ['11.0.0', '10.3.0', ' 11.0.0 ', 'none', 'NONE', '', null, undefined]) {
assert.equal(validateSettingValue('ai.amdHsaOverride', value), null, String(value))
}
})
test('ai.amdHsaOverride rejects anything that is not a version', () => {
for (const value of ['11', '11.0', 'gfx1103', '11.0.0; rm -rf /', '11.0.0\nFOO=1', 11, true]) {
assert.notEqual(validateSettingValue('ai.amdHsaOverride', value), null, String(value))
}
})

View File

@ -6,6 +6,18 @@ export type GpuHealthStatus = {
hasRocmRuntime: boolean
ollamaGpuAccessible: boolean
gpuVendor?: 'nvidia' | 'amd'
/**
* AMD passthrough_failed only. Whether a reinstall would change the running
* container. When false, a reinstall rebuilds the same CPU-bound container and
* the fix is an HSA override (ai.amdHsaOverride).
*/
amdReinstallWouldChange?: boolean
/** gfx target Ollama reported for an unsupported AMD GPU, e.g. 'gfx1103'. */
amdGfxTarget?: string
/** HSA_OVERRIDE_GFX_VERSION the running container has, if any. */
currentHsaOverride?: string
/** Override the gfx mapping recommends for amdGfxTarget, if it knows one. */
suggestedHsaOverride?: string
}
export type SystemInformationResponse = {