From c39addc7ad238f3be86807fbd4622c2d0d44f52c Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 11 May 2026 11:41:31 +0000 Subject: [PATCH 1/2] fix(onboard): surface per-GPU breakdown on mixed-model hosts Fixes #2669 Signed-off-by: Tinson Lai --- src/lib/inference/nim.test.ts | 219 ++++++++++++++++++++++++++++++++++ src/lib/inference/nim.ts | 79 ++++++++++++ src/lib/onboard.ts | 14 +-- 3 files changed, 302 insertions(+), 10 deletions(-) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index 3fbf9d76b78..5597485942c 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -307,6 +307,83 @@ describe("nim", () => { } }); + // Regression #2669: the previous fix added `name` only for homogeneous + // hosts, so mixed-GPU machines (e.g. RTX PRO 6000 + GB300 on the QA + // verification host) lost the model info entirely. We now also surface + // the per-GPU breakdown via `gpus` so the preflight line can render it. + it("populates the gpus breakdown on mixed-model hosts (regression #2669)", () => { + 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 RTX PRO 6000 Blackwell Max-Q, 97887\nNVIDIA GB300, 256703\n"; + } + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const result = nimModule.detectGpu(); + expect(result?.name).toBeUndefined(); + expect(result?.gpus).toEqual([ + { name: "NVIDIA RTX PRO 6000 Blackwell Max-Q", memoryMB: 97887 }, + { name: "NVIDIA GB300", memoryMB: 256703 }, + ]); + } finally { + restore(); + } + }); + + it("populates the gpus breakdown on homogeneous hosts too", () => { + 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 H100 80GB HBM3, 81920\nNVIDIA H100 80GB HBM3, 81920\n"; + } + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const result = nimModule.detectGpu(); + expect(result?.gpus).toEqual([ + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + ]); + } finally { + restore(); + } + }); + + it("populates the gpus breakdown on the unified-memory fallback path (#2669 GB10)", () => { + // Spark / Jetson don't have memory.total per GPU — host RAM is split + // evenly across the named devices. Used by the original GB10 reporter. + const runCapture = vi.fn((cmd: string | string[]) => { + if (!Array.isArray(cmd)) throw new Error("expected argv array"); + if (cmd.some((a: string) => a.includes("memory.total"))) return ""; + if (cmd.some((a: string) => a.includes("query-gpu=name"))) { + return "NVIDIA GB10\n"; + } + if (cmd[0] === "free" && cmd[1] === "-m") { + return " total used free\nMem: 131072 10240 90000\nSwap: 0 0 0"; + } + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const result = nimModule.detectGpu(); + expect(result?.gpus).toEqual([{ name: "NVIDIA GB10", memoryMB: 131072 }]); + } finally { + restore(); + } + }); + it("detects GB10 unified-memory GPUs as Spark-capable NVIDIA devices", () => { const runCapture = vi.fn((cmd: string | string[]) => { if (!Array.isArray(cmd)) throw new Error("expected argv array"); @@ -388,6 +465,148 @@ describe("nim", () => { }); }); + describe("groupGpusByName", () => { + it("groups identical names and sums their memory", () => { + expect( + nim.groupGpusByName([ + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + ]), + ).toEqual([{ name: "NVIDIA H100 80GB HBM3", count: 2, memoryMB: 163840 }]); + }); + + it("preserves first-appearance order across distinct names", () => { + expect( + nim.groupGpusByName([ + { name: "NVIDIA RTX PRO 6000 Blackwell Max-Q", memoryMB: 97887 }, + { name: "NVIDIA GB300", memoryMB: 256703 }, + ]).map((g: { name: string }) => g.name), + ).toEqual(["NVIDIA RTX PRO 6000 Blackwell Max-Q", "NVIDIA GB300"]); + }); + + it("groups duplicates and singletons together (2x H100 + 1x A100)", () => { + expect( + nim.groupGpusByName([ + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA A100 40GB", memoryMB: 40960 }, + ]), + ).toEqual([ + { name: "NVIDIA H100 80GB HBM3", count: 2, memoryMB: 163840 }, + { name: "NVIDIA A100 40GB", count: 1, memoryMB: 40960 }, + ]); + }); + + it("normalizes internal whitespace before comparing names", () => { + // Defensive: nvidia-smi shouldn't return double spaces, but if a driver + // ever does, we shouldn't split what is logically the same model. + expect( + nim.groupGpusByName([ + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + ]), + ).toEqual([{ name: "NVIDIA H100 80GB HBM3", count: 2, memoryMB: 163840 }]); + }); + + it("drops rows with blank names", () => { + expect( + nim.groupGpusByName([ + { name: "", memoryMB: 81920 }, + { name: " ", memoryMB: 81920 }, + { name: "NVIDIA GB300", memoryMB: 256703 }, + ]), + ).toEqual([{ name: "NVIDIA GB300", count: 1, memoryMB: 256703 }]); + }); + }); + + describe("formatNvidiaGpuPreflightLines", () => { + it("renders single GPU as a compact one-liner", () => { + const lines = nim.formatNvidiaGpuPreflightLines({ + type: "nvidia", + name: "NVIDIA GB300", + gpus: [{ name: "NVIDIA GB300", memoryMB: 284208 }], + count: 1, + totalMemoryMB: 284208, + perGpuMB: 284208, + nimCapable: true, + }); + expect(lines).toEqual(["NVIDIA GPU detected (NVIDIA GB300, 284208 MB)"]); + }); + + it("renders N homogeneous GPUs as `Nx ` in the compact form", () => { + const lines = nim.formatNvidiaGpuPreflightLines({ + type: "nvidia", + name: "NVIDIA H100 80GB HBM3", + gpus: [ + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + ], + count: 2, + totalMemoryMB: 163840, + perGpuMB: 81920, + nimCapable: true, + }); + expect(lines).toEqual([ + "NVIDIA GPU detected (2x NVIDIA H100 80GB HBM3, 163840 MB)", + ]); + }); + + // Regression #2669: this is the case the previous fix missed entirely. + it("renders mixed-model 1+1 with breakdown and no `Nx ` prefix", () => { + const lines = nim.formatNvidiaGpuPreflightLines({ + type: "nvidia", + gpus: [ + { name: "NVIDIA RTX PRO 6000 Blackwell Max-Q", memoryMB: 97887 }, + { name: "NVIDIA GB300", memoryMB: 256703 }, + ], + count: 2, + totalMemoryMB: 354590, + perGpuMB: 97887, + nimCapable: true, + }); + expect(lines).toEqual([ + "NVIDIA GPU detected: 2 GPUs, 354590 MB VRAM", + " - NVIDIA RTX PRO 6000 Blackwell Max-Q (97887 MB)", + " - NVIDIA GB300 (256703 MB)", + ]); + }); + + it("renders mixed-model with duplicates using `Nx ` prefix across all groups", () => { + const lines = nim.formatNvidiaGpuPreflightLines({ + type: "nvidia", + gpus: [ + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA A100 40GB", memoryMB: 40960 }, + ], + count: 3, + totalMemoryMB: 204800, + perGpuMB: 81920, + nimCapable: true, + }); + expect(lines).toEqual([ + "NVIDIA GPU detected: 3 GPUs, 204800 MB VRAM", + " - 2x NVIDIA H100 80GB HBM3 (163840 MB)", + " - 1x NVIDIA A100 40GB (40960 MB)", + ]); + }); + + it("falls back to count-only when every parsed row had a blank name", () => { + const lines = nim.formatNvidiaGpuPreflightLines({ + type: "nvidia", + gpus: [ + { name: "", memoryMB: 81920 }, + { name: "", memoryMB: 81920 }, + ], + count: 2, + totalMemoryMB: 163840, + perGpuMB: 81920, + nimCapable: true, + }); + expect(lines).toEqual(["NVIDIA GPU detected: 2 GPU(s), 163840 MB VRAM"]); + }); + }); + describe("nimStatus", () => { it("returns not running for nonexistent container", () => { const st = nim.nimStatus("nonexistent-test-xyz"); diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index 429fde23f36..066dd3829f7 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -31,9 +31,24 @@ export interface NimModel { export type NvidiaPlatform = "spark" | "station" | "linux"; +export interface NimGpu { + name: string; + memoryMB: number; +} + +export interface GpuGroup { + name: string; + count: number; + memoryMB: number; +} + export interface GpuDetection { type: string; name?: string; + // Per-GPU breakdown when available (primary nvidia-smi --query-gpu path). + // Always populated alongside `name` for NVIDIA; absent on the count-only + // fallback when every parsed row had a blank name. See #2669. + gpus?: NimGpu[]; count: number; totalMemoryMB: number; perGpuMB: number; @@ -44,6 +59,65 @@ export interface GpuDetection { platform?: NvidiaPlatform; } +// Group GPUs by their nvidia-smi model name, preserving first-appearance order. +// Names are whitespace-normalized; rows with blank names are dropped (the caller +// falls through to the count-only display in that case). We deliberately do not +// include memoryMB in the group key — within a single host, nvidia-smi reports +// stable name strings that already disambiguate memory variants (e.g. +// "H100 80GB HBM3" vs "H100 40GB"). The only theoretical collision is ECC-mode +// reporting variance on otherwise-identical cards, which is rare enough that +// splitting the group would create more confusion than it solves. +export function groupGpusByName(gpus: readonly NimGpu[]): GpuGroup[] { + const groups: GpuGroup[] = []; + for (const g of gpus) { + const name = g.name.replace(/\s+/g, " ").trim(); + if (!name) continue; + const existing = groups.find((grp) => grp.name === name); + if (existing) { + existing.count += 1; + existing.memoryMB += g.memoryMB; + } else { + groups.push({ name, count: 1, memoryMB: g.memoryMB }); + } + } + return groups; +} + +// Render the preflight summary for an NVIDIA GPU detection. Returns one +// or more lines that the caller prefixes with ` ✓ ` / prints directly. +// +// - Homogeneous (1 GPU or N of the same model) → single compact line: +// NVIDIA GPU detected (, MB) +// NVIDIA GPU detected (Nx , MB) +// - Mixed model → aggregate header + indented per-group breakdown: +// NVIDIA GPU detected: 2 GPUs, 354590 MB VRAM +// - NVIDIA RTX PRO 6000 Blackwell Max-Q (97887 MB) +// - NVIDIA GB300 (256703 MB) +// Within one breakdown block, `Nx ` is added to every group when any +// group has count > 1 (preserves column alignment); otherwise dropped. +// - No usable names → last-resort count-only fallback. +// +// See #2669 for the multi-GPU case the previous fix missed. +export function formatNvidiaGpuPreflightLines(gpu: GpuDetection): string[] { + if (gpu.name) { + const detail = gpu.count > 1 ? `${gpu.count}x ${gpu.name}` : gpu.name; + return [`NVIDIA GPU detected (${detail}, ${gpu.totalMemoryMB} MB)`]; + } + if (gpu.gpus && gpu.gpus.length > 0) { + const groups = groupGpusByName(gpu.gpus); + if (groups.length > 0) { + const lines = [`NVIDIA GPU detected: ${gpu.count} GPUs, ${gpu.totalMemoryMB} MB VRAM`]; + const anyDuplicate = groups.some((grp) => grp.count > 1); + for (const grp of groups) { + const prefix = anyDuplicate ? `${grp.count}x ` : ""; + lines.push(` - ${prefix}${grp.name} (${grp.memoryMB} MB)`); + } + return lines; + } + } + return [`NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`]; +} + // Read the platform model name from firmware. Try DMI first (covers Spark // and Station, observed empirically), fall back to devicetree on systems // without DMI tables. Returns "" if neither is readable. @@ -165,6 +239,7 @@ export function detectGpu(): GpuDetection | null { return { type: "nvidia", ...(allSameName ? { name: firstName } : {}), + gpus: parsed.map((p) => ({ name: p.name, memoryMB: p.memoryMB })), count: parsed.length, totalMemoryMB, perGpuMB: parsed[0].memoryMB, @@ -217,9 +292,13 @@ export function detectGpu(): GpuDetection | null { : firmwarePlatform === "station" ? "station" : "linux"; + // 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. return { type: "nvidia", name: unifiedGpuNames[0], + gpus: unifiedGpuNames.map((name: string) => ({ name, memoryMB: perGpuMB })), count, totalMemoryMB, perGpuMB: perGpuMB || totalMemoryMB, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2985e19dafe..b3895803e39 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4118,16 +4118,10 @@ async function preflight( // GPU const gpu = nim.detectGpu(); if (gpu && gpu.type === "nvidia") { - if (gpu.name) { - // Match DevTest case 517913 cross-check format when the GPU model is - // known: `NVIDIA GPU detected (, MB)`. Multi-GPU hosts - // with the same model render as `Nx ` inside the parens. - const detail = gpu.count > 1 ? `${gpu.count}x ${gpu.name}` : gpu.name; - console.log(` ✓ NVIDIA GPU detected (${detail}, ${gpu.totalMemoryMB} MB)`); - } else { - // Mixed-model or unnamed devices fall back to the count-only form so - // we never falsely attribute one GPU's name to the others. - console.log(` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); + const lines = nim.formatNvidiaGpuPreflightLines(gpu); + console.log(` ✓ ${lines[0]}`); + for (const extra of lines.slice(1)) { + console.log(` ${extra}`); } if (!gpu.nimCapable) { console.log(" ⓘ Local NIM unavailable — GPU VRAM too small"); From 07db5a29c84f4b3c0d68738a3e37d1e466cd2dad Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 11 May 2026 12:00:51 +0000 Subject: [PATCH 2/2] test(nim): fold redundant cases; fix unified-memory misattribution Signed-off-by: Tinson Lai --- src/lib/inference/nim.test.ts | 137 ++++++++++++---------------------- src/lib/inference/nim.ts | 8 +- 2 files changed, 55 insertions(+), 90 deletions(-) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index 5597485942c..13e476a44b2 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -222,7 +222,7 @@ describe("nim", () => { } }); - it("aggregates totalMemoryMB across multiple GPUs from primary path", () => { + it("aggregates fields and populates gpus for N homogeneous GPUs on the primary path", () => { const runCapture = vi.fn((cmd: string | string[]) => { if (!Array.isArray(cmd)) throw new Error("expected argv array"); if ( @@ -242,6 +242,10 @@ describe("nim", () => { count: 2, totalMemoryMB: 163840, perGpuMB: 81920, + gpus: [ + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, + ], }); } finally { restore(); @@ -278,14 +282,18 @@ describe("nim", () => { } }); - it("drops name on mixed-model multi-GPU hosts so we don't attribute one model to the others", () => { + // Regression #2669: the previous fix added `name` only for homogeneous + // hosts, so mixed-GPU machines (RTX PRO 6000 + GB300 on the QA verification + // host) dropped the model info entirely. We keep `name` undefined to avoid + // misattribution but now surface the per-GPU breakdown via `gpus`. + it("drops name and populates gpus breakdown on mixed-model hosts (regression #2669)", () => { 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 H100 80GB HBM3, 81920\nNVIDIA A100-SXM4-80GB, 81920\n"; + return "NVIDIA RTX PRO 6000 Blackwell Max-Q, 97887\nNVIDIA GB300, 256703\n"; } return ""; }); @@ -296,36 +304,8 @@ describe("nim", () => { expect(result).toMatchObject({ type: "nvidia", count: 2, - totalMemoryMB: 163840, + totalMemoryMB: 354590, }); - // Mixed-model hosts must not pin a single name; the preflight line - // would otherwise read "2x NVIDIA H100" on a host that's actually - // half H100 and half A100. - expect(result?.name).toBeUndefined(); - } finally { - restore(); - } - }); - - // Regression #2669: the previous fix added `name` only for homogeneous - // hosts, so mixed-GPU machines (e.g. RTX PRO 6000 + GB300 on the QA - // verification host) lost the model info entirely. We now also surface - // the per-GPU breakdown via `gpus` so the preflight line can render it. - it("populates the gpus breakdown on mixed-model hosts (regression #2669)", () => { - 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 RTX PRO 6000 Blackwell Max-Q, 97887\nNVIDIA GB300, 256703\n"; - } - return ""; - }); - const { nimModule, restore } = loadNimWithMockedRunner(runCapture); - - try { - const result = nimModule.detectGpu(); expect(result?.name).toBeUndefined(); expect(result?.gpus).toEqual([ { name: "NVIDIA RTX PRO 6000 Blackwell Max-Q", memoryMB: 97887 }, @@ -336,54 +316,6 @@ describe("nim", () => { } }); - it("populates the gpus breakdown on homogeneous hosts too", () => { - 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 H100 80GB HBM3, 81920\nNVIDIA H100 80GB HBM3, 81920\n"; - } - return ""; - }); - const { nimModule, restore } = loadNimWithMockedRunner(runCapture); - - try { - const result = nimModule.detectGpu(); - expect(result?.gpus).toEqual([ - { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, - { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, - ]); - } finally { - restore(); - } - }); - - it("populates the gpus breakdown on the unified-memory fallback path (#2669 GB10)", () => { - // Spark / Jetson don't have memory.total per GPU — host RAM is split - // evenly across the named devices. Used by the original GB10 reporter. - const runCapture = vi.fn((cmd: string | string[]) => { - if (!Array.isArray(cmd)) throw new Error("expected argv array"); - if (cmd.some((a: string) => a.includes("memory.total"))) return ""; - if (cmd.some((a: string) => a.includes("query-gpu=name"))) { - return "NVIDIA GB10\n"; - } - if (cmd[0] === "free" && cmd[1] === "-m") { - return " total used free\nMem: 131072 10240 90000\nSwap: 0 0 0"; - } - return ""; - }); - const { nimModule, restore } = loadNimWithMockedRunner(runCapture); - - try { - const result = nimModule.detectGpu(); - expect(result?.gpus).toEqual([{ name: "NVIDIA GB10", memoryMB: 131072 }]); - } finally { - restore(); - } - }); - it("detects GB10 unified-memory GPUs as Spark-capable NVIDIA devices", () => { const runCapture = vi.fn((cmd: string | string[]) => { if (!Array.isArray(cmd)) throw new Error("expected argv array"); @@ -404,6 +336,11 @@ describe("nim", () => { nimCapable: true, unifiedMemory: true, spark: true, + // Regression #2669: the unified-memory fallback path now also + // populates `gpus` so the preflight breakdown works when the + // primary --query-gpu=memory.total path is unavailable (Jetson / + // Spark / Orin). + gpus: [{ name: "NVIDIA GB10", memoryMB: 131072 }], }); } finally { restore(); @@ -438,6 +375,37 @@ describe("nim", () => { } }); + // Same invariant as the primary-path mixed-model test: don't pin a + // single name on hosts with multiple distinct GPU models, even on the + // unified-memory fallback. Hypothetical today (no shipping platform mixes + // unified-memory NVIDIA devices), but the previous version of this code + // would silently misattribute the first GPU's name to all of them. + it("drops name on mixed-model unified-memory hosts but keeps the gpus breakdown", () => { + const runCapture = vi.fn((cmd: string | string[]) => { + if (!Array.isArray(cmd)) throw new Error("expected argv array"); + if (cmd.some((a: string) => a.includes("memory.total"))) return ""; + if (cmd.some((a: string) => a.includes("query-gpu=name"))) { + return "NVIDIA GB10\nNVIDIA Jetson AGX Orin"; + } + if (cmd[0] === "free" && cmd[1] === "-m") { + return " total used free\nMem: 163840 10240 120000\nSwap: 0 0 0"; + } + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const result = nimModule.detectGpu(); + expect(result?.name).toBeUndefined(); + expect(result?.gpus).toEqual([ + { name: "NVIDIA GB10", memoryMB: 81920 }, + { name: "NVIDIA Jetson AGX Orin", memoryMB: 81920 }, + ]); + } finally { + restore(); + } + }); + it("marks low-memory unified-memory NVIDIA devices as not NIM-capable", () => { const runCapture = vi.fn((cmd: string | string[]) => { if (!Array.isArray(cmd)) throw new Error("expected argv array"); @@ -466,15 +434,6 @@ describe("nim", () => { }); describe("groupGpusByName", () => { - it("groups identical names and sums their memory", () => { - expect( - nim.groupGpusByName([ - { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, - { name: "NVIDIA H100 80GB HBM3", memoryMB: 81920 }, - ]), - ).toEqual([{ name: "NVIDIA H100 80GB HBM3", count: 2, memoryMB: 163840 }]); - }); - it("preserves first-appearance order across distinct names", () => { expect( nim.groupGpusByName([ diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index 066dd3829f7..4e4f5e6d8af 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -282,6 +282,12 @@ export function detectGpu(): GpuDetection | null { } const count = unifiedGpuNames.length; const perGpuMB = count > 0 ? Math.floor(totalMemoryMB / count) : totalMemoryMB; + const firstUnifiedName = unifiedGpuNames[0] ?? ""; + // Mirror the primary path: only surface a single name when every GPU + // reports the same model. Otherwise a hypothetical mixed unified-memory + // host (e.g. Spark + Orin) would be misrendered as `Nx `. + const allUnifiedSameName = + !!firstUnifiedName && unifiedGpuNames.every((n: string) => n === firstUnifiedName); // Cross-check the firmware model against the GPU name. Spark must have // a GB10; falling through to firmware lets us classify Station too. const firmwarePlatform = detectNvidiaPlatform(); @@ -297,7 +303,7 @@ export function detectGpu(): GpuDetection | null { // Approximation, but the only number nvidia-smi gives us in this path. return { type: "nvidia", - name: unifiedGpuNames[0], + ...(allUnifiedSameName ? { name: firstUnifiedName } : {}), gpus: unifiedGpuNames.map((name: string) => ({ name, memoryMB: perGpuMB })), count, totalMemoryMB,