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%
9.1 KB · 227 lines swift
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2//3// Multi-run overlay: val-loss (and optional train-EMA) curves for 2–8 runs,4// X axis in steps, TOKENS (the honest axis when batch sizes differ) or5// wall-clock; legend annotated with the auto-computed hyperparameter diff6// (only the fields that differ across the selection); summary table + CSV7// export.8import Charts9import SwiftUI1011struct CompareView: View {12    @Environment(AppModel.self) private var app13    @State private var selected: Set<UUID> = []14    @State private var xMode: XMode = .steps15    @State private var showTrainEMA = false16    @State private var series: [UUID: CompareSeries] = [:]1718    enum XMode: String, CaseIterable {19        case steps = "steps"20        case tokens = "tokens"21        case time = "temps"22    }2324    struct CompareSeries {25        var val: [(x: Double, y: Double)] = []26        var ema: [(x: Double, y: Double)] = []27        var bestVal: Double?28        var finalPpl: Double?29        var meanTps: Double?30        var totalTime: Double?31    }3233    private let palette: [Color] = [.blue, .orange, .green, .purple, .red, .teal,34                                    .pink, .brown]3536    private var candidates: [Run] {37        app.store.runs.filter { FileManager.default.fileExists(atPath: $0.logCSVPath) }38            .sorted { $0.createdAt > $1.createdAt }39    }40    private var selectedRuns: [Run] { candidates.filter { selected.contains($0.id) } }4142    var body: some View {43        HSplitView {44            List(candidates, selection: $selected) { run in45                RunRow(run: run).tag(run.id)46            }47            .frame(minWidth: 230, maxWidth: 300)4849            VStack(alignment: .leading, spacing: 12) {50                HStack {51                    Text("Comparaison").font(.title2.weight(.semibold))52                    Spacer()53                    Toggle("train EMA", isOn: $showTrainEMA).toggleStyle(.checkbox)54                    Picker("", selection: $xMode) {55                        ForEach(XMode.allCases, id: \.self) { Text($0.rawValue) }56                    }57                    .pickerStyle(.segmented)58                    .frame(width: 220)59                    Button("Exporter CSV") { exportCSV() }60                        .disabled(selectedRuns.isEmpty)61                }62                if selectedRuns.count < 2 {63                    ContentUnavailableView(64                        "Sélectionnez 2 à 8 runs",65                        systemImage: "chart.xyaxis.line",66                        description: Text("⌘-clic dans la liste pour comparer leurs courbes de validation."))67                } else {68                    diffLegend69                    chart70                    summaryTable71                }72                Spacer(minLength: 0)73            }74            .padding()75        }76        .task(id: "\(selected.hashValue)|\(xMode.rawValue)") { await loadSeries() }77        .navigationTitle("Comparer")78    }7980    private var chart: some View {81        Chart {82            ForEach(Array(selectedRuns.enumerated()), id: \.element.id) { idx, run in83                let s = series[run.id]84                ForEach(s?.val ?? [], id: \.x) { p in85                    LineMark(x: .value("x", p.x), y: .value("val", p.y),86                             series: .value("run", run.name + " val"))87                        .foregroundStyle(palette[idx % palette.count])88                }89                if showTrainEMA {90                    ForEach(s?.ema ?? [], id: \.x) { p in91                        LineMark(x: .value("x", p.x), y: .value("ema", p.y),92                                 series: .value("run", run.name + " ema"))93                            .foregroundStyle(palette[idx % palette.count].opacity(0.4))94                    }95                }96            }97        }98        .chartXAxisLabel(xMode == .tokens ? "tokens vus"99                         : xMode == .time ? "secondes" : "steps")100        .frame(minHeight: 300)101    }102103    // Only the config fields that differ across the selection.104    private var diffLegend: some View {105        let diffs = configDiffs()106        return VStack(alignment: .leading, spacing: 4) {107            ForEach(Array(selectedRuns.enumerated()), id: \.element.id) { idx, run in108                HStack(spacing: 8) {109                    Circle().fill(palette[idx % palette.count]).frame(width: 9, height: 9)110                    Text(run.name).font(.callout.weight(.medium))111                    Text(diffs[run.id] ?? "").font(.caption)112                        .foregroundStyle(.secondary)113                }114            }115        }116    }117118    private func configDiffs() -> [UUID: String] {119        guard selectedRuns.count >= 2 else { return [:] }120        // Flatten each config to key: value strings via JSON.121        var flat: [UUID: [String: String]] = [:]122        for run in selectedRuns {123            guard let data = try? run.config.exportJSON(),124                  let obj = try? JSONSerialization.jsonObject(with: data)125                      as? [String: [String: Any]] else { continue }126            var kv: [String: String] = [:]127            for (section, fields) in obj {128                for (k, v) in fields { kv["\(section).\(k)"] = "\(v)" }129            }130            flat[run.id] = kv131        }132        let allKeys = Set(flat.values.flatMap(\.keys))133        let differing = allKeys.filter { key in134            Set(flat.values.map { $0[key] ?? "" }).count > 1135        }.sorted()136        var out: [UUID: String] = [:]137        for run in selectedRuns {138            out[run.id] = differing139                .filter { $0 != "model.name" }140                .map { "\($0.split(separator: ".").last!)=\(flat[run.id]?[$0] ?? "?")" }141                .joined(separator: "  ")142        }143        return out144    }145146    private var summaryTable: some View {147        Table(selectedRuns) {148            TableColumn("Run") { Text($0.name) }149            TableColumn("Params") {150                Text($0.config.model.paramCount.formatted(.number.notation(.compactName)))151            }152            TableColumn("Best val") { run in153                Text(series[run.id]?.bestVal.map { String(format: "%.4f", $0) } ?? "—")154            }155            TableColumn("PPL finale") { run in156                Text(series[run.id]?.finalPpl.map { String(format: "%.1f", $0) } ?? "—")157            }158            TableColumn("tok/s moyen") { run in159                Text(series[run.id]?.meanTps.map { String(format: "%.0f", $0) } ?? "—")160            }161            TableColumn("Durée") { run in162                Text(series[run.id]?.totalTime.map {163                    Duration.seconds($0).formatted(.time(pattern: .hourMinuteSecond))164                } ?? "—")165            }166        }167        .frame(height: CGFloat(40 + selectedRuns.count * 28))168    }169170    private func loadSeries() async {171        var out: [UUID: CompareSeries] = [:]172        for run in selectedRuns {173            let store = MetricsStore()174            await store.ingestCSV(at: URL(fileURLWithPath: run.logCSVPath))175            let snap = await store.snapshot(maxPoints: 800, smoothing: 0.6)176            let tps = Double(run.config.tokensPerStep)177            func xform(_ p: Downsampler.XY, elapsedLookup: Bool = false) -> (Double, Double) {178                switch xMode {179                case .steps: return (p.x, p.y)180                case .tokens: return ((p.x + 1) * tps, p.y)181                case .time: return (p.x, p.y) // refined below with elapsed182                }183            }184            var s = CompareSeries()185            if xMode == .time {186                // elapsed_s per step from the raw points.187                let pts = await store.points188                s.val = pts.compactMap { p in189                    guard let v = p.valLoss, let e = p.elapsedS else { return nil }190                    return (e, v)191                }192                s.ema = zip(pts, Smoothing.ema(pts.map(\.trainLoss), smoothing: 0.6))193                    .compactMap { p, e in p.elapsedS.map { ($0, e) } }194            } else {195                s.val = snap.val.map { xform($0) }196                s.ema = snap.trainEMA.map { xform($0) }197            }198            s.bestVal = snap.bestVal?.loss199            s.finalPpl = snap.lastPoint.map { exp($0.trainLoss) }200            let allPts = await store.points201            if !allPts.isEmpty {202                s.meanTps = allPts.map(\.tokensPerSec).reduce(0, +) / Double(allPts.count)203                s.totalTime = allPts.last?.elapsedS204            }205            out[run.id] = s206        }207        series = out208    }209210    private func exportCSV() {211        var csv = "run,params,best_val,final_ppl,mean_tok_s,total_s\n"212        for run in selectedRuns {213            let s = series[run.id]214            csv += "\(run.name),\(run.config.model.paramCount),"215            csv += "\(s?.bestVal.map { String($0) } ?? ""),"216            csv += "\(s?.finalPpl.map { String($0) } ?? ""),"217            csv += "\(s?.meanTps.map { String($0) } ?? ""),"218            csv += "\(s?.totalTime.map { String($0) } ?? "")\n"219        }220        let panel = NSSavePanel()221        panel.nameFieldStringValue = "compare.csv"222        if panel.runModal() == .OK, let url = panel.url {223            try? csv.write(to: url, atomically: true, encoding: .utf8)224        }225    }226}227