SPB Git

spb/forge-studio Public

The Instruments of LLM training — a native macOS cockpit for Forge. Train language models from scratch on Apple Silicon without a terminal.

Swift 95.7% Shell 4.3%
10.9 KB · 268 lines swift
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2//3// Run dashboard: status strip, the loss chart (raw + bias-corrected EMA +4// val points, log/linear Y, best-val marker), and the console. Live data5// arrives via the supervisor's MetricsStore at ~1 Hz.6import Charts7import SwiftUI89struct RunDetailView: View {10    let run: Run11    @Environment(AppModel.self) private var app12    @State private var snapshot: MetricsStore.Snapshot?13    @State private var smoothing = 0.614    @State private var logScale = true15    @State private var showConsole = false16    @State private var historicStore: MetricsStore?17    @State private var xState = ChartXState()18    @State private var checkpointSteps: [Int] = []19    @State private var selectedCheckpoint: String?2021    private var isLive: Bool { app.supervisor.activeRunID == run.id }2223    private func scanCheckpoints() {24        let dir = URL(fileURLWithPath: run.outDirectory)25        checkpointSteps = ((try? FileManager.default.contentsOfDirectory(26            at: dir, includingPropertiesForKeys: nil)) ?? [])27            .compactMap { url -> Int? in28                let n = url.lastPathComponent29                guard n.hasPrefix("ckpt_"), n.hasSuffix(".bin"),30                      let s = Int(n.dropFirst(5).dropLast(4)) else { return nil }31                return s32            }33            .sorted()34    }3536    var body: some View {37        VStack(spacing: 0) {38            StatusStrip(run: run, snapshot: snapshot)39            Divider()40            ScrollView {41                VStack(alignment: .leading, spacing: 16) {42                    TrainingChartView(snapshot: snapshot, isLive: isLive,43                                      smoothing: $smoothing, logScale: $logScale,44                                      xState: xState,45                                      checkpointSteps: checkpointSteps,46                                      onCheckpointTap: { step in47                                          selectedCheckpoint = run.outDirectory48                                              + String(format: "/ckpt_%06d.bin", step)49                                      })50                    HStack(spacing: 24) {51                        secondaryChart(title: "Learning rate",52                                       series: snapshot?.lr ?? [], format: "%.2e")53                        secondaryChart(title: "Tokens/s",54                                       series: snapshot?.tokensPerSec ?? [], format: "%.0f")55                        secondaryChart(title: "Grad norm",56                                       series: snapshot?.gradNorm ?? [], format: "%.2f",57                                       threshold: run.config.train.gradClip)58                    }59                    if !run.state.isActive {60                        CheckpointsPanel(run: run, selected: $selectedCheckpoint)61                    }62                    DisclosureGroup("Console (\(app.supervisor.consoleLines.count) lignes)",63                                    isExpanded: $showConsole) {64                        ConsoleView(lines: isLive ? app.supervisor.consoleLines65                                                  : ["(run terminé — voir run.log)"])66                    }67                }68                .padding()69            }70        }71        .toolbar {72            ToolbarItem {73                Menu {74                    Button("Chart en PNG (2×)…") { exportPNG() }75                    Button("Métriques en CSV…") { exportCSV() }76                } label: {77                    Label("Exporter", systemImage: "square.and.arrow.up")78                }79                .disabled(snapshot == nil)80            }81            if isLive {82                ToolbarItem {83                    Button(role: .destructive) {84                        Task { await app.supervisor.stop() }85                    } label: {86                        Label("Stop", systemImage: "stop.fill")87                    }88                    .help("SIGTERM — forge ne checkpointe pas à l'arrêt ; reprise depuis ckpt_latest.bin")89                }90            }91        }92        .task(id: run.id) {93            scanCheckpoints()94            await refreshLoop()95        }96        .navigationTitle(run.name)97    }9899    // Secondary charts share the main chart's X window and crosshair100    // (linked axes): zooming/hovering up top moves everything.101    private func secondaryChart(title: String, series: [Downsampler.XY],102                                format: String, threshold: Double? = nil) -> some View {103        VStack(alignment: .leading, spacing: 4) {104            HStack {105                Text(title).font(.caption.weight(.semibold))106                Spacer()107                if let h = xState.hoverX,108                   let near = series.min(by: { abs($0.x - h) < abs($1.x - h) }) {109                    Text(String(format: format, near.y))110                        .font(.caption.monospacedDigit())111                        .foregroundStyle(.teal)112                } else if let last = series.last {113                    Text(String(format: format, last.y))114                        .font(.caption.monospacedDigit())115                        .foregroundStyle(.secondary)116                }117            }118            Chart {119                ForEach(series, id: \.x) { p in120                    LineMark(x: .value("step", p.x), y: .value("v", p.y))121                        .foregroundStyle(.teal)122                }123                if let t = threshold, t > 0 {124                    RuleMark(y: .value("clip", t))125                        .lineStyle(.init(lineWidth: 1, dash: [3, 3]))126                        .foregroundStyle(.red.opacity(0.5))127                }128                if let h = xState.hoverX {129                    RuleMark(x: .value("hover", h))130                        .foregroundStyle(.secondary.opacity(0.5))131                        .lineStyle(.init(lineWidth: 1))132                }133            }134            .chartXAxis(.hidden)135            .modifier(LinkedScrollModifier(xState: xState))136            .frame(height: 110)137        }138    }139140    private func exportPNG() {141        let chart = TrainingChartView(snapshot: snapshot, isLive: false,142                                      smoothing: $smoothing, logScale: $logScale,143                                      xState: ChartXState(),144                                      checkpointSteps: checkpointSteps)145            .frame(width: 1200, height: 500)146            .padding()147            .background(Color(nsColor: .windowBackgroundColor))148        let renderer = ImageRenderer(content: chart)149        renderer.scale = 2.0150        guard let image = renderer.nsImage,151              let tiff = image.tiffRepresentation,152              let png = NSBitmapImageRep(data: tiff)?153                  .representation(using: .png, properties: [:]) else { return }154        let panel = NSSavePanel()155        panel.nameFieldStringValue = "\(run.name)-loss.png"156        if panel.runModal() == .OK, let url = panel.url {157            try? png.write(to: url)158        }159    }160161    private func exportCSV() {162        let panel = NSSavePanel()163        panel.nameFieldStringValue = "\(run.name)-metrics.csv"164        if panel.runModal() == .OK, let url = panel.url {165            try? FileManager.default.removeItem(at: url)166            try? FileManager.default.copyItem(167                at: URL(fileURLWithPath: run.logCSVPath), to: url)168        }169    }170171    private func refreshLoop() async {172        if isLive {173            while !Task.isCancelled && app.supervisor.activeRunID == run.id {174                snapshot = await app.supervisor.metrics175                    .snapshot(maxPoints: 1200, smoothing: smoothing)176                try? await Task.sleep(for: .milliseconds(500))177            }178        }179        // Historical (or freshly finished): load the whole CSV once.180        let store = MetricsStore()181        historicStore = store182        await store.ingestCSV(at: URL(fileURLWithPath: run.logCSVPath))183        snapshot = await store.snapshot(maxPoints: 1200, smoothing: smoothing)184    }185}186187struct StatusStrip: View {188    let run: Run189    let snapshot: MetricsStore.Snapshot?190191    var body: some View {192        HStack(spacing: 20) {193            StateBadge(state: run.state)194            if let step = run.lastStep {195                VStack(alignment: .leading, spacing: 1) {196                    Text("step \(step + 1) / \(run.config.train.maxSteps)")197                        .font(.callout.monospacedDigit())198                    ProgressView(value: run.progress).frame(width: 140)199                }200            }201            if let last = snapshot?.lastPoint {202                metric("loss", String(format: "%.4f", last.trainLoss))203                metric("ppl", String(format: "%.1f", exp(last.trainLoss)))204                metric("tok/s", String(format: "%.0f", last.tokensPerSec))205                if let e = last.elapsedS {206                    metric("écoulé", Duration.seconds(e)207                        .formatted(.time(pattern: .hourMinuteSecond)))208                }209            }210            if let best = run.bestValLoss {211                metric("best val", String(format: "%.4f", best))212            }213            if run.state == .running, let growth = snapshot?.lastGrowth,214               Date.now.timeIntervalSince(growth) > 30 {215                Label("possiblement bloqué", systemImage: "exclamationmark.triangle")216                    .font(.caption)217                    .foregroundStyle(.orange)218                    .help("Aucune nouvelle métrique depuis \(Int(Date.now.timeIntervalSince(growth))) s — le run n'a pas été interrompu.")219            }220            Spacer()221            Text("\(run.config.model.paramCount.formatted(.number.notation(.compactName))) params")222                .foregroundStyle(.secondary)223        }224        .padding(.horizontal)225        .padding(.vertical, 10)226    }227228    private func metric(_ label: String, _ value: String) -> some View {229        VStack(alignment: .leading, spacing: 1) {230            Text(label).font(.caption2).foregroundStyle(.secondary)231            Text(value).font(.callout.monospacedDigit())232        }233    }234}235236struct ConsoleView: View {237    let lines: [String]238239    var body: some View {240        ScrollViewReader { proxy in241            ScrollView {242                LazyVStack(alignment: .leading, spacing: 0) {243                    ForEach(Array(lines.enumerated()), id: \.offset) { i, line in244                        Text(line)245                            .font(.system(.caption, design: .monospaced))246                            .textSelection(.enabled)247                            .id(i)248                    }249                }250                .frame(maxWidth: .infinity, alignment: .leading)251            }252            .frame(height: 200)253            .background(.black.opacity(0.85))254            .clipShape(RoundedRectangle(cornerRadius: 6))255            .onChange(of: lines.count) {256                proxy.scrollTo(lines.count - 1, anchor: .bottom)257            }258        }259    }260}261262extension View {263    @ViewBuilder264    func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {265        if condition { transform(self) } else { self }266    }267}268