oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1// Command bench measures the index, so §7's numbers are recorded
2// measurements rather than assertions.
3//
4// A review of draft 2 pointed out that every figure in §7 — 3.2 GFLOP/s,
5// 104 MB, 19 s — had no benchmark, command or machine behind it, and that
6// the FTS size could not be reconciled with the stated schema, because a
7// default FTS5 table keeps its own copy of the inserted text. Both are
8// fair, and both are answerable by measuring instead of arguing.
9package main
10
11import (
12 "crypto/rand"
13 "database/sql"
14 "flag"
15 "fmt"
16 "math"
17 "os"
18 "path/filepath"
19 "runtime"
20 "strings"
21 "time"
22
23 _ "modernc.org/sqlite"
24
25 "amadan.net/oficina/memoria/internal/memoria/chunk"
26)
27
28func main() {
29 corpus := flag.String("corpus", "", "directory of real text to build the corpus from")
30 target := flag.Int("chunks", 90000, "how many chunks to grow the corpus to")
31 flag.Parse()
32
33 fmt.Printf("machine: %s/%s, %d cpu, go %s\n\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU(), runtime.Version())
34
35 passages := realPassages(*corpus)
36 if len(passages) == 0 {
37 fmt.Fprintln(os.Stderr, "no corpus found; pass --corpus")
38 os.Exit(1)
39 }
40 avgWords := 0
41 avgBytes := 0
42 for _, p := range passages {
43 avgWords += len(strings.Fields(p))
44 avgBytes += len(p)
45 }
46 fmt.Printf("real corpus: %d passages, mean %d words / %d bytes per passage\n",
47 len(passages), avgWords/len(passages), avgBytes/len(passages))
48
49 // Grow to the target by cycling the real passages. Cycling repeats
50 // vocabulary, which makes the postings list SMALLER than genuinely
51 // varied English would — so the FTS figure below is optimistic, and
52 // says so.
53 grown := make([]string, 0, *target)
54 for i := 0; i < *target; i++ {
55 grown = append(grown, passages[i%len(passages)])
56 }
57 totalBytes := 0
58 for _, p := range grown {
59 totalBytes += len(p)
60 }
61 fmt.Printf("grown corpus: %d chunks, %.1f MB of text\n\n", len(grown), float64(totalBytes)/(1<<20))
62
63 for _, contentless := range []bool{false, true} {
64 measureFTS(grown, contentless)
65 }
66 measureCosine()
67}
68
69func measureFTS(chunks []string, contentless bool) {
70 label := "default (keeps its own copy of the text)"
71 schema := `CREATE VIRTUAL TABLE c USING fts5(body, tokenize='porter unicode61');`
72 if contentless {
73 label = "contentless (content='')"
74 schema = `CREATE VIRTUAL TABLE c USING fts5(body, content='', tokenize='porter unicode61');`
75 }
76 dir, err := os.MkdirTemp("/home/paulca/memoriadrive", "bench")
77 if err != nil {
78 panic(err)
79 }
80 defer os.RemoveAll(dir)
81 path := filepath.Join(dir, "fts.db")
82 db, err := sql.Open("sqlite", "file:"+path)
83 if err != nil {
84 panic(err)
85 }
86 defer db.Close()
87 if _, err := db.Exec(schema); err != nil {
88 panic(err)
89 }
90
91 start := time.Now()
92 tx, _ := db.Begin()
93 st, err := tx.Prepare(`INSERT INTO c (body) VALUES (?)`)
94 if err != nil {
95 panic(err)
96 }
97 for _, ch := range chunks {
98 if _, err := st.Exec(ch); err != nil {
99 panic(err)
100 }
101 }
102 st.Close()
103 if err := tx.Commit(); err != nil {
104 panic(err)
105 }
106 if _, err := db.Exec(`INSERT INTO c(c) VALUES('optimize');`); err != nil {
107 panic(err)
108 }
109 build := time.Since(start)
110 db.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`)
111
112 var size int64
113 for _, suffix := range []string{"", "-wal"} {
114 if fi, err := os.Stat(path + suffix); err == nil {
115 size += fi.Size()
116 }
117 }
118
119 // A query, warm.
120 qstart := time.Now()
121 var n int
122 row := db.QueryRow(`SELECT count(*) FROM c WHERE c MATCH '"revenue" OR "sealed"'`)
123 if err := row.Scan(&n); err != nil {
124 fmt.Printf(" query failed: %v\n", err)
125 }
126 q := time.Since(qstart)
127
128 fmt.Printf("FTS5 %s\n", label)
129 fmt.Printf(" index on disk : %.1f MB (%d bytes/chunk)\n", float64(size)/(1<<20), size/int64(len(chunks)))
130 fmt.Printf(" build time : %s\n", build.Round(time.Millisecond))
131 fmt.Printf(" one BM25 query: %s (%d rows matched)\n\n", q.Round(time.Microsecond), n)
132}
133
134// measureCosine times a brute-force pass, the number §7's latency table
135// is derived from.
136func measureCosine() {
137 fmt.Println("brute-force cosine, one goroutine, pure Go, no SIMD")
138 for _, c := range []struct{ n, d int }{{10000, 512}, {90000, 512}, {100000, 768}, {250000, 512}} {
139 vecs := make([]float32, c.n*c.d)
140 buf := make([]byte, len(vecs)*4)
141 rand.Read(buf)
142 for i := range vecs {
143 vecs[i] = float32(int8(buf[i*4])) / 128
144 }
145 q := make([]float32, c.d)
146 for i := range q {
147 q[i] = float32(i%17) / 17
148 }
149 // Three runs, report the best — a slow run measures the
150 // scheduler, not the arithmetic.
151 best := time.Duration(math.MaxInt64)
152 for run := 0; run < 3; run++ {
153 start := time.Now()
154 var bestScore float32
155 for i := 0; i+c.d <= len(vecs); i += c.d {
156 var dot float32
157 row := vecs[i : i+c.d]
158 for j, v := range row {
159 dot += v * q[j]
160 }
161 if dot > bestScore {
162 bestScore = dot
163 }
164 }
165 if e := time.Since(start); e < best {
166 best = e
167 }
168 }
169 flops := float64(2 * c.n * c.d)
170 fmt.Printf(" %7d chunks x %d dims: %8s (%.1f GFLOP/s) vectors %.0f MB f32 / %.0f MB int8\n",
171 c.n, c.d, best.Round(time.Microsecond), flops/best.Seconds()/1e9,
172 float64(c.n*c.d*4)/(1<<20), float64(c.n*c.d)/(1<<20))
173 }
174}
175
176// realPassages reads .md files and chunks them with MEMORIA'S OWN
177// chunker, so the measurement describes the real product rather than an
178// approximation of it. An earlier version split on blank lines and
179// reported a mean of 81 words per passage, which is a paragraph, not a
180// memoria chunk.
181func realPassages(dir string) []string {
182 var out []string
183 if dir == "" {
184 return nil
185 }
186 filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
187 if err != nil || info.IsDir() {
188 return nil
189 }
190 if !strings.HasSuffix(p, ".md") || strings.Contains(p, "/.") || strings.Contains(p, "node_modules") {
191 return nil
192 }
193 raw, err := os.ReadFile(p)
194 if err != nil {
195 return nil
196 }
197 for _, c := range chunk.Split(string(raw)) {
198 out = append(out, c.Indexable())
199 }
200 return nil
201 })
202 return out
203}
204