From 81333e166de88fbdb61b3264927cf391e761eb2f Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Sat, 25 Jul 2026 01:09:17 -0500 Subject: [PATCH] studio: refresh GPU index space on backend swap; self-heal cold-cache pin stamp Addresses two Codex P2s on the Vulkan GPU-pin machinery: - use-gpu-info caches /api/system forever, so after an in-session llama.cpp backend swap (update/rebuild) currentGpuIndexKind() kept reporting the pre-swap index space until a full reload, stranding physical pins on a now-Vulkan backend (or dropping Vulkan pins after a swap back). Add invalidateGpuInfoCache() and call it on llama.cpp update success, beside the existing refreshHardwareInfo(). - A /status hydration (or eager picker edit) that beats the first /api/system fetch stamped a live pick's index kind null; currentRuntimePerModelConfig then serialized it as an unstamped (legacy physical) pick, dropping a real Vulkan selection on the next reconcile. Add warmSelectedGpuIdsKind() to warm the cache and backfill the kind at the two cold-capable stamp sites (setSelectedGpuIds, the /status hydration). --- .../lib/apply-inference-status-to-store.ts | 6 ++++ .../chat/stores/chat-runtime-store.ts | 30 +++++++++++++++++-- studio/frontend/src/hooks/use-gpu-info.ts | 13 ++++++++ .../src/hooks/use-llama-update-check.ts | 6 ++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index f85ff3246b..d387e9c037 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -15,6 +15,7 @@ import { loadedGpuMemoryFields, resolveToolsEnabledOnLoad, useChatRuntimeStore, + warmSelectedGpuIdsKind, } from "../stores/chat-runtime-store"; import { type InferenceStatusResponse, @@ -338,6 +339,11 @@ export function applyActiveModelStatusToStore( }), }); + // A /status hydration can beat the first /api/system fetch, stamping the + // loaded pick's index kind null; backfill it once the GPU cache warms so a + // subsequent rollback snapshot doesn't serialize it as a physical default. + warmSelectedGpuIdsKind(); + ensureActiveModelInStoreList(status, checkpointId); if ( diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 0d041cc25b..6ff9a0936f 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -6,6 +6,7 @@ import { type GpuIndexKind, cachedPinnableGpuIndices, currentGpuIndexKind, + ensureGpuDeviceCache, } from "@/hooks/use-gpu-info"; import { toast } from "@/lib/toast"; import { create } from "zustand"; @@ -701,6 +702,27 @@ export function loadedGpuMemoryFields(resp: { }; } +/** Self-heal a cold-cache GPU-kind stamp. A live pick stamped while the + * /api/system GPU cache was still cold (a /status hydration that beat the first + * fetch, or an eager picker edit) lands with selectedGpuIdsKind === null, which + * imperative snapshots (currentRuntimePerModelConfig) then serialize as an + * unstamped -- i.e. legacy physical -- pick, dropping a real Vulkan selection on + * the next reconcile. Warm the cache and stamp the now-known kind so any + * snapshot taken after (a model-switch rollback) carries the true index space. + * No-op when the pick is already stamped or cleared. */ +export function warmSelectedGpuIdsKind(): void { + const { selectedGpuIds, selectedGpuIdsKind } = useChatRuntimeStore.getState(); + if (selectedGpuIds == null || selectedGpuIdsKind != null) return; + void ensureGpuDeviceCache().then(() => { + // Re-read: a swap/clear during the await gap must not resurrect a stamp for a + // pick that is gone, and a pick already stamped by its own action is left be. + const s = useChatRuntimeStore.getState(); + if (s.selectedGpuIds == null || s.selectedGpuIdsKind != null) return; + const kind = currentGpuIndexKind(); + if (kind != null) useChatRuntimeStore.setState({ selectedGpuIdsKind: kind }); + }); +} + /** A pick is a GGUF: HF variant, native file, or a direct local .gguf. */ export function hasGgufSource(x: { ggufVariant?: string; @@ -1937,14 +1959,18 @@ export const useChatRuntimeStore = create((set, get) => ({ setGpuLayers: (gpuLayers) => set({ gpuLayers }), setNCpuMoe: (nCpuMoe) => set({ nCpuMoe }), setSplitRatio: (splitRatio) => set({ splitRatio }), - setSelectedGpuIds: (selectedGpuIds) => + setSelectedGpuIds: (selectedGpuIds) => { set({ selectedGpuIds, // A live picker selection is by definition in the current backend's index // space; stamp it so a later reconcile keeps it (and drops it only after // an actual backend swap). null pick carries no space. selectedGpuIdsKind: selectedGpuIds == null ? null : currentGpuIndexKind(), - }), + }); + // If the cache was cold the stamp above is null; warm and backfill it so a + // later rollback snapshot serializes the true space, not a physical default. + warmSelectedGpuIdsKind(); + }, setExpandQuantizations: (expandQuantizations) => { saveBool(CHAT_EXPAND_QUANTIZATIONS_KEY, expandQuantizations); set({ expandQuantizations }); diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index e1e2c9fb84..38129be787 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -77,6 +77,19 @@ export function currentGpuIndexKind(): GpuIndexKind | null { return systemGpuIndexKind(cachedSystem); } +/** Drop the cached /api/system snapshot so the next read refetches it. Call + * after an in-session llama.cpp backend swap (update / MTP rebuild): the swap + * flips the gpu_ids index space (physical CUDA/ROCm ids <-> ggml Vulkan + * ordinals), and because this module caches the first fetch forever, + * currentGpuIndexKind() and the load-boundary reconcile would otherwise keep + * reporting the pre-swap space -- stranding stale physical pins on a now-Vulkan + * backend, or dropping valid Vulkan pins after a swap back -- until a full page + * reload. Invalidating here lets the next ensureGpuDeviceCache() re-probe. */ +export function invalidateGpuInfoCache(): void { + cachedSystem = null; + systemPromise = null; +} + async function fetchSystemOnce(): Promise { if (cachedSystem) return cachedSystem; if (systemPromise) return systemPromise; diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index 999700ae1f..0d34ec605f 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -3,6 +3,7 @@ import { authFetch, getAuthToken } from "@/features/auth"; import { refreshHardwareInfo } from "@/hooks/use-hardware-info"; +import { invalidateGpuInfoCache } from "@/hooks/use-gpu-info"; import { useCallback, useEffect, useRef, useState } from "react"; // Initial check plus hourly reminders until dismissed or applied. @@ -212,6 +213,11 @@ export function useLlamaUpdateCheck({ if (s.job.state === "success") { setVisible(false); void refreshHardwareInfo(); + // A llama.cpp update can swap the backend (e.g. ROCm -> Vulkan), which + // flips the gpu_ids index space. Drop the separate /api/system GPU + // cache too so currentGpuIndexKind()/the next reconcile re-probe the + // new space instead of stranding pins stamped under the old one. + invalidateGpuInfoCache(); // The update unloads the running model server-side, so the chat // runtime still points at a model that now 400s on send. Let the // consumer drop the selector to "select model" instead of waiting for