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..304ecf1 --- /dev/null +++ b/benchmark/main.go @@ -0,0 +1,84 @@ +// 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 ( + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "sync" + "sync/atomic" + "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} + + // 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.Go(func() { + 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 { + jobs <- path + } + close(jobs) + wg.Wait() + + return parsed +} + +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 +}