| new file mode 100644 |
| index 0000000..9a280b4 |
| --- /dev/null |
| +++ b/tools/bench/main.go |
| @@ -0,0 +1,203 @@ |
| +// Command bench measures the index, so §7's numbers are recorded |
| +// measurements rather than assertions. |
| +// |
| +// A review of draft 2 pointed out that every figure in §7 — 3.2 GFLOP/s, |
| +// 104 MB, 19 s — had no benchmark, command or machine behind it, and that |
| +// the FTS size could not be reconciled with the stated schema, because a |
| +// default FTS5 table keeps its own copy of the inserted text. Both are |
| +// fair, and both are answerable by measuring instead of arguing. |
| +package main |
| + |
| +import ( |
| + "crypto/rand" |
| + "database/sql" |
| + "flag" |
| + "fmt" |
| + "math" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "time" |
| + |
| + _ "modernc.org/sqlite" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/chunk" |
| +) |
| + |
| +func main() { |
| + corpus := flag.String("corpus", "", "directory of real text to build the corpus from") |
| + target := flag.Int("chunks", 90000, "how many chunks to grow the corpus to") |
| + flag.Parse() |
| + |
| + fmt.Printf("machine: %s/%s, %d cpu, go %s\n\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU(), runtime.Version()) |
| + |
| + passages := realPassages(*corpus) |
| + if len(passages) == 0 { |
| + fmt.Fprintln(os.Stderr, "no corpus found; pass --corpus") |
| + os.Exit(1) |
| + } |
| + avgWords := 0 |
| + avgBytes := 0 |
| + for _, p := range passages { |
| + avgWords += len(strings.Fields(p)) |
| + avgBytes += len(p) |
| + } |
| + fmt.Printf("real corpus: %d passages, mean %d words / %d bytes per passage\n", |
| + len(passages), avgWords/len(passages), avgBytes/len(passages)) |
| + |
| + // Grow to the target by cycling the real passages. Cycling repeats |
| + // vocabulary, which makes the postings list SMALLER than genuinely |
| + // varied English would — so the FTS figure below is optimistic, and |
| + // says so. |
| + grown := make([]string, 0, *target) |
| + for i := 0; i < *target; i++ { |
| + grown = append(grown, passages[i%len(passages)]) |
| + } |
| + totalBytes := 0 |
| + for _, p := range grown { |
| + totalBytes += len(p) |
| + } |
| + fmt.Printf("grown corpus: %d chunks, %.1f MB of text\n\n", len(grown), float64(totalBytes)/(1<<20)) |
| + |
| + for _, contentless := range []bool{false, true} { |
| + measureFTS(grown, contentless) |
| + } |
| + measureCosine() |
| +} |
| + |
| +func measureFTS(chunks []string, contentless bool) { |
| + label := "default (keeps its own copy of the text)" |
| + schema := `CREATE VIRTUAL TABLE c USING fts5(body, tokenize='porter unicode61');` |
| + if contentless { |
| + label = "contentless (content='')" |
| + schema = `CREATE VIRTUAL TABLE c USING fts5(body, content='', tokenize='porter unicode61');` |
| + } |
| + dir, err := os.MkdirTemp("/home/paulca/memoriadrive", "bench") |
| + if err != nil { |
| + panic(err) |
| + } |
| + defer os.RemoveAll(dir) |
| + path := filepath.Join(dir, "fts.db") |
| + db, err := sql.Open("sqlite", "file:"+path) |
| + if err != nil { |
| + panic(err) |
| + } |
| + defer db.Close() |
| + if _, err := db.Exec(schema); err != nil { |
| + panic(err) |
| + } |
| + |
| + start := time.Now() |
| + tx, _ := db.Begin() |
| + st, err := tx.Prepare(`INSERT INTO c (body) VALUES (?)`) |
| + if err != nil { |
| + panic(err) |
| + } |
| + for _, ch := range chunks { |
| + if _, err := st.Exec(ch); err != nil { |
| + panic(err) |
| + } |
| + } |
| + st.Close() |
| + if err := tx.Commit(); err != nil { |
| + panic(err) |
| + } |
| + if _, err := db.Exec(`INSERT INTO c(c) VALUES('optimize');`); err != nil { |
| + panic(err) |
| + } |
| + build := time.Since(start) |
| + db.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`) |
| + |
| + var size int64 |
| + for _, suffix := range []string{"", "-wal"} { |
| + if fi, err := os.Stat(path + suffix); err == nil { |
| + size += fi.Size() |
| + } |
| + } |
| + |
| + // A query, warm. |
| + qstart := time.Now() |
| + var n int |
| + row := db.QueryRow(`SELECT count(*) FROM c WHERE c MATCH '"revenue" OR "sealed"'`) |
| + if err := row.Scan(&n); err != nil { |
| + fmt.Printf(" query failed: %v\n", err) |
| + } |
| + q := time.Since(qstart) |
| + |
| + fmt.Printf("FTS5 %s\n", label) |
| + fmt.Printf(" index on disk : %.1f MB (%d bytes/chunk)\n", float64(size)/(1<<20), size/int64(len(chunks))) |
| + fmt.Printf(" build time : %s\n", build.Round(time.Millisecond)) |
| + fmt.Printf(" one BM25 query: %s (%d rows matched)\n\n", q.Round(time.Microsecond), n) |
| +} |
| + |
| +// measureCosine times a brute-force pass, the number §7's latency table |
| +// is derived from. |
| +func measureCosine() { |
| + fmt.Println("brute-force cosine, one goroutine, pure Go, no SIMD") |
| + for _, c := range []struct{ n, d int }{{10000, 512}, {90000, 512}, {100000, 768}, {250000, 512}} { |
| + vecs := make([]float32, c.n*c.d) |
| + buf := make([]byte, len(vecs)*4) |
| + rand.Read(buf) |
| + for i := range vecs { |
| + vecs[i] = float32(int8(buf[i*4])) / 128 |
| + } |
| + q := make([]float32, c.d) |
| + for i := range q { |
| + q[i] = float32(i%17) / 17 |
| + } |
| + // Three runs, report the best — a slow run measures the |
| + // scheduler, not the arithmetic. |
| + best := time.Duration(math.MaxInt64) |
| + for run := 0; run < 3; run++ { |
| + start := time.Now() |
| + var bestScore float32 |
| + for i := 0; i+c.d <= len(vecs); i += c.d { |
| + var dot float32 |
| + row := vecs[i : i+c.d] |
| + for j, v := range row { |
| + dot += v * q[j] |
| + } |
| + if dot > bestScore { |
| + bestScore = dot |
| + } |
| + } |
| + if e := time.Since(start); e < best { |
| + best = e |
| + } |
| + } |
| + flops := float64(2 * c.n * c.d) |
| + fmt.Printf(" %7d chunks x %d dims: %8s (%.1f GFLOP/s) vectors %.0f MB f32 / %.0f MB int8\n", |
| + c.n, c.d, best.Round(time.Microsecond), flops/best.Seconds()/1e9, |
| + float64(c.n*c.d*4)/(1<<20), float64(c.n*c.d)/(1<<20)) |
| + } |
| +} |
| + |
| +// realPassages reads .md files and chunks them with MEMORIA'S OWN |
| +// chunker, so the measurement describes the real product rather than an |
| +// approximation of it. An earlier version split on blank lines and |
| +// reported a mean of 81 words per passage, which is a paragraph, not a |
| +// memoria chunk. |
| +func realPassages(dir string) []string { |
| + var out []string |
| + if dir == "" { |
| + return nil |
| + } |
| + filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { |
| + if err != nil || info.IsDir() { |
| + return nil |
| + } |
| + if !strings.HasSuffix(p, ".md") || strings.Contains(p, "/.") || strings.Contains(p, "node_modules") { |
| + return nil |
| + } |
| + raw, err := os.ReadFile(p) |
| + if err != nil { |
| + return nil |
| + } |
| + for _, c := range chunk.Split(string(raw)) { |
| + out = append(out, c.Indexable()) |
| + } |
| + return nil |
| + }) |
| + return out |
| +} |