]> git.giacomolli.uk - onechat/blob - probe.go
Measure the compactor with a probe run
[onechat] / probe.go
1 package main
2
3 import (
4         "bufio"
5         "context"
6         "encoding/json"
7         "errors"
8         "fmt"
9         "io"
10         "maps"
11         "os"
12         "path/filepath"
13         "slices"
14         "strconv"
15         "strings"
16         "time"
17 )
18
19 const (
20         // probeName is the agent name of a probe run. The cache key comes from
21         // it.
22         probeName = "Probe"
23
24         // probeQuotaLimit is how many points the used percent of a quota window
25         // can grow in a probe run before the run stops.
26         probeQuotaLimit = 15.0
27
28         // probeLineBytes is the most bytes of one line of a probe log.
29         probeLineBytes = 1 << 20
30 )
31
32 // probe runs "onechat probe LOG COUNT". It appends the first COUNT messages of
33 // LOG, one JSON object {kind, text} per line, to a new chat in probe/ in the
34 // data directory, and builds its tree with the real compactor. After each
35 // message it waits for the builds, as a turn does. It stops at the first
36 // failed call, or when the used percent of a quota window grows by more than
37 // probeQuotaLimit points. At the end it reports the calls, the cached share,
38 // the quota, the retries, and the line sizes.
39 func probe(ctx context.Context, args []string, out io.Writer) error {
40         if len(args) != 2 {
41                 return errors.New("usage: onechat probe LOG COUNT")
42         }
43
44         count, err := strconv.Atoi(args[1])
45         if err != nil || count <= 0 {
46                 return fmt.Errorf("COUNT %q is not a positive number", args[1])
47         }
48
49         entries, err := readProbeLog(args[0], count)
50         if err != nil {
51                 return err
52         }
53
54         dir, err := dataDir()
55         if err != nil {
56                 return err
57         }
58
59         config, err := loadConfig(dir)
60         if err != nil {
61                 return err
62         }
63
64         address, auth, err := modelEndpoint(config, dir)
65         if err != nil {
66                 return err
67         }
68
69         root := filepath.Join(dir, "probe", time.Now().Format("20060102-150405"))
70         err = os.MkdirAll(root, 0o700)
71         if err != nil {
72                 return fmt.Errorf("create probe directory: %w", err)
73         }
74
75         usagePath := filepath.Join(root, "usage.jsonl")
76         client := newClient(address, auth, "onechat-"+probeName, usagePath)
77         memory, err := openMemory(ctx, root, time.Now, newCompactor(client, probeName, config.Compactor))
78         if err != nil {
79                 return err
80         }
81
82         _, err = fmt.Fprintf(out, "probe: %d messages into %s with %s at %s effort\n", len(entries), root, config.Compactor.Model, config.Compactor.Effort)
83         if err != nil {
84                 return errors.Join(err, memory.Close())
85         }
86
87         appended, stop := runProbe(ctx, memory, entries, usagePath)
88         sizes, err := modelLines(memory)
89         err = errors.Join(err, memory.Close())
90         if err != nil {
91                 return err
92         }
93
94         records, err := readUsageFile(usagePath)
95         if err != nil {
96                 return err
97         }
98
99         return writeProbeReport(out, appended, stop, records, sizes)
100 }
101
102 // runProbe appends the entries and waits for the builds after each one. It
103 // returns the number of messages appended and why the run stopped early, or
104 // "" when it did not.
105 func runProbe(ctx context.Context, memory *Memory, entries []probeEntry, usagePath string) (int, string) {
106         for n, entry := range entries {
107                 _, err := memory.Append(entry.Kind, entry.Text)
108                 if err == nil {
109                         err = memory.Wait(ctx)
110                 }
111
112                 if err != nil {
113                         return n, err.Error()
114                 }
115
116                 records, err := readUsageFile(usagePath)
117                 if err != nil {
118                         return n + 1, err.Error()
119                 }
120
121                 reason := probeStop(records)
122                 if reason != "" {
123                         return n + 1, reason
124                 }
125         }
126
127         return len(entries), ""
128 }
129
130 // probeEntry is one message of a probe log.
131 type probeEntry struct {
132         Kind string `json:"kind"`
133         Text string `json:"text"`
134 }
135
136 // readProbeLog reads the first count messages of the probe log at path.
137 func readProbeLog(path string, count int) (entries []probeEntry, err error) {
138         file, err := os.Open(path)
139         if err != nil {
140                 return nil, fmt.Errorf("open probe log: %w", err)
141         }
142
143         defer func() {
144                 err = errors.Join(err, file.Close())
145         }()
146
147         scanner := bufio.NewScanner(file)
148         scanner.Buffer(make([]byte, 64*1024), probeLineBytes)
149         for len(entries) < count && scanner.Scan() {
150                 var entry probeEntry
151                 err = json.Unmarshal(scanner.Bytes(), &entry)
152                 if err != nil {
153                         return nil, fmt.Errorf("probe log line %d: %w", len(entries)+1, err)
154                 }
155
156                 entries = append(entries, entry)
157         }
158
159         err = scanner.Err()
160         if err != nil {
161                 return nil, fmt.Errorf("read probe log: %w", err)
162         }
163
164         if len(entries) < count {
165                 return nil, fmt.Errorf("probe log has %d messages, fewer than %d", len(entries), count)
166         }
167
168         return entries, nil
169 }
170
171 // probeStop returns why a probe run must stop: a failed call, or a quota window
172 // whose used percent grew by more than probeQuotaLimit points since the first
173 // call. It returns "" when the run can go on.
174 func probeStop(records []usageRecord) string {
175         for _, record := range records {
176                 if record.Error != "" {
177                         return "call failed: " + record.Error
178                 }
179         }
180
181         if len(records) == 0 {
182                 return ""
183         }
184
185         first := usedPercents(records[0])
186         for name, used := range usedPercents(records[len(records)-1]) {
187                 start, found := first[name]
188                 if found && used-start > probeQuotaLimit {
189                         return fmt.Sprintf("%s grew from %g to %g", name, start, used)
190                 }
191         }
192
193         return ""
194 }
195
196 // modelLines returns the sizes of the nodes that the model wrote, in tree
197 // order. A node that fits its source with no model call is not one of them.
198 func modelLines(memory *Memory) ([]int, error) {
199         memory.mutex.Lock()
200         defer memory.mutex.Unlock()
201
202         keys, err := memory.tree.Keys()
203         if err != nil {
204                 return nil, err
205         }
206
207         var sizes []int
208         for _, key := range keys {
209                 node, _, err := memory.tree.Node(key.level, key.index)
210                 if err != nil {
211                         return nil, err
212                 }
213
214                 model, err := builtByModel(memory, key)
215                 if err != nil {
216                         return nil, err
217                 }
218
219                 if model {
220                         sizes = append(sizes, node.Size)
221                 }
222         }
223
224         return sizes, nil
225 }
226
227 // builtByModel tells if the node key needed a model call: a leaf whose source
228 // is over nodeBytes, or a merge whose children do not fit in one node.
229 func builtByModel(memory *Memory, key nodeKey) (bool, error) {
230         if key.level == 0 {
231                 message, err := memory.log.Message(key.index)
232                 if err != nil {
233                         return false, err
234                 }
235
236                 _, free := freeLeaf(message)
237
238                 return !free, nil
239         }
240
241         left, right, err := memory.children(key)
242         if err != nil {
243                 return false, err
244         }
245
246         _, free := freeMerge(left, right)
247
248         return !free, nil
249 }
250
251 // readUsageFile reads the records of usage.jsonl at path.
252 func readUsageFile(path string) (records []usageRecord, err error) {
253         file, err := os.Open(path)
254         if errors.Is(err, os.ErrNotExist) {
255                 return nil, nil
256         }
257
258         if err != nil {
259                 return nil, fmt.Errorf("open usage file: %w", err)
260         }
261
262         defer func() {
263                 err = errors.Join(err, file.Close())
264         }()
265
266         scanner := bufio.NewScanner(file)
267         scanner.Buffer(make([]byte, 64*1024), probeLineBytes)
268         for scanner.Scan() {
269                 var record usageRecord
270                 err = json.Unmarshal(scanner.Bytes(), &record)
271                 if err != nil {
272                         return nil, fmt.Errorf("decode usage: %w", err)
273                 }
274
275                 records = append(records, record)
276         }
277
278         err = scanner.Err()
279         if err != nil {
280                 return nil, fmt.Errorf("read usage file: %w", err)
281         }
282
283         return records, nil
284 }
285
286 // usedPercents returns the used percent of each quota window of record, by
287 // header name.
288 func usedPercents(record usageRecord) map[string]float64 {
289         used := make(map[string]float64)
290         for name, value := range record.Quota {
291                 if !strings.HasSuffix(name, "-used-percent") {
292                         continue
293                 }
294
295                 percent, err := strconv.ParseFloat(value, 64)
296                 if err == nil {
297                         used[name] = percent
298                 }
299         }
300
301         return used
302 }
303
304 // writeProbeReport writes the result of a probe run.
305 func writeProbeReport(out io.Writer, appended int, stop string, records []usageRecord, sizes []int) error {
306         var input, cached, output, failed int
307         for _, record := range records {
308                 input += record.InputTokens
309                 cached += record.CachedTokens
310                 output += record.OutputTokens
311                 if record.Error != "" {
312                         failed++
313                 }
314         }
315
316         var lines []string
317         if stop != "" {
318                 lines = append(lines, "stopped early: "+stop)
319         }
320
321         lines = append(lines,
322                 fmt.Sprintf("messages: %d", appended),
323                 fmt.Sprintf("calls: %d (%d failed), %.2f per message", len(records), failed, ratio(len(records), appended)),
324                 fmt.Sprintf("model lines: %d, retries: %d", len(sizes), len(records)-failed-len(sizes)),
325                 fmt.Sprintf("tokens: %d input, %d cached (%.1f%%), %d output", input, cached, 100*ratio(cached, input), output),
326         )
327
328         if len(sizes) > 0 {
329                 lines = append(lines, sizeText(sizes))
330         }
331
332         if len(records) > 0 {
333                 lines = append(lines,
334                         "quota at the first call: "+quotaText(records[0]),
335                         "quota at the last call: "+quotaText(records[len(records)-1]),
336                 )
337         }
338
339         _, err := io.WriteString(out, strings.Join(lines, "\n")+"\n")
340
341         return err
342 }
343
344 // sizeText describes the sizes of the model lines.
345 func sizeText(sizes []int) string {
346         sorted := slices.Sorted(slices.Values(sizes))
347         total, over := 0, 0
348         for _, size := range sorted {
349                 total += size
350                 if size > nodeBytes {
351                         over++
352                 }
353         }
354
355         return fmt.Sprintf("line bytes: min %d, median %d, mean %.0f, max %d, %d over %d", sorted[0], sorted[len(sorted)/2], ratio(total, len(sorted)), sorted[len(sorted)-1], over, nodeBytes)
356 }
357
358 // quotaText returns the quota headers of record, sorted by name.
359 func quotaText(record usageRecord) string {
360         names := slices.Sorted(maps.Keys(record.Quota))
361         parts := make([]string, len(names))
362         for n, name := range names {
363                 parts[n] = strings.TrimPrefix(name, "x-codex-") + "=" + record.Quota[name]
364         }
365
366         return strings.Join(parts, " ")
367 }
368
369 // ratio returns a / b, or 0 when b is 0.
370 func ratio(a, b int) float64 {
371         if b == 0 {
372                 return 0
373         }
374
375         return float64(a) / float64(b)
376 }