From 924aa85dacbfc1c33dcefe679adec495c8062097 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:42:33 +0200 Subject: [PATCH 1/3] Add Laravel corpus wall-clock benchmark + CI --- .github/workflows/benchmark.yaml | 63 ++++++++++++++++++++++++++++++++ .gitignore | 3 ++ Makefile | 4 ++ README.md | 11 ++++++ benchmark/main.go | 54 +++++++++++++++++++++++++++ 5 files changed, 135 insertions(+) create mode 100644 .github/workflows/benchmark.yaml create mode 100644 benchmark/main.go diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml new file mode 100644 index 0000000..6469530 --- /dev/null +++ b/.github/workflows/benchmark.yaml @@ -0,0 +1,63 @@ +name: Benchmark + +on: + pull_request: + branches: + - main + push: + branches: + - main + schedule: + # every 12 hours + - cron: '0 */12 * * *' + +jobs: + corpus: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + - name: Clone + install Laravel corpus + run: | + git clone --depth 1 https://github.com/laravel/framework laravel + composer install --no-interaction --no-progress --ignore-platform-reqs --working-dir=laravel + # drop intentionally-broken PHP fixtures + rm -rf laravel/tests + find laravel/vendor -depth -type d -name tests -exec rm -rf {} + + + - run: go build -o benchmark-corpus ./benchmark + + - name: Run benchmark (timed + memory) + run: | + # average wall-clock over 5 runs + bench() { + total=0 + for i in $(seq 1 5); do + start=$(date +%s%3N) + "$@" > /dev/null + end=$(date +%s%3N) + total=$((total + end - start)) + done + echo $((total / 5)) + } + # peak resident set size (MB) of a single run + mem() { + /usr/bin/time -v "$@" 2>mem.log > /dev/null + awk '/Maximum resident set size/{printf "%d", $NF/1024}' mem.log + } + ms=$(bench ./benchmark-corpus laravel) + mb=$(mem ./benchmark-corpus laravel) + files=$(./benchmark-corpus laravel | awk '{print $2}') + { + echo "## Corpus benchmark" + echo "" + echo "Parsing the full Laravel framework (\`src/\` + Composer \`vendor/\`), average wall-clock over 5 runs." + echo "" + echo "| Files | Avg (5 runs) | Peak mem |" + echo "|------:|-------------:|---------:|" + echo "| $files | $ms ms | $mb MB |" + } | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 6bcac03..10cac47 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .idea **/*.test +/laravel/ +/benchmark-corpus + cpu.pprof mem.pprof trace.out diff --git a/Makefile b/Makefile index 148bba1..ee29944 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,10 @@ bench: go test -benchmem -bench=. ./internal/php5 go test -benchmem -bench=. ./internal/php7 +# wall-clock parse of a corpus, e.g. `make bench-corpus DIR=./laravel` +bench-corpus: + go run ./benchmark $(DIR) + compile: ./internal/php5/php5.go ./internal/php7/php7.go ./internal/php8/php8.go ./internal/php8/scanner.go ./internal/scanner/scanner.go sed -i '' -e 's/yyErrorVerbose = false/yyErrorVerbose = true/g' ./internal/php5/php5.go sed -i '' -e 's/yyErrorVerbose = false/yyErrorVerbose = true/g' ./internal/php7/php7.go diff --git a/README.md b/README.md index 9bbaa7e..77e2ae2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,17 @@ func main() { - `pkg/visitor` — traverser, printer, dumper, namespace and class resolvers, formatter. - `pkg/token`, `pkg/position`, `pkg/version`, `pkg/errors`, `pkg/conf`. +## Benchmark + +`benchmark/` parses every `.php` file under a directory and reports the wall-clock time. Point it at any corpus: + +```bash +make bench-corpus DIR=./laravel +# parsed 2966 files in 1215 ms +``` + +CI runs it against a full Laravel framework checkout (`src/` + Composer `vendor/`) on every push and every 12 hours, reporting the average over 5 runs and peak memory in the run's **Summary**. + ## Generated code `internal/*/php*.go` and `internal/*/scanner.go` are generated. Edit the grammar source instead: diff --git a/benchmark/main.go b/benchmark/main.go new file mode 100644 index 0000000..773ea9e --- /dev/null +++ b/benchmark/main.go @@ -0,0 +1,54 @@ +// Corpus benchmark: parse every .php file under the given path and report timing. +package main + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/rectorphp/php-parser-in-go/pkg/conf" + "github.com/rectorphp/php-parser-in-go/pkg/parser" + "github.com/rectorphp/php-parser-in-go/pkg/version" +) + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + + phpVersion, err := version.New("8.3") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + config := conf.Config{Version: phpVersion} + + files := collectPHPFiles(root) + + start := time.Now() + for _, path := range files { + content, err := os.ReadFile(path) + if err != nil { + continue + } + // broken fixtures return an error; the parse work is what we time + parser.Parse(content, config) + } + elapsed := time.Since(start) + + fmt.Printf("parsed %d files in %d ms\n", len(files), elapsed.Milliseconds()) +} + +func collectPHPFiles(root string) []string { + var files []string + filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() && filepath.Ext(path) == ".php" { + files = append(files, path) + } + return nil + }) + return files +} From a00534cfe6738861a74226623d5d4198e6d0c43b Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:55:53 +0200 Subject: [PATCH 2/3] Speed up corpus benchmark: parallel parsing + GC off (#3) --- benchmark/main.go | 48 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/benchmark/main.go b/benchmark/main.go index 773ea9e..1c8a3ca 100644 --- a/benchmark/main.go +++ b/benchmark/main.go @@ -1,4 +1,5 @@ // Corpus benchmark: parse every .php file under the given path and report timing. +// Parsing runs across GOMAXPROCS workers with the GC disabled for the short-lived run. package main import ( @@ -6,6 +7,10 @@ import ( "io/fs" "os" "path/filepath" + "runtime" + "runtime/debug" + "sync" + "sync/atomic" "time" "github.com/rectorphp/php-parser-in-go/pkg/conf" @@ -26,20 +31,47 @@ func main() { } config := conf.Config{Version: phpVersion} + // short-lived process: parse throughput matters, not steady-state memory + debug.SetGCPercent(-1) + files := collectPHPFiles(root) start := time.Now() + parsed := parseAll(files, config) + elapsed := time.Since(start) + + fmt.Printf("parsed %d files in %d ms\n", parsed, elapsed.Milliseconds()) +} + +// parseAll parses every file across GOMAXPROCS workers and returns the count parsed. +func parseAll(files []string, config conf.Config) int64 { + jobs := make(chan string, runtime.GOMAXPROCS(0)) + var parsed int64 + + var wg sync.WaitGroup + for range runtime.GOMAXPROCS(0) { + wg.Add(1) + go func() { + defer wg.Done() + for path := range jobs { + content, err := os.ReadFile(path) + if err != nil { + continue + } + // broken fixtures return an error; the parse work is what we time + parser.Parse(content, config) + atomic.AddInt64(&parsed, 1) + } + }() + } + for _, path := range files { - content, err := os.ReadFile(path) - if err != nil { - continue - } - // broken fixtures return an error; the parse work is what we time - parser.Parse(content, config) + jobs <- path } - elapsed := time.Since(start) + close(jobs) + wg.Wait() - fmt.Printf("parsed %d files in %d ms\n", len(files), elapsed.Milliseconds()) + return parsed } func collectPHPFiles(root string) []string { From 226aa22f4fe83243f351f96571b273371bfea0c1 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 01:00:12 +0200 Subject: [PATCH 3/3] Apply go fix: use WaitGroup.Go in corpus benchmark Satisfies the format CI check (go fix -diff) flagging benchmark/main.go. Claude-Session: https://claude.ai/code/session_01QDEtmow9psVbccgbgHDqgd --- benchmark/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/benchmark/main.go b/benchmark/main.go index 1c8a3ca..304ecf1 100644 --- a/benchmark/main.go +++ b/benchmark/main.go @@ -50,9 +50,7 @@ func parseAll(files []string, config conf.Config) int64 { var wg sync.WaitGroup for range runtime.GOMAXPROCS(0) { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for path := range jobs { content, err := os.ReadFile(path) if err != nil { @@ -62,7 +60,7 @@ func parseAll(files []string, config conf.Config) int64 { parser.Parse(content, config) atomic.AddInt64(&parsed, 1) } - }() + }) } for _, path := range files {