A job names the catalog model the adapter is for, the safetensors weights to train on, the node and the MiB of a card it needs, and question/answer pairs. The control plane sends it when the node's agent is there and nothing else trains on it; the agent trains with its installed vLLM's torch and a pinned overlay of peft and accelerate (no second environment), as the engines' user, on the card with that much free — beside an engine, if it leaves that much — converts the adapter to GGUF with the installed llama.cpp's own conversion code when the model is served by llama.cpp, and sends what it made back in pieces, kept in the store to download. Progress, loss and peak card memory are reported as it goes; a node that goes fails its job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
165 lines
5.0 KiB
Go
165 lines
5.0 KiB
Go
package train
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// fakePython stands in for an engine's interpreter: as the trainer it
|
|
// prints progress and writes an adapter; as the converter it writes a GGUF;
|
|
// told to fail, it fails.
|
|
const fakePython = `#!/bin/sh
|
|
case "$1" in
|
|
*trainer.py)
|
|
out=$(sed -n 's/.*"out": *"\([^"]*\)".*/\1/p' "$2")
|
|
grep -q FAIL "$(dirname "$2")/data.jsonl" && { echo "CUDA out of memory" >&2; exit 1; }
|
|
echo '{"state":"preparing","message":"fetching base"}'
|
|
echo '{"state":"running","step":5,"steps":10,"loss":0.9,"peak_mib":1900}'
|
|
mkdir -p "$out"; echo '{"r":16}' > "$out/adapter_config.json"; echo weights > "$out/adapter_model.safetensors"
|
|
echo '{"state":"trained","step":10,"steps":10,"peak_mib":2100,"base_dir":"/base"}'
|
|
;;
|
|
*convert_lora_to_gguf.py)
|
|
while [ $# -gt 0 ]; do [ "$1" = "--outfile" ] && echo GGUF > "$2"; shift; done
|
|
;;
|
|
esac
|
|
`
|
|
|
|
func runner(t *testing.T, free int) *Runner {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
py := filepath.Join(dir, "python3")
|
|
_ = os.WriteFile(py, []byte(fakePython), 0o755)
|
|
uv := filepath.Join(dir, "uv")
|
|
_ = os.WriteFile(uv, []byte("#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = \"--target\" ] && mkdir -p \"$2\"; shift; done\n"), 0o755)
|
|
return New(Env{WorkDir: filepath.Join(dir, "work"), Python: py, UV: uv, ConvDir: dir,
|
|
GPUs: func(context.Context) ([]GPU, error) { return []GPU{{0, 500}, {1, free}}, nil }})
|
|
}
|
|
|
|
type sink struct {
|
|
mu sync.Mutex
|
|
statuses []Status
|
|
parts map[string][]byte
|
|
lasts int
|
|
}
|
|
|
|
func (s *sink) status(st Status) { s.mu.Lock(); s.statuses = append(s.statuses, st); s.mu.Unlock() }
|
|
func (s *sink) artifact(kind string, _ int, b []byte, last bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.parts == nil {
|
|
s.parts = map[string][]byte{}
|
|
}
|
|
s.parts[kind] = append(s.parts[kind], b...)
|
|
if last {
|
|
s.lasts++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestAJobTrainsOnTheFreestCardAndSendsWhatItMade(t *testing.T) {
|
|
r := runner(t, 3000)
|
|
s := &sink{}
|
|
err := r.Run(context.Background(), Job{ID: "j1", BaseRepo: "Qwen/x", Data: []byte(`{"q":"a","a":"b"}`), GPUMemoryMiB: 2500, GGUF: true},
|
|
s.status, s.artifact)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var running bool
|
|
for _, st := range s.statuses {
|
|
if st.State == "running" && st.Step == 5 && st.Steps == 10 && st.PeakMiB == 1900 {
|
|
running = true
|
|
}
|
|
}
|
|
if !running {
|
|
t.Errorf("statuses %+v", s.statuses)
|
|
}
|
|
if string(s.parts["gguf"]) != "GGUF\n" || s.lasts != 2 {
|
|
t.Errorf("gguf %q, lasts %d", s.parts["gguf"], s.lasts)
|
|
}
|
|
files := map[string]string{}
|
|
gz, err := gzip.NewReader(bytes.NewReader(s.parts["peft"]))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
for {
|
|
h, err := tr.Next()
|
|
if err != nil {
|
|
break
|
|
}
|
|
b, _ := io.ReadAll(tr)
|
|
files[h.Name] = string(b)
|
|
}
|
|
if files["adapter_model.safetensors"] != "weights\n" || files["adapter_config.json"] == "" {
|
|
t.Errorf("peft archive %v", files)
|
|
}
|
|
// Nothing of the job is left on the node.
|
|
if entries, _ := os.ReadDir(filepath.Join(r.env.WorkDir, "jobs")); len(entries) != 0 {
|
|
t.Errorf("left behind: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestAJobWaitsForNoCardItDoesNotFit(t *testing.T) {
|
|
r := runner(t, 1000)
|
|
err := r.Run(context.Background(), Job{ID: "j1", Data: []byte(`{"q":"a","a":"b"}`), GPUMemoryMiB: 2500}, (&sink{}).status, (&sink{}).artifact)
|
|
if err == nil || !strings.Contains(err.Error(), "no card here has 2500 MiB free") {
|
|
t.Errorf("got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestATrainerThatFailsSaysWhy(t *testing.T) {
|
|
r := runner(t, 3000)
|
|
err := r.Run(context.Background(), Job{ID: "j1", Data: []byte(`{"q":"FAIL","a":"b"}`)}, (&sink{}).status, (&sink{}).artifact)
|
|
if err == nil || !strings.Contains(err.Error(), "CUDA out of memory") {
|
|
t.Errorf("got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOneJobAtATime(t *testing.T) {
|
|
r := runner(t, 3000)
|
|
r.job = "other"
|
|
if err := r.Run(context.Background(), Job{ID: "j2"}, (&sink{}).status, (&sink{}).artifact); err != ErrBusy {
|
|
t.Errorf("got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCardsAreReadFromNvidiaSMI(t *testing.T) {
|
|
got := parseGPUs("0, 2310\n1, 80000\nbad line\n")
|
|
if len(got) != 2 || got[1].FreeMiB != 80000 || got[0].Index != 0 {
|
|
t.Errorf("parsed %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestOnlyTheConversionCodeIsKeptOfLlamaCpp(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gz)
|
|
for _, name := range []string{"llama.cpp-b1/convert_lora_to_gguf.py", "llama.cpp-b1/conversion/qwen.py",
|
|
"llama.cpp-b1/gguf-py/gguf/__init__.py", "llama.cpp-b1/src/llama.cpp", "llama.cpp-b1/../evil"} {
|
|
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
|
|
_, _ = tw.Write([]byte("x"))
|
|
}
|
|
_ = tw.Close()
|
|
_ = gz.Close()
|
|
dir := filepath.Join(t.TempDir(), "conv")
|
|
if err := untarConversion(&buf, dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"convert_lora_to_gguf.py", "conversion/qwen.py", "gguf-py/gguf/__init__.py"} {
|
|
if _, err := os.Stat(filepath.Join(dir, want)); err != nil {
|
|
t.Errorf("%s not kept", want)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "src")); err == nil {
|
|
t.Error("the rest of llama.cpp was kept")
|
|
}
|
|
}
|