diff --git a/studio/frontend/src/components/ui/terminal.tsx b/studio/frontend/src/components/ui/terminal.tsx new file mode 100644 index 0000000000..d9483eaca5 --- /dev/null +++ b/studio/frontend/src/components/ui/terminal.tsx @@ -0,0 +1,227 @@ +import { cn } from "@/lib/utils" +import { + Children, + cloneElement, + isValidElement, + useEffect, + useRef, + useState, +} from "react" +import type { ElementType, ReactElement, ReactNode } from "react" + +type TerminalProps = { + children: ReactNode + className?: string + sequence?: boolean + startOnView?: boolean +} + +type InternalLineProps = { + __isActive?: boolean + __onDone?: () => void + __sequence?: boolean +} + +function useStartOnView(enabled: boolean): { + ref: React.RefObject + started: boolean +} { + const ref = useRef(null) + const [isInView, setIsInView] = useState(false) + const started = !enabled || isInView + + useEffect(() => { + if (!enabled) { + return + } + + const node = ref.current + if (!node) { + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setIsInView(true) + observer.disconnect() + } + }, + { threshold: 0.2 } + ) + + observer.observe(node) + return () => observer.disconnect() + }, [enabled]) + + return { ref, started } +} + +export function Terminal({ + children, + className, + sequence = true, + startOnView = true, +}: TerminalProps): ReactElement { + const { ref, started } = useStartOnView(startOnView) + const childElements = Children.toArray(children).filter(isValidElement) + const [activeIndex, setActiveIndex] = useState(0) + const visibleIndex = sequence + ? started + ? activeIndex + : -1 + : Number.MAX_SAFE_INTEGER + + function handleLineDone(index: number): void { + if (!sequence) { + return + } + + setActiveIndex((prev) => { + if (prev !== index) { + return prev + } + return Math.min(index + 1, childElements.length) + }) + } + + return ( +
+ {childElements.map((child, index) => + cloneElement(child, { + __sequence: sequence, + __isActive: !sequence || visibleIndex >= index, + __onDone: () => handleLineDone(index), + key: child.key ?? index, + } as InternalLineProps) + )} +
+ ) +} + +type AnimatedSpanProps = InternalLineProps & { + children: ReactNode + className?: string + delay?: number + startOnView?: boolean +} + +export function AnimatedSpan({ + children, + className, + delay = 0, + startOnView = false, + __isActive, + __sequence, + __onDone, +}: AnimatedSpanProps): ReactElement { + const { ref, started } = useStartOnView(startOnView) + const [visible, setVisible] = useState(false) + const doneRef = useRef(false) + const onDoneRef = useRef(__onDone) + const shouldStart = __sequence ? __isActive : started + + useEffect(() => { + onDoneRef.current = __onDone + }, [__onDone]) + + useEffect(() => { + if (!shouldStart || doneRef.current) { + return + } + + const timeout = window.setTimeout(() => { + setVisible(true) + doneRef.current = true + onDoneRef.current?.() + }, delay) + + return () => window.clearTimeout(timeout) + }, [delay, shouldStart]) + + return ( +
+ {children} +
+ ) +} + +type TypingAnimationProps = InternalLineProps & { + children: string + className?: string + duration?: number + delay?: number + as?: ElementType + startOnView?: boolean +} + +export function TypingAnimation({ + children, + className, + duration = 60, + delay = 0, + as: Component = "span", + startOnView = true, + __isActive, + __sequence, + __onDone, +}: TypingAnimationProps): ReactElement { + const { ref, started } = useStartOnView(startOnView) + const [typed, setTyped] = useState("") + const doneRef = useRef(false) + const onDoneRef = useRef(__onDone) + const shouldStart = __sequence ? __isActive : started + + useEffect(() => { + onDoneRef.current = __onDone + }, [__onDone]) + + useEffect(() => { + if (!shouldStart || doneRef.current) { + return + } + + let index = 0 + let intervalId: number | null = null + const startTimer = window.setTimeout(() => { + intervalId = window.setInterval(() => { + index += 1 + setTyped(children.slice(0, index)) + + if (index >= children.length) { + if (intervalId) { + window.clearInterval(intervalId) + } + doneRef.current = true + onDoneRef.current?.() + } + }, duration) + }, delay) + + return () => { + window.clearTimeout(startTimer) + if (intervalId) { + window.clearInterval(intervalId) + } + } + }, [children, delay, duration, shouldStart]) + + return ( +
+ {typed} +
+ ) +} diff --git a/studio/frontend/src/features/studio/sections/charts-content.tsx b/studio/frontend/src/features/studio/sections/charts-content.tsx index 3a4d2d1d29..74583bfc89 100644 --- a/studio/frontend/src/features/studio/sections/charts-content.tsx +++ b/studio/frontend/src/features/studio/sections/charts-content.tsx @@ -23,7 +23,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; -import type { TrainingMetrics } from "@/types/training"; import { ChartAverageIcon, Settings02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { type ReactElement, useMemo, useState } from "react"; @@ -44,9 +43,11 @@ const lossConfig = { const lrConfig = { lr: { label: "LR", color: "#8b5cf6" }, } satisfies ChartConfig; + const gradNormConfig = { gradNorm: { label: "Grad Norm", color: "#f97316" }, } satisfies ChartConfig; + const evalLossConfig = { loss: { label: "Eval Loss", color: "#ef4444" }, } satisfies ChartConfig; @@ -62,10 +63,81 @@ const placeholderEvalData = [ type LossHistoryItem = { step: number; loss: number }; type SmoothedLossItem = LossHistoryItem & { smoothed: number }; +interface TrainingChartSeries { + lossHistory: LossHistoryItem[]; + lrHistory: { step: number; lr: number }[]; + gradNormHistory: { step: number; gradNorm: number }[]; +} + +const CHART_SYNC_ID = "train-metrics-sync"; +const MAX_RENDER_POINTS = 800; +const DEFAULT_VISIBLE_POINTS = 160; + +function formatStepTick(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k`; + } + return String(Math.round(value)); +} + +function compressSeries(data: T[], maxPoints: number): T[] { + if (data.length <= maxPoints) { + return data; + } + + const stride = Math.ceil(data.length / maxPoints); + return data.filter( + (_item, index) => index % stride === 0 || index === data.length - 1, + ); +} + +function buildStepTicks(min: number, max: number, targetCount = 6): number[] { + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return [0, 1]; + } + if (max <= min) { + return [min, max]; + } + + const stepSize = Math.max(1, Math.ceil((max - min) / (targetCount - 1))); + const ticks: number[] = []; + let current = min; + + while (current < max) { + ticks.push(current); + current += stepSize; + } + + ticks.push(max); + return Array.from(new Set(ticks)); +} + +function buildYDomain(values: number[]): [number, number] { + if (values.length === 0) { + return [0, 1]; + } + + const min = Math.min(...values); + const max = Math.max(...values); + + if (min === max) { + const base = Math.abs(min); + const pad = base > 0 ? base * 0.08 : 0.1; + return [min - pad, max + pad]; + } + + const pad = (max - min) * 0.12; + return [min - pad, max + pad]; +} + function ema(data: LossHistoryItem[], alpha: number): SmoothedLossItem[] { if (data.length === 0) { return []; } + let s = data[0].loss; return data.map((d) => { s = alpha * d.loss + (1 - alpha) * s; @@ -75,18 +147,112 @@ function ema(data: LossHistoryItem[], alpha: number): SmoothedLossItem[] { export function ChartsContent({ metrics, -}: { metrics: TrainingMetrics }): ReactElement { - const [smoothing, setSmoothing] = useState(0.6); +}: { metrics: TrainingChartSeries }): ReactElement { + const [smoothing, setSmoothing] = useState(0.75); const [showRaw, setShowRaw] = useState(true); const [showSmoothed, setShowSmoothed] = useState(true); const [showAvgLine, setShowAvgLine] = useState(true); const lossHistory = metrics.lossHistory; const smoothedData = useMemo( - () => (lossHistory ? ema(lossHistory, 1 - smoothing) : []), + () => (lossHistory.length > 0 ? ema(lossHistory, 1 - smoothing) : []), [lossHistory, smoothing], ); + const reducedLossData = useMemo( + () => compressSeries(smoothedData, MAX_RENDER_POINTS), + [smoothedData], + ); + + const reducedGradNormData = useMemo( + () => compressSeries(metrics.gradNormHistory, MAX_RENDER_POINTS), + [metrics.gradNormHistory], + ); + + const reducedLrData = useMemo( + () => compressSeries(metrics.lrHistory, MAX_RENDER_POINTS), + [metrics.lrHistory], + ); + + const visibleStepDomain = useMemo<[number, number]>(() => { + const allSteps = [ + ...reducedLossData.map((point) => point.step), + ...reducedGradNormData.map((point) => point.step), + ...reducedLrData.map((point) => point.step), + ].sort((a, b) => a - b); + + if (allSteps.length === 0) { + return [0, 1]; + } + + const endStep = allSteps[allSteps.length - 1] ?? 1; + const startIndex = Math.max(0, allSteps.length - DEFAULT_VISIBLE_POINTS); + const startStep = allSteps[startIndex] ?? 0; + if (startStep === endStep) { + return [Math.max(0, startStep - 1), startStep + 4]; + } + if (endStep - startStep < 6) { + return [Math.max(0, endStep - 6), endStep]; + } + return [startStep, endStep]; + }, [reducedGradNormData, reducedLossData, reducedLrData]); + + const xAxisTicks = useMemo( + () => buildStepTicks(visibleStepDomain[0], visibleStepDomain[1]), + [visibleStepDomain], + ); + + const visibleLossValues = useMemo( + () => + reducedLossData + .filter( + (point) => + point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], + ) + .map((point) => point.loss), + [reducedLossData, visibleStepDomain], + ); + + const visibleSmoothValues = useMemo( + () => + reducedLossData + .filter( + (point) => + point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], + ) + .map((point) => point.smoothed), + [reducedLossData, visibleStepDomain], + ); + + const visibleGradValues = useMemo( + () => + reducedGradNormData + .filter( + (point) => + point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], + ) + .map((point) => point.gradNorm), + [reducedGradNormData, visibleStepDomain], + ); + + const visibleLrValues = useMemo( + () => + reducedLrData + .filter( + (point) => + point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], + ) + .map((point) => point.lr), + [reducedLrData, visibleStepDomain], + ); + + const lossDomain = useMemo( + () => buildYDomain([...visibleLossValues, ...visibleSmoothValues]), + [visibleLossValues, visibleSmoothValues], + ); + const gradDomain = useMemo(() => buildYDomain(visibleGradValues), [visibleGradValues]); + const lrDomain = useMemo(() => buildYDomain(visibleLrValues), [visibleLrValues]); + const avg = metrics.lossHistory.length > 0 ? +( @@ -96,8 +262,7 @@ export function ChartsContent({ : 0; return ( -
- {/* Training Loss */} +
Training Loss @@ -106,7 +271,7 @@ export function ChartsContent({ @@ -155,12 +320,10 @@ export function ChartsContent({ - + @@ -168,19 +331,27 @@ export function ChartsContent({ formatStepTick(Number(value))} interval="preserveStartEnd" /> Number(value).toFixed(2)} /> )} {showSmoothed && ( )} @@ -232,7 +411,6 @@ export function ChartsContent({ - {/* Grad Norm */} Gradient Norm @@ -240,10 +418,11 @@ export function ChartsContent({ @@ -251,19 +430,27 @@ export function ChartsContent({ formatStepTick(Number(value))} interval="preserveStartEnd" /> Number(value).toFixed(2)} /> } /> @@ -288,18 +479,15 @@ export function ChartsContent({ - {/* Learning Rate */} Learning Rate - + @@ -307,20 +495,27 @@ export function ChartsContent({ formatStepTick(Number(value))} interval="preserveStartEnd" /> v.toExponential(0)} + width={52} + tickFormatter={(value) => Number(value).toExponential(0)} /> `Step ${payload?.[0]?.payload?.step ?? ""}` } - formatter={(value) => [ - Number(value).toExponential(3), - "LR", - ]} + formatter={(value) => [Number(value).toExponential(3), "LR"]} /> } /> } /> @@ -349,7 +545,6 @@ export function ChartsContent({ - {/* Eval Loss (disabled/blurred) */} @@ -360,7 +555,7 @@ export function ChartsContent({
= { + idle: "Idle", + loading_model: "Loading model", + loading_dataset: "Loading dataset", + configuring: "Configuring", + training: "Training", + completed: "Completed", + error: "Error", + stopped: "Stopped", +}; + +const phaseColors: Record = { + idle: "bg-muted text-muted-foreground", + loading_model: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", + loading_dataset: + "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", + configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + training: + "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", + completed: + "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", + error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", + stopped: "bg-muted text-muted-foreground", +}; + +function formatDuration(seconds: number | null): string { + if (seconds == null || seconds < 0) { + return "--"; } + const total = Math.floor(seconds); + const min = Math.floor(total / 60); + const sec = total % 60; + return `${min}m ${sec}s`; +} - const pct = Math.round((metrics.currentStep / metrics.totalSteps) * 100); - const etaSec = - metrics.totalSteps > 0 - ? Math.round( - ((metrics.totalSteps - metrics.currentStep) / - Math.max(metrics.currentStep, 1)) * - metrics.elapsed, +function formatNumber(value: number | null | undefined, digits: number): string { + if (value == null || !Number.isFinite(value)) { + return "--"; + } + return value.toFixed(digits); +} + +export function ProgressSection(): ReactElement { + const runtime = useTrainingRuntimeStore( + useShallow((state) => ({ + phase: state.phase, + message: state.message, + error: state.error, + currentStep: state.currentStep, + totalSteps: state.totalSteps, + currentEpoch: state.currentEpoch, + currentLoss: state.currentLoss, + currentLearningRate: state.currentLearningRate, + currentGradNorm: state.currentGradNorm, + progressPercent: state.progressPercent, + elapsedSeconds: state.elapsedSeconds, + etaSeconds: state.etaSeconds, + currentNumTokens: state.currentNumTokens, + isTrainingRunning: state.isTrainingRunning, + })), + ); + + const config = useTrainingConfigStore( + useShallow((state) => ({ + selectedModel: state.selectedModel, + trainingMethod: state.trainingMethod, + epochs: state.epochs, + batchSize: state.batchSize, + learningRate: state.learningRate, + maxSteps: state.maxSteps, + contextLength: state.contextLength, + warmupSteps: state.warmupSteps, + loraRank: state.loraRank, + loraAlpha: state.loraAlpha, + loraDropout: state.loraDropout, + loraVariant: state.loraVariant, + })), + ); + + const { stopTrainingRun } = useTrainingActions(); + + const pct = + runtime.totalSteps > 0 + ? Math.min( + 100, + Math.max( + 0, + Math.round((runtime.currentStep / runtime.totalSteps) * 100), + ), ) - : 0; - const fmtTime = (s: number) => { - const m = Math.floor(s / 60); - const sec = s % 60; - return `${m}m ${sec}s`; - }; + : Math.round(runtime.progressPercent); - const statusColors = { - training: - "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", - warmup: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", - saving: - "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", - }; - const statusLabels = { - training: "Training", - warmup: "Warming up", - saving: "Saving checkpoint", - }; + const elapsed = runtime.elapsedSeconds; + const derivedEta = + elapsed != null && pct > 0 + ? Math.round((elapsed * (100 - pct)) / Math.max(pct, 1)) + : null; + const eta = runtime.etaSeconds ?? derivedEta; - const modelName = store.selectedModel ?? "—"; + const stepsPerSecond = + elapsed != null && elapsed > 0 + ? runtime.currentStep / elapsed + : null; const configItems = [ { section: "Hyperparams", rows: [ - ["Epochs", store.epochs], - ["Batch size", store.batchSize], - ["Learning rate", store.learningRate], - ["Max steps", store.maxSteps], - ["Context length", store.contextLength], - ["Warmup steps", store.warmupSteps], + ["Epochs", config.epochs], + ["Batch size", config.batchSize], + ["Learning rate", config.learningRate], + ["Max steps", config.maxSteps], + ["Context length", config.contextLength], + ["Warmup steps", config.warmupSteps], ], }, - ...(store.trainingMethod !== "full" + ...(config.trainingMethod !== "full" ? [ { section: "LoRA", rows: [ - ["Rank", store.loraRank], - ["Alpha", store.loraAlpha], - ["Dropout", store.loraDropout], - ["Variant", store.loraVariant], + ["Rank", config.loraRank], + ["Alpha", config.loraAlpha], + ["Dropout", config.loraDropout], + ["Variant", config.loraVariant], ], }, ] @@ -86,7 +159,7 @@ export function ProgressSection(): ReactElement | null { } title="Training Progress" - description="Live training metrics" + description={runtime.message || "Live training metrics"} accent="emerald" className="shadow-border ring-1 ring-border" headerAction={ @@ -130,7 +203,8 @@ export function ProgressSection(): ReactElement | null { variant="destructive" size="sm" className="h-7 cursor-pointer px-3 text-xs" - onClick={() => store.setIsTraining(false)} + onClick={() => void stopTrainingRun()} + disabled={!runtime.isTrainingRunning} > Stop @@ -138,24 +212,22 @@ export function ProgressSection(): ReactElement | null { } >
- {/* Left: Progress */}
- {statusLabels[metrics.status]} + {phaseLabel[runtime.phase]} - Epoch {metrics.currentEpoch.toFixed(2)} / {metrics.totalEpochs} + Epoch {runtime.currentEpoch.toFixed(2)}
- {/* Progress bar */}
- Step {metrics.currentStep} / {metrics.totalSteps} + Step {runtime.currentStep} / {runtime.totalSteps || "--"} {pct}%
@@ -167,51 +239,59 @@ export function ProgressSection(): ReactElement | null {
- {/* Metrics */} -
+ {runtime.error && ( +

{runtime.error}

+ )} + +

Loss

- {metrics.currentLoss.toFixed(4)} + {runtime.currentLoss.toFixed(4)}

LR

- {metrics.currentLR.toExponential(2)} + {runtime.currentLearningRate.toExponential(2)}

Grad Norm

- {metrics.gradNorm.toFixed(3)} + {formatNumber(runtime.currentGradNorm, 3)}

Model

- {modelName} + {config.selectedModel ?? "--"}

Method

-

{store.trainingMethod}

+

+ {config.trainingMethod.toUpperCase()} +

- {/* Timings */} -
- Elapsed: {fmtTime(metrics.elapsed)} - ETA: {fmtTime(etaSec)} - {metrics.samplesPerSecond} samples/s +
+ Elapsed: {formatDuration(elapsed)} + ETA: {formatDuration(eta)} + + {stepsPerSecond == null + ? "-- steps/s" + : `${stepsPerSecond.toFixed(2)} steps/s`} + + + Tokens: {runtime.currentNumTokens == null ? "--" : runtime.currentNumTokens} +
- {/* Right: GPU */}
-

- GPU Monitor -

+

GPU Monitor

} - value={`${metrics.gpuUtil}%`} - pct={metrics.gpuUtil} + value="--" + pct={0} /> - } - value={`${metrics.gpuTemp}°C`} - pct={metrics.gpuTemp} + icon={} + value="--" + pct={0} max={100} /> } - value={`${metrics.gpuVramUsed.toFixed(1)} / ${metrics.gpuVramTotal}GB`} - pct={(metrics.gpuVramUsed / metrics.gpuVramTotal) * 100} + value="--" + pct={0} /> } - value={`${metrics.gpuPower}W`} - pct={(metrics.gpuPower / 350) * 100} + value="--" + pct={0} />
@@ -272,6 +350,7 @@ function GpuStat({ } else if (clamped < 95) { barColor = "bg-amber-500"; } + return (
diff --git a/studio/frontend/src/features/studio/training-start-overlay.tsx b/studio/frontend/src/features/studio/training-start-overlay.tsx new file mode 100644 index 0000000000..b6c413b400 --- /dev/null +++ b/studio/frontend/src/features/studio/training-start-overlay.tsx @@ -0,0 +1,58 @@ +import { + AnimatedSpan, + Terminal, + TypingAnimation, +} from "@/components/ui/terminal" +import type { ReactElement } from "react" + +type TrainingStartOverlayProps = { + message: string + currentStep: number +} + +export function TrainingStartOverlay({ + message, + currentStep, +}: TrainingStartOverlayProps): ReactElement { + return ( +
+
+ Unsloth mascot + + + {"> unsloth training starts..."} + + +
{`==((====))==
+\\\\   /|
+O^O/ \\_/ \\
+\\        /
+ "-____-"`}
+
+ + {"> Preparing model and dataset..."} + + + {"> We are getting everything ready for your run..."} + + + {"> Did you know, Mugi is actually short for \"Mugiwara\" xd"} + + + {`> ${message || "starting training..."} | waiting for first step... (${currentStep})`} + +
+
+
+ ) +} diff --git a/studio/frontend/src/features/studio/training-view.tsx b/studio/frontend/src/features/studio/training-view.tsx index f1120b0ac2..e10faa6735 100644 --- a/studio/frontend/src/features/studio/training-view.tsx +++ b/studio/frontend/src/features/studio/training-view.tsx @@ -1,164 +1,47 @@ -import { useWizardStore } from "@/stores/training"; -import type { TrainingMetrics } from "@/types/training"; -import { type ReactElement, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { useTrainingRuntimeStore } from "@/features/training"; +import type { ReactElement } from "react"; +import { useShallow } from "zustand/react/shallow"; import { ChartsSection } from "./sections/charts-section"; import { ProgressSection } from "./sections/progress-section"; - -function createInitialMetrics( - totalSteps: number, - totalEpochs: number, - lr: number, -): TrainingMetrics { - return { - currentStep: 0, - totalSteps, - currentEpoch: 0, - totalEpochs, - currentLoss: 2.5, - currentLR: lr * 0.1, - gradNorm: 0, - samplesPerSecond: 0, - lossHistory: [], - lrHistory: [], - gradNormHistory: [], - gpuUtil: 0, - gpuTemp: 45, - gpuVramUsed: 0, - gpuVramTotal: 24, - gpuPower: 50, - elapsed: 0, - status: "warmup", - }; -} +import { TrainingStartOverlay } from "./training-start-overlay"; export function TrainingView(): ReactElement { - const { maxSteps, epochs, learningRate, warmupSteps, setTrainingMetrics } = - useWizardStore(); - const metricsRef = useRef | null>(null); - const chartsRef = useRef | null>(null); + const runtime = useTrainingRuntimeStore( + useShallow((state) => ({ + phase: state.phase, + message: state.message, + currentStep: state.currentStep, + firstStepReceived: state.firstStepReceived, + isStarting: state.isStarting, + })), + ); - useEffect(() => { - const totalSteps = maxSteps || 500; - const totalEpochs = epochs || 3; - const peakLR = learningRate; - const warmup = warmupSteps || 20; - - setTrainingMetrics(createInitialMetrics(totalSteps, totalEpochs, peakLR)); - - let step = 0; - let elapsed = 0; - - const computeStep = () => { - step++; - if (step > totalSteps) { - return null; - } - elapsed++; - - let lr: number; - if (step < warmup) { - lr = peakLR * (step / warmup); - } else { - const progress = (step - warmup) / (totalSteps - warmup); - lr = peakLR * 0.5 * (1 + Math.cos(Math.PI * progress)); - } - - const baseLoss = 2.5 * Math.exp((-3 * step) / totalSteps) + 0.3; - const noise = (Math.random() - 0.5) * 0.08; - const loss = Math.max(0.1, baseLoss + noise); - const status = - step < warmup ? "warmup" : step % 100 === 0 ? "saving" : "training"; - const gradNorm = +( - 1.2 * Math.exp(-step / totalSteps) + - 0.1 + - (Math.random() - 0.5) * 0.05 - ).toFixed(3); - - return { - step, - elapsed, - lr, - loss: +loss.toFixed(4), - status: status as TrainingMetrics["status"], - gradNorm, - }; - }; - - // Top card values — update every 1s - metricsRef.current = setInterval(() => { - const s = computeStep(); - if (!s) { - if (metricsRef.current) { - clearInterval(metricsRef.current); - } - if (chartsRef.current) { - clearInterval(chartsRef.current); - } - return; - } - - const prev = useWizardStore.getState().trainingMetrics; - setTrainingMetrics({ - currentStep: s.step, - totalSteps, - currentEpoch: - Math.floor((s.step / totalSteps) * totalEpochs * 100) / 100, - totalEpochs, - currentLoss: s.loss, - currentLR: s.lr, - gradNorm: s.gradNorm, - samplesPerSecond: +(12 + (Math.random() - 0.5) * 2).toFixed(1), - lossHistory: prev?.lossHistory ?? [], - lrHistory: prev?.lrHistory ?? [], - gradNormHistory: prev?.gradNormHistory ?? [], - gpuUtil: Math.min(99, 85 + Math.round((Math.random() - 0.5) * 10)), - gpuTemp: Math.min(89, 68 + Math.round((Math.random() - 0.5) * 6)), - gpuVramUsed: +(18.2 + (Math.random() - 0.5) * 0.4).toFixed(1), - gpuVramTotal: 24, - gpuPower: Math.round(280 + (Math.random() - 0.5) * 30), - elapsed: s.elapsed, - status: s.status, - }); - }, 1000); - - // Chart history — update every 5s - chartsRef.current = setInterval(() => { - const prev = useWizardStore.getState().trainingMetrics; - if (!prev || prev.currentStep === 0) { - return; - } - - setTrainingMetrics({ - ...prev, - lossHistory: [ - ...prev.lossHistory, - { step: prev.currentStep, loss: prev.currentLoss }, - ], - lrHistory: [ - ...prev.lrHistory, - { step: prev.currentStep, lr: prev.currentLR }, - ], - gradNormHistory: [ - ...prev.gradNormHistory, - { step: prev.currentStep, gradNorm: prev.gradNorm }, - ], - }); - }, 5000); - - return () => { - if (metricsRef.current) { - clearInterval(metricsRef.current); - } - if (chartsRef.current) { - clearInterval(chartsRef.current); - } - }; - }, [epochs, learningRate, maxSteps, setTrainingMetrics, warmupSteps]); + const isPreparingPhase = + runtime.phase === "loading_model" || + runtime.phase === "loading_dataset" || + runtime.phase === "configuring"; + const isWaitingForFirstStep = + runtime.phase === "training" && !runtime.firstStepReceived; + const showOverlay = + runtime.isStarting || + isPreparingPhase || + (isWaitingForFirstStep && runtime.currentStep <= 0); return ( -
- - +
+
+ + +
+ {showOverlay ? ( + + ) : null}
); }