carlos / todomvc-bakeoff Public
feat: local memory measurement (idle, peak under load, after)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+206
−0
copy-review/strings.json+32 −0harness/cmd/bakeoff/main.go+17 −0harness/harness_test.go+29 −0harness/memory.go+124 −0harness/report/report.go+3 −0harness/results.go+1 −0
diff --git a/copy-review/strings.json b/copy-review/strings.json| index 3b19264..2c96fee 100644 |
| --- a/copy-review/strings.json |
| +++ b/copy-review/strings.json |
| @@ -253,6 +253,38 @@ |
| "context": "The line under the chart title that says exactly what was measured.", |
| "notes": "One sentence or two." |
| }, |
| + { |
| + "id": "chart.memory_idle.title", |
| + "section": "Charts", |
| + "label": "Memory at rest: title", |
| + "text": "Memory at rest", |
| + "context": "Title of the chart showing how much memory each app uses when it has just started and nobody is using it.", |
| + "notes": "Short." |
| + }, |
| + { |
| + "id": "chart.memory_idle.note", |
| + "section": "Charts", |
| + "label": "Memory at rest: note", |
| + "text": "Resident memory just after start, measured on our own machine because Carloku doesn't show members memory figures.", |
| + "context": "The line under that chart saying exactly what was measured, and why it was measured locally.", |
| + "notes": "One sentence or two." |
| + }, |
| + { |
| + "id": "chart.memory_peak.title", |
| + "section": "Charts", |
| + "label": "Memory under load: title", |
| + "text": "Memory under load", |
| + "context": "Title of the chart showing the most memory each app used while being load-tested.", |
| + "notes": "Short." |
| + }, |
| + { |
| + "id": "chart.memory_peak.note", |
| + "section": "Charts", |
| + "label": "Memory under load: note", |
| + "text": "The most resident memory each app used during 10 seconds of 64 simultaneous page loads and then 10 seconds of 64 simultaneous toggles, on our own machine.", |
| + "context": "The line under that chart saying exactly what was measured.", |
| + "notes": "One sentence or two." |
| + }, |
| { |
| "id": "chart.binary.title", |
| "section": "Charts", |
diff --git a/harness/cmd/bakeoff/main.go b/harness/cmd/bakeoff/main.go| index 354cd70..f33b0c8 100644 |
| --- a/harness/cmd/bakeoff/main.go |
| +++ b/harness/cmd/bakeoff/main.go |
| @@ -90,6 +90,8 @@ func main() { |
| err = findings(*slug, *file) |
| case "probe": |
| err = probe(*url, *levels, *dur) |
| + case "memory": |
| + err = memory(*slug, *dir, *dur) |
| case "record": |
| if rec.URL = *recURL; rec.URL == "" && strings.HasPrefix(rec.Status, "LIVE") { |
| rec.URL = *url |
| @@ -446,3 +448,18 @@ func probe(url, levels string, d time.Duration) error { |
| } |
| return nil |
| } |
| + |
| +// memory measures the app's own host build (dist/app, from make build) |
| +// locally: resident memory at rest, peak under load, and after. |
| +func memory(slug, dir string, d time.Duration) error { |
| + bin, err := filepath.Abs(filepath.Join(dir, "dist", "app")) |
| + if err != nil { |
| + return err |
| + } |
| + m, err := b.MeasureMemory(bin, nil, d) |
| + if err != nil { |
| + return err |
| + } |
| + fmt.Printf("%s memory: idle %d KB, peak %d KB, after %d KB\n", slug, m.IdleKB, m.PeakKB, m.AfterKB) |
| + return b.Update(slug, func(r *b.AppResult) { r.Memory = m }) |
| +} |
diff --git a/harness/harness_test.go b/harness/harness_test.go| index 08c74d2..a9f2f7a 100644 |
| --- a/harness/harness_test.go |
| +++ b/harness/harness_test.go |
| @@ -360,3 +360,32 @@ func TestAsleepFromLogs(t *testing.T) { |
| t.Fatal("no events: unknown, so not asleep") |
| } |
| } |
| + |
| +func TestParseProcStatus(t *testing.T) { |
| + status := "Name:\tapp\nVmHWM:\t 20480 kB\nVmRSS:\t 12288 kB\nThreads:\t4\n" |
| + rss, hwm, err := ParseProcStatus(status) |
| + if err != nil || rss != 12288 || hwm != 20480 { |
| + t.Fatalf("got rss %d hwm %d err %v", rss, hwm, err) |
| + } |
| +} |
| + |
| +func TestMeasureMemoryOfRefapp(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("builds and loads the reference app") |
| + } |
| + if _, err := exec.LookPath("oha"); err != nil { |
| + t.Skip("oha not installed") |
| + } |
| + bin := filepath.Join(t.TempDir(), "refapp") |
| + if out, err := exec.Command("go", "build", "-o", bin, "./refapp").CombinedOutput(); err != nil { |
| + t.Fatalf("%v\n%s", err, out) |
| + } |
| + shared, _ := filepath.Abs("../shared") |
| + m, err := MeasureMemory(bin, []string{"--shared", shared}, time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.IdleKB <= 0 || m.PeakKB < m.IdleKB || m.AfterKB <= 0 { |
| + t.Fatalf("implausible %+v", m) |
| + } |
| +} |
diff --git a/harness/memory.go b/harness/memory.go| new file mode 100644 |
| index 0000000..e360b19 |
| --- /dev/null |
| +++ b/harness/memory.go |
| @@ -0,0 +1,124 @@ |
| +package bakeoff |
| + |
| +import ( |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "syscall" |
| + "time" |
| +) |
| + |
| +// Memory is an app's resident memory, from Linux's /proc, in kilobytes: |
| +// just after start, the peak over a load run, and a few seconds after. |
| +type Memory struct{ IdleKB, PeakKB, AfterKB int } |
| + |
| +// ParseProcStatus reads VmRSS (resident now) and VmHWM (peak resident) |
| +// from /proc/<pid>/status. |
| +func ParseProcStatus(status string) (rss, hwm int, err error) { |
| + for _, line := range strings.Split(status, "\n") { |
| + k, v, ok := strings.Cut(line, ":") |
| + if !ok { |
| + continue |
| + } |
| + f := strings.Fields(v) |
| + if len(f) == 0 { |
| + continue |
| + } |
| + n, _ := strconv.Atoi(f[0]) |
| + switch k { |
| + case "VmRSS": |
| + rss = n |
| + case "VmHWM": |
| + hwm = n |
| + } |
| + } |
| + if rss == 0 { |
| + return 0, 0, fmt.Errorf("no VmRSS in status") |
| + } |
| + return rss, hwm, nil |
| +} |
| + |
| +func procStatus(pid int) (int, int, error) { |
| + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) |
| + if err != nil { |
| + return 0, 0, err |
| + } |
| + return ParseProcStatus(string(b)) |
| +} |
| + |
| +// MeasureMemory runs the app binary locally (on --listen, with a fresh |
| +// database), reads its memory at rest, drives it with 64 concurrent reads |
| +// and then 64 concurrent toggles for d each, and reads it again. The |
| +// working directory is the app's own (the binary's parent's parent, since |
| +// builds land in dist/). |
| +func MeasureMemory(bin string, extra []string, d time.Duration) (Memory, error) { |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + return Memory{}, err |
| + } |
| + addr := ln.Addr().String() |
| + ln.Close() |
| + tmp, err := os.MkdirTemp(os.Getenv("TMPDIR"), "bakeoff-mem-") |
| + if err != nil { |
| + return Memory{}, err |
| + } |
| + defer os.RemoveAll(tmp) |
| + cmd := exec.Command(bin, append(extra, "--listen", addr, "--db", filepath.Join(tmp, "app.db"))...) |
| + cmd.Dir = filepath.Dir(filepath.Dir(bin)) |
| + cmd.Stdout, cmd.Stderr = nil, nil |
| + if err := cmd.Start(); err != nil { |
| + return Memory{}, err |
| + } |
| + defer func() { |
| + cmd.Process.Signal(syscall.SIGTERM) |
| + done := make(chan error, 1) |
| + go func() { done <- cmd.Wait() }() |
| + select { |
| + case <-done: |
| + case <-time.After(10 * time.Second): |
| + cmd.Process.Kill() |
| + } |
| + }() |
| + base := "http://" + addr |
| + up := false |
| + for i := 0; i < 150 && !up; i++ { |
| + if res, err := http.Get(base + "/healthz"); err == nil { |
| + res.Body.Close() |
| + up = res.StatusCode == 200 |
| + } |
| + if !up { |
| + time.Sleep(200 * time.Millisecond) |
| + } |
| + } |
| + if !up { |
| + return Memory{}, fmt.Errorf("%s did not answer /healthz on %s", bin, addr) |
| + } |
| + time.Sleep(2 * time.Second) |
| + var m Memory |
| + if m.IdleKB, _, err = procStatus(cmd.Process.Pid); err != nil { |
| + return m, err |
| + } |
| + s, err := NewSession(base) |
| + if err != nil { |
| + return m, err |
| + } |
| + if _, err := Seed(s, 50); err != nil { |
| + return m, err |
| + } |
| + for _, sc := range []string{"read", "toggle"} { |
| + if _, err := RunLoad(s, sc, 64, d); err != nil { |
| + return m, err |
| + } |
| + } |
| + if _, m.PeakKB, err = procStatus(cmd.Process.Pid); err != nil { |
| + return m, err |
| + } |
| + time.Sleep(5 * time.Second) |
| + m.AfterKB, _, err = procStatus(cmd.Process.Pid) |
| + return m, err |
| +} |
diff --git a/harness/report/report.go b/harness/report/report.go| index a48a505..78f25fd 100644 |
| --- a/harness/report/report.go |
| +++ b/harness/report/report.go |
| @@ -44,6 +44,7 @@ func ms(v float64) string { |
| } |
| func mb(v float64) string { return fmt.Sprintf("%.1f MB", v/1e6) } |
| func usd(v float64) string { return fmt.Sprintf("$%.2f", v) } |
| +func kb(v float64) string { return fmt.Sprintf("%.1f MB", v/1024) } |
| func secs(v float64) string { return fmt.Sprintf("%.1f s", v) } |
| func num(v float64) string { |
| s := fmt.Sprintf("%.0f", v) |
| @@ -139,6 +140,8 @@ func charts(rs []b.AppResult, c Copy) []Chart { |
| chart(rs, c, "toggles_peak", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "toggle").PeakRPS) }), |
| kneeChart(rs, c, "toggle", "toggles_knee"), |
| chart(rs, c, "reads_after", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "read-after").PeakRPS) }), |
| + chart(rs, c, "memory_idle", kb, func(r b.AppResult) (float64, bool) { return pos(float64(r.Memory.IdleKB)) }), |
| + chart(rs, c, "memory_peak", kb, func(r b.AppResult) (float64, bool) { return pos(float64(r.Memory.PeakKB)) }), |
| chart(rs, c, "binary", mb, func(r b.AppResult) (float64, bool) { return pos(float64(r.Build.Bytes)) }), |
| chart(rs, c, "build_cold", secs, func(r b.AppResult) (float64, bool) { return pos(r.Build.ColdSeconds) }), |
| chart(rs, c, "build_warm", secs, func(r b.AppResult) (float64, bool) { return pos(r.Build.WarmSeconds) }), |
diff --git a/harness/results.go b/harness/results.go| index 0d12ee8..91a5aaf 100644 |
| --- a/harness/results.go |
| +++ b/harness/results.go |
| @@ -30,6 +30,7 @@ type AppResult struct { |
| Wake []float64 |
| Load []LoadResult |
| Weight Weight |
| + Memory Memory |
| Code CodeStats |
| Build Build |
| TokensBuild, TokensFix, TokensSecurity Tokens |