256999b42c
- Rename module to gitea.iliadenisov.ru/developer/scrabble-solver so it can be consumed as a versioned dependency (no go.work replace / CI clone). - De-internalize wordlist and dictdawg as public packages. - Remove cmd/builddict, dictprep/, the dictionaries submodule and the dawg Makefile: the word-list parsing and DAWG build now live in the separate scrabble-dictionary repository, which publishes the DAWG set as a release artifact. - internal/dict loads the committed dawg/en_sowpods.dawg fixture for cmd/stress. - Update README/CLAUDE docs accordingly.
105 lines
2.9 KiB
Go
105 lines
2.9 KiB
Go
// Command stress plays many greedy AI-vs-AI games and reports the DAWG move generator's
|
|
// speed and memory. It is a benchmark / regression tool for the production generator.
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/internal/dict"
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
|
|
)
|
|
|
|
func main() {
|
|
games := flag.Int("games", 100, "games to play")
|
|
flag.Parse()
|
|
|
|
rs := rules.English()
|
|
if !dict.EnglishAvailable() {
|
|
log.Fatal("English dictionary not available: dawg/en_sowpods.dawg missing")
|
|
}
|
|
f, err := dict.EnglishDAWG()
|
|
if err != nil {
|
|
log.Fatalf("load dawg: %v", err)
|
|
}
|
|
gen := scrabble.NewDAWGGenerator(rs, f)
|
|
structSize := fileSize(dict.DAWGCache())
|
|
|
|
runtime.GC()
|
|
var m0 runtime.MemStats
|
|
runtime.ReadMemStats(&m0)
|
|
start := time.Now()
|
|
|
|
var turns, plays, movesGen int
|
|
var genTime time.Duration
|
|
var score float64
|
|
for seed := 1; seed <= *games; seed++ {
|
|
res := selfplay.PlayGame(rs, gen, scrabble.Both, int64(seed), nil)
|
|
turns += res.Turns
|
|
plays += res.Plays
|
|
movesGen += res.MovesGenerated
|
|
genTime += res.GenTime
|
|
score += float64(res.Scores[0] + res.Scores[1])
|
|
}
|
|
wall := time.Since(start)
|
|
var m1 runtime.MemStats
|
|
runtime.ReadMemStats(&m1)
|
|
|
|
fmt.Printf("DAWG · English SOWPODS · %d games · board %dx%d · greedy self-play\n\n", *games, rs.Rows, rs.Cols)
|
|
fmt.Printf(" structure size %s\n", humanBytes(structSize))
|
|
fmt.Printf(" turns / plays %d / %d\n", turns, plays)
|
|
fmt.Printf(" moves generated %d\n", movesGen)
|
|
fmt.Printf(" generation time %s (%.1f µs/turn)\n",
|
|
genTime.Round(time.Millisecond), float64(genTime.Microseconds())/float64(turns))
|
|
fmt.Printf(" moves generated/sec %.0f\n", float64(movesGen)/genTime.Seconds())
|
|
fmt.Printf(" wall time %s\n", wall.Round(time.Millisecond))
|
|
fmt.Printf(" heap allocated %s (%d GC cycles)\n",
|
|
humanBytes(int64(m1.TotalAlloc-m0.TotalAlloc)), m1.NumGC-m0.NumGC)
|
|
fmt.Printf(" avg final game score %.1f\n", score/float64(*games))
|
|
fmt.Printf(" peak process RSS %s\n", humanKB(peakRSS()))
|
|
}
|
|
|
|
func fileSize(p string) int64 {
|
|
if fi, err := os.Stat(p); err == nil {
|
|
return fi.Size()
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func peakRSS() int64 {
|
|
data, err := os.ReadFile("/proc/self/status")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for line := range strings.SplitSeq(string(data), "\n") {
|
|
if rest, ok := strings.CutPrefix(line, "VmHWM:"); ok {
|
|
if f := strings.Fields(rest); len(f) > 0 {
|
|
kb, _ := strconv.ParseInt(f[0], 10, 64)
|
|
return kb
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
switch {
|
|
case n >= 1<<20:
|
|
return fmt.Sprintf("%.2f MB", float64(n)/(1<<20))
|
|
case n >= 1<<10:
|
|
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
|
|
default:
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
}
|
|
|
|
func humanKB(kb int64) string { return humanBytes(kb * 1024) }
|