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%
7.2 KB · 173 lines swift
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2//3// Per-run checkpoints with Generate (streamed token-by-token) and Eval4// (loss/ppl) panels — thin drivers over `forge generate` / `forge eval`.5import SwiftUI67struct CheckpointsPanel: View {8    let run: Run9    @Binding var selected: String?10    @Environment(AppModel.self) private var app11    @State private var checkpoints: [Checkpoint] = []12    @State private var prompt = "Once upon a time"13    @State private var temp = 0.814    @State private var topK = 4015    @State private var maxTokens = 20016    @State private var output = ""17    @State private var evalResult: String?18    @State private var busy = false19    @State private var errorText: String?2021    var body: some View {22        VStack(alignment: .leading, spacing: 12) {23            Text("Checkpoints").font(.title3.weight(.semibold))24            if checkpoints.isEmpty {25                Text("Aucun checkpoint pour l'instant (écrits tous les \(run.config.train.checkpointEvery) steps).")26                    .foregroundStyle(.secondary)27            } else {28                Picker("Checkpoint", selection: $selected) {29                    ForEach(checkpoints) { ck in30                        Text("step \(ck.step) · \(ByteCountFormatter.string(fromByteCount: ck.sizeBytes, countStyle: .file))")31                            .tag(Optional(ck.path))32                    }33                }34                .frame(maxWidth: 420)3536                GroupBox("Génération") {37                    VStack(alignment: .leading, spacing: 8) {38                        TextField("Prompt", text: $prompt)39                        HStack {40                            Text("temp")41                            Slider(value: $temp, in: 0.1...1.5).frame(width: 100)42                            Text(String(format: "%.2f", temp)).monospacedDigit()43                            Stepper("top-k \(topK)", value: $topK, in: 1...200, step: 10)44                            Stepper("tokens \(maxTokens)", value: $maxTokens,45                                    in: 20...1000, step: 20)46                            Spacer()47                            Button(busy ? "…" : "Générer") { generate() }48                                .disabled(busy || selected == nil)49                        }50                        .font(.callout)51                        if !output.isEmpty {52                            ScrollView {53                                Text(output)54                                    .font(.system(.body, design: .serif))55                                    .textSelection(.enabled)56                                    .frame(maxWidth: .infinity, alignment: .leading)57                            }58                            .frame(height: 160)59                            .padding(8)60                            .background(.quaternary.opacity(0.4),61                                        in: RoundedRectangle(cornerRadius: 6))62                        }63                    }64                }6566                GroupBox("Évaluation") {67                    HStack {68                        Button(busy ? "…" : "Évaluer (50 batches)") { evaluate() }69                            .disabled(busy || selected == nil)70                        if let r = evalResult {71                            Text(r).font(.callout.monospacedDigit())72                        }73                        Spacer()74                    }75                }76            }77            if let e = errorText {78                Label(e, systemImage: "exclamationmark.triangle").foregroundStyle(.red)79            }80        }81        .onAppear { scan() }82    }8384    private func scan() {85        let dir = URL(fileURLWithPath: run.outDirectory)86        let files = (try? FileManager.default.contentsOfDirectory(87            at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]))88            ?? []89        checkpoints = files.compactMap { url in90            let name = url.lastPathComponent91            guard name.hasPrefix("ckpt_"), name.hasSuffix(".bin"),92                  let step = Int(name.dropFirst(5).dropLast(4)) else { return nil }93            let vals = try? url.resourceValues(forKeys: [.fileSizeKey,94                                                         .contentModificationDateKey])95            return Checkpoint(path: url.path, step: step,96                              sizeBytes: Int64(vals?.fileSize ?? 0),97                              modifiedAt: vals?.contentModificationDate ?? .now,98                              valLossAtStep: nil)99        }100        .sorted { $0.step > $1.step }101        if selected == nil { selected = checkpoints.first?.path }102    }103104    private var tokenizerPath: String? {105        let dir = URL(fileURLWithPath: run.datasetPath)106        return (try? FileManager.default.contentsOfDirectory(107            at: dir, includingPropertiesForKeys: nil))?108            .first { $0.pathExtension == "model" }?.path109    }110111    private func generate() {112        guard let ckpt = selected, let forge = ForgeBinaryLocator.savedBinaryURL,113              let tok = tokenizerPath else {114            errorText = "tokenizer introuvable dans le dossier du dataset"115            return116        }117        busy = true118        output = ""119        errorText = nil120        Task {121            defer { busy = false }122            do {123                let runner = ProcessRunner()124                let (lines, exit) = try await runner.launch(125                    executable: forge,126                    arguments: ["generate", "--checkpoint", ckpt,127                                "--tokenizer", tok, "--prompt", prompt,128                                "--temp", String(temp), "--top-k", String(topK),129                                "--max-tokens", String(maxTokens)],130                    currentDirectory: forge.deletingLastPathComponent())131                for await (line, isErr) in lines where !isErr {132                    output += line + "\n"133                }134                let status = await exit.value135                if status.code != 0 {136                    errorText = "forge generate a échoué (code \(status.code))"137                }138            } catch {139                errorText = error.localizedDescription140            }141        }142    }143144    private func evaluate() {145        guard let ckpt = selected, let forge = ForgeBinaryLocator.savedBinaryURL146        else { return }147        let valBin = run.datasetPath + "/val.bin"148        busy = true149        evalResult = nil150        errorText = nil151        Task {152            defer { busy = false }153            do {154                let runner = ProcessRunner()155                let (lines, exit) = try await runner.launch(156                    executable: forge,157                    arguments: ["eval", "--checkpoint", ckpt, "--data", valBin,158                                "--batches", "50"],159                    currentDirectory: forge.deletingLastPathComponent())160                for await (line, _) in lines where line.hasPrefix("val loss") {161                    evalResult = line162                }163                let status = await exit.value164                if status.code != 0 {165                    errorText = "forge eval a échoué (code \(status.code))"166                }167            } catch {168                errorText = error.localizedDescription169            }170        }171    }172}173