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).
This commit is contained in:
LeoBorcherding 2026-07-25 01:09:17 -05:00
commit 81333e166d
4 changed files with 53 additions and 2 deletions

View file

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

View file

@ -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<ChatRuntimeStore>((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 });

View file

@ -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<SystemInfoResponse | null> {
if (cachedSystem) return cachedSystem;
if (systemPromise) return systemPromise;

View file

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