Merge pull request #99 from unslothai/feature/vram-estimation

feat: VRAM-based model filtering in frontend
This commit is contained in:
Wasim Yousef Said 2026-02-15 12:17:07 -08:00 committed by GitHub
commit f99e4318e5
6 changed files with 245 additions and 14 deletions

View file

@ -9,6 +9,8 @@
"@assistant-ui/react-markdown": "^0.12.1",
"@assistant-ui/react-streamdown": "^0.1.0",
"@base-ui/react": "^1.1.0",
"@dagrejs/dagre": "^2.0.4",
"@dagrejs/graphlib": "^3.0.4",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
@ -189,6 +191,10 @@
"@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="],
"@dagrejs/dagre": ["@dagrejs/dagre@2.0.4", "", { "dependencies": { "@dagrejs/graphlib": "3.0.4" } }, "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA=="],
"@dagrejs/graphlib": ["@dagrejs/graphlib@3.0.4", "", {}, "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="],

View file

@ -72,12 +72,12 @@
"zustand": "^5.0.10"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@biomejs/biome": "^1.9.4",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

View file

@ -28,10 +28,17 @@ import {
import { MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import {
useDebouncedValue,
useGpuInfo,
useHfModelSearch,
useInfiniteScroll,
} from "@/hooks";
import { formatCompact } from "@/lib/utils";
import {
type TrainingMethod as VramTrainingMethod,
type VramFitStatus,
checkVramFit,
estimateLoadingVram,
} from "@/lib/vram";
import { useTrainingConfigStore } from "@/features/training";
import type { TrainingMethod } from "@/types/training";
import {
@ -57,6 +64,8 @@ const DARK_CONTENT =
"bg-foreground text-background shadow-xl border-background/10 [--accent:rgba(255,255,255,0.1)] [--accent-foreground:white] [&_[data-slot=select-item]]:text-white/70 [&_[data-slot=select-scroll-up-button]]:bg-foreground [&_[data-slot=select-scroll-down-button]]:bg-foreground";
export function ModelSection() {
const gpu = useGpuInfo();
const {
modelType,
selectedModel,
@ -122,6 +131,37 @@ export function ModelSection() {
return ids;
}, [hfResults, selectedModel]);
// Pre-compute VRAM fit status for every model in the current result set.
// Keyed by model id so the render callback is a simple O(1) lookup.
//
// Pre-compute VRAM fit status for every model in the current result set.
// Keyed by model id so the render callback is a simple O(1) lookup.
// Re-computes when the training method changes (QLoRA=4-bit vs LoRA/Full=fp16).
const vramMap = useMemo(() => {
const method = trainingMethod as VramTrainingMethod;
const map = new Map<
string,
{ est: number; status: VramFitStatus | null; detail: string | null }
>();
for (const r of hfResults) {
const detail = r.totalParams
? formatCompact(r.totalParams)
: r.downloads != null
? `\u2193${formatCompact(r.downloads)}`
: null;
if (r.totalParams) {
const est = estimateLoadingVram(r.totalParams, method);
const status = gpu.available
? checkVramFit(est, gpu.memoryTotalGb)
: null;
map.set(r.id, { est, status, detail });
} else {
map.set(r.id, { est: 0, status: null, detail });
}
}
return map;
}, [hfResults, gpu, trainingMethod]);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
@ -225,21 +265,21 @@ export function ModelSection() {
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const r = hfResults.find((m) => m.id === id);
const detail = r?.totalParams
? formatCompact(r.totalParams)
: r?.downloads != null
? `${formatCompact(r.downloads)}`
: null;
const entry = vramMap.get(id);
const detail = entry?.detail ?? null;
const fitStatus = entry?.status ?? null;
const vramEst = entry?.est ?? null;
const exceeds = fitStatus === "exceeds";
return (
<ComboboxItem
key={id}
value={id}
className="justify-between"
className={`justify-between ${exceeds ? "opacity-50" : ""}`}
>
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
<span className={`min-w-0 flex-1 truncate ${exceeds ? "line-through decoration-muted-foreground/50" : ""}`}>
{id}
</span>
</TooltipTrigger>
@ -248,13 +288,34 @@ export function ModelSection() {
className="max-w-xs break-all"
>
{id}
{vramEst != null && vramEst > 0 && gpu.available && (
<span className="block text-[10px] mt-1">
{exceeds
? `Needs ~${vramEst}GB VRAM (GPU: ${gpu.memoryTotalGb}GB)`
: fitStatus === "tight"
? `~${vramEst}GB VRAM (tight fit on ${gpu.memoryTotalGb}GB)`
: `~${vramEst}GB VRAM`}
</span>
)}
</TooltipContent>
</Tooltip>
{detail && (
<span className="text-[10px] text-muted-foreground shrink-0">
{detail}
</span>
)}
<span className="flex items-center gap-1.5 shrink-0">
{fitStatus === "exceeds" && (
<span className="text-[9px] font-medium text-red-400">
OOM
</span>
)}
{fitStatus === "tight" && (
<span className="text-[9px] font-medium text-amber-400">
TIGHT
</span>
)}
{detail && (
<span className="text-[10px] text-muted-foreground">
{detail}
</span>
)}
</span>
</ComboboxItem>
);
}}

View file

@ -1,4 +1,5 @@
export { useDebouncedValue } from "./use-debounced-value";
export { useGpuInfo } from "./use-gpu-info";
export { useHfModelSearch } from "./use-hf-model-search";
export { useHfDatasetSearch } from "./use-hf-dataset-search";
export { useInfiniteScroll } from "./use-infinite-scroll";

View file

@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
export interface GpuInfo {
available: boolean;
name: string;
memoryTotalGb: number;
}
const DEFAULT_GPU: GpuInfo = {
available: false,
name: "Unknown",
memoryTotalGb: 0,
};
// Module-level cache so multiple components share one fetch.
let cachedGpu: GpuInfo | null = null;
let fetchPromise: Promise<GpuInfo> | null = null;
async function fetchGpuOnce(): Promise<GpuInfo> {
if (cachedGpu) return cachedGpu;
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
try {
const res = await fetch("/api/system");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const gpuData = data?.gpu;
if (!gpuData?.available || !gpuData.devices?.length) return DEFAULT_GPU;
const dev = gpuData.devices[0];
const info: GpuInfo = {
available: true,
name: dev.name ?? "Unknown",
memoryTotalGb: dev.memory_total_gb ?? 0,
};
cachedGpu = info;
return info;
} catch {
// Reset promise so subsequent calls retry (e.g. backend wasn't ready)
fetchPromise = null;
return DEFAULT_GPU;
}
})();
return fetchPromise;
}
/**
* Fetch GPU info from the backend /api/system endpoint.
*
* The result is cached at module level -- only one network request is made
* regardless of how many components call this hook.
*/
export function useGpuInfo(): GpuInfo {
const [gpu, setGpu] = useState<GpuInfo>(cachedGpu ?? DEFAULT_GPU);
useEffect(() => {
if (cachedGpu) return;
let cancelled = false;
fetchGpuOnce().then((info) => {
if (!cancelled) setGpu(info);
});
return () => { cancelled = true; };
}, []);
return gpu;
}

View file

@ -0,0 +1,95 @@
/**
* VRAM estimation for model loading (4-bit quantization via bitsandbytes).
*
* Estimates the total driver-level VRAM (what nvidia-smi reports) needed to
* load a model in 4-bit with Unsloth / bitsandbytes. This determines
* whether a model will fit on the user's GPU before any training begins.
*
* Formula: totalParams * 0.90 + 1.4 GB
*
* Calibrated against isolated Unsloth model loads on RTX 5070 Ti (2026.2):
* Qwen2.5-0.5B (0.49B) : est 1.8 vs actual 1.86 GB (-3%)
* Llama-3.2-1B (1.24B) : est 2.5 vs actual 2.54 GB (-1%)
* Llama-3.2-3B (3.21B) : est 4.3 vs actual 4.40 GB (-2%)
* Llama-3.1-8B (8.03B) : est 8.6 vs actual 8.14 GB (+6%)
*
* Accuracy: within 3% for 0.5B-3B models, within 6% for 8B.
*/
// ---------------------------------------------------------------------------
// Constants (exported for testing)
// ---------------------------------------------------------------------------
/**
* Effective bytes per parameter for 4-bit model weights at driver level.
*
* Raw bnb 4-bit is ~0.5 bytes/param, but embedding and lm_head layers remain
* in fp16 and bnb adds per-block quantization metadata, bringing the
* effective rate to ~0.84-0.93 across tested architectures. 0.9 is the
* calibrated middle ground.
*/
export const BNB_4BIT_LOADING_BYTES = 0.9;
/**
* Fixed overhead (GB) for the CUDA driver context and PyTorch runtime.
*
* This is independent of model size -- it is the baseline GPU memory consumed
* before any model weights are loaded. Measured at 1.34-1.46 GB across
* tested models; we use 1.4 as the default.
*/
export const LOADING_OVERHEAD_GB = 1.4;
// ---------------------------------------------------------------------------
// Estimation
// ---------------------------------------------------------------------------
export type VramFitStatus = "fits" | "tight" | "exceeds";
/**
* Bytes per parameter when loading a model at fp16/bf16 (LoRA, full FT).
*
* This is the theoretical value (2 bytes = 16 bits). Not yet calibrated
* against actual measurements -- the real driver-level usage may be slightly
* higher due to buffers and metadata, similar to how 4-bit is 0.9 vs 0.5.
*/
export const FP16_LOADING_BYTES = 2.0;
export type TrainingMethod = "qlora" | "lora" | "full";
/**
* Estimate VRAM (GB) needed to load a model with Unsloth.
*
* The bytes-per-param rate depends on the training method:
* - QLoRA : 4-bit quantized via bnb -> 0.90 bytes/param (calibrated)
* - LoRA : fp16 -> 2.0 bytes/param (theoretical)
* - Full : fp16 -> 2.0 bytes/param (theoretical)
*
* Formula: totalParams * bytesPerParam + 1.4 GB overhead
*/
export function estimateLoadingVram(
totalParams: number,
method: TrainingMethod = "qlora",
): number {
const bytesPerParam =
method === "qlora" ? BNB_4BIT_LOADING_BYTES : FP16_LOADING_BYTES;
const gb = (totalParams / 1e9) * bytesPerParam + LOADING_OVERHEAD_GB;
return Math.round(gb * 10) / 10;
}
/**
* Check whether a model fits in the available GPU VRAM.
*
* fits - uses <= 75% of available
* tight - uses 75-100% of available
* exceeds - uses > 100% of available
*/
export function checkVramFit(
requiredGb: number,
availableGb: number,
): VramFitStatus {
if (availableGb <= 0) return requiredGb <= 0 ? "fits" : "exceeds";
const ratio = requiredGb / availableGb;
if (ratio <= 0.75) return "fits";
if (ratio <= 1.0) return "tight";
return "exceeds";
}