227 lines
7.5 KiB
TypeScript
227 lines
7.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
// @ts-expect-error The shared ES module has no TypeScript declarations.
|
|
import { assessCapacity } from "../../docs/js/cookbook-memory.mjs";
|
|
import weightsData from "../../docs/assets/cookbook-weights.json";
|
|
|
|
export type MemoryStatus = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
|
|
|
|
/** The subset of an `assessCapacity` result the UI shows. */
|
|
export interface MemoryAssessment {
|
|
status: MemoryStatus;
|
|
reason?: string;
|
|
requiredGiB?: number;
|
|
capacityGiB?: number;
|
|
basis?: string;
|
|
parts?: Record<string, number>;
|
|
}
|
|
|
|
export interface WeightsModel {
|
|
parts: Record<string, number>;
|
|
alternates?: Record<string, number>;
|
|
sources?: string[];
|
|
}
|
|
|
|
const WEIGHTS = weightsData as { models: Record<string, WeightsModel> };
|
|
|
|
/** Measured checkpoint sizes for recipes that have no full capacity model yet. */
|
|
export function weightsFor(modelId: string | undefined): WeightsModel | undefined {
|
|
return modelId ? WEIGHTS.models[modelId] : undefined;
|
|
}
|
|
|
|
/**
|
|
* Compare resident checkpoint weights with one device. This answers whether the
|
|
* weights load, not whether a request peaks below the device limit: activations,
|
|
* VSA workspace, and decode buffers are not modeled.
|
|
*/
|
|
export function assessWeightsRecipe({
|
|
weights,
|
|
config,
|
|
hardware,
|
|
budgetGb,
|
|
}: {
|
|
weights: WeightsModel;
|
|
config: Record<string, unknown>;
|
|
hardware: { memory_gb?: number; usable_budget_gb?: number; memory_unit?: string; memory_type?: string };
|
|
budgetGb?: number;
|
|
}): MemoryAssessment | undefined {
|
|
if (hardware.memory_type !== "dedicated" && hardware.memory_type !== "unified") {
|
|
return {
|
|
status: "unknown",
|
|
reason:
|
|
"A weights fit check requires dedicated GPU memory or unified memory. Host-only system RAM is not GPU capacity.",
|
|
};
|
|
}
|
|
const generator = (config.generator ?? {}) as {
|
|
engine?: { offload?: Record<string, boolean> };
|
|
pipeline?: {
|
|
components?: Record<string, string | null>;
|
|
experimental?: Record<string, unknown>;
|
|
};
|
|
};
|
|
const parts = { ...weights.parts };
|
|
const components = generator.pipeline?.components ?? {};
|
|
const experimental = generator.pipeline?.experimental ?? {};
|
|
if (experimental.video_decode_backend === "taeh3") {
|
|
return {
|
|
status: "unknown",
|
|
reason: "TAEH3 decoder weights are not modeled. This backend does not load the bundled H3 video VAE.",
|
|
};
|
|
}
|
|
if (experimental.h3_vae_nvfp4_decoder) {
|
|
return {
|
|
status: "unknown",
|
|
reason:
|
|
"NVFP4 decoder residency is not modeled: the VAE retains its encoder and packs decoder weights after loading. Decoder file size is not the resident VAE size.",
|
|
};
|
|
}
|
|
if (components.text_encoder_weights && weights.alternates?.text_encoder_quantized !== undefined) {
|
|
parts.text_encoder = weights.alternates.text_encoder_quantized;
|
|
}
|
|
const quant = (config.generator as { engine?: { quantization?: { transformer_quant?: string | null } } })?.engine
|
|
?.quantization?.transformer_quant;
|
|
if (quant) {
|
|
return {
|
|
status: "unknown",
|
|
reason:
|
|
"Transformer quantization residency is not modeled for this checkpoint and layer profile. Published export sizes do not describe on-the-fly quantization of this configuration.",
|
|
};
|
|
}
|
|
const offload = generator.engine?.offload ?? {};
|
|
if (offload.text_encoder) delete parts.text_encoder;
|
|
const resident = Object.values(parts).reduce((sum, value) => sum + value, 0);
|
|
const capacityGb = budgetGb ?? hardware.usable_budget_gb ?? hardware.memory_gb;
|
|
if (!capacityGb) return undefined;
|
|
const capacityGiB = hardware.memory_unit === "GiB" ? capacityGb : (capacityGb * 1e9) / GIB;
|
|
const label: Record<string, string> = {
|
|
transformer: "transformer",
|
|
text_encoder: "text encoder",
|
|
vae: "VAE",
|
|
audio_vae: "audio VAE",
|
|
};
|
|
const split = Object.entries(parts)
|
|
.map(([role, value]) => `${label[role] ?? role} ${value.toFixed(1)} GiB`)
|
|
.join(", ");
|
|
const status: MemoryStatus = resident > capacityGiB ? "exceeds" : resident > capacityGiB * 0.85 ? "tight" : "fits";
|
|
const offloaded = offload.text_encoder ? " The text encoder is offloaded." : "";
|
|
return {
|
|
status,
|
|
basis: "weights",
|
|
requiredGiB: resident,
|
|
capacityGiB,
|
|
parts,
|
|
reason: `Weights only: ${split}.${offloaded} Activations, VSA workspace, and decode buffers are not modeled.`,
|
|
};
|
|
}
|
|
|
|
interface CapacityModel {
|
|
id: string;
|
|
name?: string;
|
|
examples?: { deployment_id?: string }[];
|
|
reference_runs?: unknown[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface CapacityState {
|
|
status: "loading" | "ready" | "unavailable";
|
|
model?: CapacityModel;
|
|
}
|
|
|
|
const CAPACITY_INDEX = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/assets/cookbook-config/capacity/index.json`;
|
|
const GIB = 1024 ** 3;
|
|
|
|
let indexPromise: Promise<{ models: { id: string; catalog_url: string }[] }> | null = null;
|
|
|
|
function loadIndex() {
|
|
indexPromise ??= fetch(CAPACITY_INDEX)
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error(`Capacity index: HTTP ${response.status}`);
|
|
return response.json() as Promise<{ models: { id: string; catalog_url: string }[] }>;
|
|
})
|
|
.catch((error) => {
|
|
indexPromise = null;
|
|
throw error;
|
|
});
|
|
return indexPromise;
|
|
}
|
|
|
|
/**
|
|
* Load the capacity model for one cookbook entry. Most recipes do not have one;
|
|
* the hook stays `unavailable` and the UI says so instead of guessing.
|
|
*/
|
|
export function useCapacityModel(modelId: string | undefined): CapacityState {
|
|
const [state, setState] = useState<CapacityState>({ status: "loading" });
|
|
useEffect(() => {
|
|
if (!modelId) {
|
|
setState({ status: "unavailable" });
|
|
return;
|
|
}
|
|
let live = true;
|
|
setState({ status: "loading" });
|
|
loadIndex()
|
|
.then(async (index) => {
|
|
const entry = index.models.find((model) => model.id === modelId);
|
|
if (!entry) throw new Error("No capacity model ships for this checkpoint.");
|
|
const url = new URL(entry.catalog_url, new URL(CAPACITY_INDEX, window.location.origin));
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`Capacity model: HTTP ${response.status}`);
|
|
const model = (await response.json()) as CapacityModel;
|
|
if (live) setState({ status: "ready", model });
|
|
})
|
|
.catch(() => {
|
|
if (live) setState({ status: "unavailable" });
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [modelId]);
|
|
return state;
|
|
}
|
|
|
|
/**
|
|
* Run the analytical estimate for the edited configuration. Returns `undefined`
|
|
* when no capacity model covers the checkpoint, so the caller can say so.
|
|
*/
|
|
export function assessRecipe({
|
|
model,
|
|
example,
|
|
hardware,
|
|
config,
|
|
budgetGb,
|
|
hostGb,
|
|
}: {
|
|
model: CapacityModel;
|
|
example: { workload?: string; env?: Record<string, string> };
|
|
hardware?: {
|
|
memory_gb?: number;
|
|
memory_unit?: string;
|
|
memory_type?: string;
|
|
usable_budget_gb?: number;
|
|
host_ram_gb?: number;
|
|
id?: string;
|
|
};
|
|
config?: Record<string, unknown>;
|
|
budgetGb?: number;
|
|
hostGb?: number;
|
|
}): MemoryAssessment | undefined {
|
|
if (!config || !hardware?.memory_gb) return undefined;
|
|
try {
|
|
const result = assessCapacity({
|
|
model,
|
|
example,
|
|
hardware,
|
|
config,
|
|
memoryBudgetGiB: budgetGb ? (hardware.memory_unit === "GiB" ? budgetGb : (budgetGb * 1e9) / GIB) : undefined,
|
|
hostMemoryGb: hostGb,
|
|
}) as MemoryAssessment;
|
|
return {
|
|
status: (result.status ?? "unknown") as MemoryStatus,
|
|
reason: result.reason,
|
|
requiredGiB: result.requiredGiB,
|
|
capacityGiB: result.capacityGiB,
|
|
basis: result.basis,
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|