From 81fcab46cf313b15e9aa46596bc435f66d94283e Mon Sep 17 00:00:00 2001 From: fcying Date: Tue, 11 Aug 2026 13:38:02 +0800 Subject: [PATCH] test: add build log memory benchmarks --- README.md | 187 +++++-------------------- internal/build_log_benchmark_test.go | 196 +++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 150 deletions(-) create mode 100644 internal/build_log_benchmark_test.go diff --git a/README.md b/README.md index 9fcc651..814f0b5 100644 --- a/README.md +++ b/README.md @@ -66,137 +66,39 @@ COMMANDS: --cmd/-c value Command to be used as make executable. ``` -`compiledb` provides a Go `make` wrapper which, besides executing the make -build command, updates the JSON compilation database file corresponding to that build, -resulting in a command-line interface similar to [Bear][bear]. - -By default, new commands update the existing compilation database. Entries are matched by -their `directory` and `file`, and a newly generated entry replaces an existing entry for -the same source file. Legacy relative directories that map to the configured build directory, -and explicit Windows drive/UNC slash and case variants, are -matched to their current representation without rewriting preserved JSON fields. Use -`--overwrite/-f` to replace the database instead: -```bash -$ compiledb --overwrite make -``` +### Make Wrapper -When `--output -` is used, only commands generated by the current invocation are written -to stdout. Parser diagnostics and real Make output are written to stderr so stdout remains -a valid JSON compilation database. +Run Make and update `compile_commands.json` after a successful build: -Non-verbose diagnostics use error level and are always written to stderr. Relevant malformed compiler, -`cd`, and Make commands report the build-log line, tracked directory, and reason without printing the -full command; tokenization and backtick execution failures skip only the affected command and parsing -continues with exit status 0. Cancellation remains a nonzero error. Strict mode follows symlinks and -accepts only regular source files. Repeat `--exclude/-e value` to exclude any source path whose -beginning matches one of the supplied regular expressions. - -To generate `compile_commands.json` file using compiledb's "make wrapper" script, -executing Makefile target `all`: ```bash $ compiledb make ``` -The `make` subcommand consumes `--cmd/-c command` to select the GNU Make-compatible executable, -and `--help/-h` displays its help; all other options and arguments are forwarded to Make in their -original order. The first `make --` ends wrapper option parsing and is not forwarded; every following -argument is forwarded. For example, generate `compile_commands.json` using `gmake` and -`core/main.mk` -as main makefile (`--file/-f`), starting the build from `build` directory (`--directory/-C`): +Arguments after `make` are forwarded to GNU Make. Use `make --cmd/-c` to select another compatible +executable, and `make --` when a Make argument must bypass wrapper option parsing: + ```bash $ compiledb make --cmd gmake -f core/main.mk -C build ``` -An empty `--cmd` value falls back to `make`, matching the original Python CLI. The top-level -`--command-style/-c` remains separate: `compiledb -c make` enables command-style -database output, while `compiledb make -c gmake` selects the Make executable. -As in the original Click CLI, known short options are recognized inside a short-option cluster; -place `--` before attached Make options when they must bypass wrapper parsing unchanged. - -To query each compiler for its predefined macros and add them to generated entries, use -`--macros/-m`. Compiler executables must be available from the command's working directory -or `PATH`. The query preserves selected safe compiler options that affect built-in macros, -such as `-std`, `-m32`, and `--target`. Macro probing is skipped for response files and -unsupported macro-affecting or compiler-forwarding options because replaying them could use -the wrong target configuration, overwrite build outputs, or load compiler plugins. It is also -skipped for multi-source commands, multi-stage languages such as CUDA/HIP, and commands whose -compiler is changed by shell, ccache, icecc, or launcher configuration. Compiler-control and -dynamic-loader injection environment variables also disable probing rather than bypassing these -checks. Compiler commands containing source inputs are recorded even when they do not use `-c`, -matching the original Python implementation. Multi-source commands generate one database entry -for each source instead of dropping all but the last one. The default matcher accepts compiler -drivers, including cross-prefixed and versioned GCC/Clang names, while excluding source-processing -tools such as `clang-format` and `clang-tidy`. Explicit `time` and `nice` launchers are supported; -arbitrary command prefixes are not scanned because output commands such as `echo gcc ...` must not -be treated as compilations. Ordinary diagnostic `-W` options are ignored by the probe: -```bash -$ compiledb --macros make -``` -To add custom compiler arguments into generated entries, repeat `--add-arg/-a`. Each flag -value is appended as one argument without comma or shell-word splitting: -```bash -$ compiledb --add-arg='-DCSV=a,b' -a=-m32 make -``` +The top-level `--command-style/-c` is separate from `make --cmd/-c`. Use `--no-build/-n` to skip +the real build and run discovery only: -By default, `compiledb make` generates the compilation database, runs the actual build -command requested (acting as a make wrapper), and prints wrapped `make` output using raw -stream forwarding. The discovery Make invocation starts only after the real Make process -returns successfully, so a failed build does not update the compilation database and the two -Make invocations do not run concurrently. The build step can be skipped using `--no-build/-n`. ```bash $ compiledb -n make ``` -The discovery Make invocation is forced to run serially with directory printing so compilation -commands retain a deterministic working directory, including with GNU Make 3.81. Its discovery -mode removes conflicting question, touch, print-data-base, help, version, no-keep-going, and -no-print-directory options from both command-line arguments and `MAKEFLAGS`/`GNUMAKEFLAGS`, while -preserving Make variable assignments. A Makefile supplied with `-f -`, including through those -environment variables, is read once and provided independently to the real and discovery Make -processes. Only discovery stdout is parsed as build-log input; discovery stderr is forwarded to -stderr and can never create database entries. A nonzero discovery exit leaves the compilation -database untouched, even if Make produced partial stdout before failing. Simple `mkdir -p` commands -printed by the dry run, including their implied parent directories, are tracked so a following -`cd` can use a generated build directory without depending on the real build's timing. - -Build-log backtick expressions outside single quotes are executed once by an embedded POSIX shell -interpreter in the tracked command directory; their output is treated as argument data and is not -re-evaluated as shell source. Shell builtins do not require an external shell executable. Programs -explicitly invoked by the backtick body must still be available through `PATH`; a failure skips only -the affected command, emits an error diagnostic, and leaves the parser status successful. -Unsupported shell constructs and conditionals whose execution cannot be -determined safely are skipped as a whole rather than allowing commands inside groups, -functions, or control structures to escape into the database. Backslash-newline continuation -uses shell semantics without inserting extra whitespace; malformed or unterminated continuation -is skipped as a recoverable parse failure. Physical build-log lines are limited to 100 MiB. -Do not parse untrusted build logs. -With `--no-strict`, a simple inline `cd` updates the tracked directory without requiring that path -to exist on the parser host, which allows logs captured on another machine to retain their cwd. -Interrupting the CLI cancels active Make, compiler-probe, and backtick subprocesses. On Unix, -cancellation is forwarded to the subprocess group; on Windows, controlled subprocesses are placed -in a Job Object. Output is forwarded through a cancellable relay, with the inherited-pipe drain -timeout starting only after Make exits. JSON stdout writes also observe cancellation instead of -waiting indefinitely on a blocked consumer. - -If your wrapped `make` output is encoded as GB18030, you can opt into decoding it while -streaming: -```bash -$ compiledb --encoding gb18030 make -``` +Only successful discovery output updates the database. Make output and diagnostics go to stderr +when `--output -` is used, keeping stdout valid JSON. -To avoid repeating the option in a fixed environment, you can set an environment variable: -```bash -$ export COMPILEDB_ENCODING=gb18030 -$ compiledb make -``` +### Parse A Build Log -`compiledb` base command has been designed so that it can be used to parse compile commands -from arbitrary text files (or stdin), assuming it has a build log (ideally generated using -`make -Bnwk` command), and generates the corresponding JSON Compilation database. +Parse a saved build log, read stdin, or pipe Make output directly: -For example, to generate the compilation database from `build-log.txt` using `--parse/-p`: ```bash $ compiledb --parse build-log.txt +$ compiledb < build-log.txt +$ make -Bnwk | compiledb --output - ``` The initial working directory is selected in this order: @@ -205,56 +107,46 @@ The initial working directory is selected in this order: 2. When parsing a build-log file, its containing directory is used by default. 3. When parsing stdin, the directory where `compiledb` was started is used. -Using the build-log directory makes file parsing deterministic when the log and build tree move -together or when the command is invoked from another directory. If a log is copied separately to -another location, such as `/tmp` or an archive directory, pass the real build directory explicitly: - ```bash $ compiledb --build-dir /path/to/project/build --parse /tmp/build-log.txt ``` -The selected directory is the base for relative source paths, inline `cd`, `make -C`, compiler -lookup through relative `PATH` entries, backtick expressions, strict file checks, and predefined -macro probing. +Backtick expressions are executed by an embedded POSIX interpreter. Their output is used as +argument data, but explicitly invoked programs must still be available through `PATH`. Do not parse +untrusted build logs. -or its equivalent: -```bash -$ compiledb < build-log.txt -$ compiledb --parse - < build-log.txt -``` +### Output Options + +By default, new entries update the existing database. Use `--overwrite/-f` to replace it: -Or even, to pipe make's output and print the compilation database to the standard output: ```bash -$ make -Bnwk | compiledb -o - +$ compiledb --overwrite make ``` -By default `compiledb` generates a JSON compilation database in the "arguments" list -[format](https://clang.llvm.org/docs/JSONCompilationDatabase.html). The "command" string -format is also supported through `--command-style/-c`: +Useful output options: + ```bash $ compiledb --command-style make +$ compiledb --add-arg='-DCSV=a,b' -a=-m32 make +$ compiledb --macros make +$ PATH=/opt/buildroot/bin:$PATH compiledb --full-path make +$ compiledb --encoding gb18030 make ``` -`--full-path` searches the user's $PATH for the compiler executable and in -case it's found, replaces the executable name with the full path to -executable in "arguments" section in compilation database. -This argument allows to specify the compiler path only once, when -calling compiledb, like so: -``` -PATH=/opt/buildroot/bin:$PATH compiledb --full-path make -``` +`--macros/-m` requires the compiler to be available from its working directory or `PATH`; unsafe or +unsupported probes are skipped. Repeat `--exclude/-e` and `--add-arg/-a` as needed. -### Windows Support +### Memory Use + +Build-log processing is buffered and uses `O(build-log size + entries)` memory. Each physical line +is limited to 100 MiB. In synthetic Linux amd64 tests, direct parsing peaked at about 311 MiB/1.53 +GiB RSS for 100 MiB/500 MiB logs; Make discovery peaked at about 490 MiB/1.95 GiB. -The Windows artifact supports Windows compiler paths, `arguments` output, Windows command-line -quoting for `--command-style`, and process cancellation through Job Objects. The Make wrapper still -requires a GNU Make-compatible executable; use `compiledb make --cmd gmake` or -`compiledb make --cmd mingw32-make` when it is not named `make`. +### Windows Support -Build-log parsing currently targets POSIX/MSYS shell syntax. GNU Make output produced in MSYS2 or -Git Bash is the supported Windows workflow. Native `cmd.exe` recipe grammar, including caret escapes, -`%VAR%`/`!VAR!`, `cd /d`, and `cmd /C`, is not parsed, and `nmake` is not supported. Backtick -substitution uses the embedded POSIX interpreter and does not require a shell executable. +Use a GNU Make-compatible executable and POSIX/MSYS recipe output, typically through MSYS2 or Git +Bash. Select `gmake` or `mingw32-make` with `make --cmd` when necessary. Native `cmd.exe` recipe +syntax and `nmake` are not supported. ## Testing / Contributing @@ -269,11 +161,6 @@ could use it with some great tools, such as: - [Neovim][neovim] + [LanguageClient-neovim][lsp] + [cquery][cquery] + [deoplete][deoplete] - [Neovim][neovim] + [ALE][ale] + [ccls][ccls] -The release workflow supports Linux amd64/arm64, Windows amd64, and macOS arm64. Each artifact is -built on a native GitHub-hosted runner and must successfully execute `compiledb --help` before it -can be published. Linux amd64 additionally gates releases on `gofmt`, `go vet`, the full test suite, -and the race detector. Other platforms are unsupported and receive no release artifacts. - ## License GNU GPLv3 diff --git a/internal/build_log_benchmark_test.go b/internal/build_log_benchmark_test.go new file mode 100644 index 0000000..2b4c4e7 --- /dev/null +++ b/internal/build_log_benchmark_test.go @@ -0,0 +1,196 @@ +package internal + +import ( + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/sirupsen/logrus" +) + +const benchmarkBuildLogChunkSize = 1 << 20 + +var benchmarkBuildLogSizes = []struct { + name string + size int64 +}{ + {name: "10MiB", size: 10 << 20}, + {name: "100MiB", size: 100 << 20}, + {name: "500MiB", size: 500 << 20}, +} + +func BenchmarkBuildLogScan(b *testing.B) { + chunk := buildLogBenchmarkChunk() + for _, size := range benchmarkBuildLogSizes { + b.Run(size.name, func(b *testing.B) { + data := buildLogBenchmarkData(size.size, chunk) + b.SetBytes(size.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + lines, err := scanBuildLog(data) + if err != nil { + b.Fatal(err) + } + runtime.KeepAlive(lines) + } + }) + } +} + +func BenchmarkBuildLogMergeLogicalLines(b *testing.B) { + chunk := buildLogBenchmarkChunk() + for _, size := range benchmarkBuildLogSizes { + b.Run(size.name, func(b *testing.B) { + data := buildLogBenchmarkData(size.size, chunk) + lines, err := scanBuildLog(data) + if err != nil { + b.Fatal(err) + } + data = nil + runtime.GC() + + b.SetBytes(size.size) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + logicalLines, issues := mergeLogicalLines(lines) + if len(issues) != 0 { + b.Fatalf("unexpected logical line issues: %#v", issues) + } + runtime.KeepAlive(logicalLines) + } + }) + } +} + +func BenchmarkBuildLogPipeline(b *testing.B) { + chunk := buildLogBenchmarkChunk() + b.Run("DirectGenerate", func(b *testing.B) { + for _, size := range benchmarkBuildLogSizes { + b.Run(size.name, func(b *testing.B) { + benchmarkDirectGenerate(b, size.size, chunk) + }) + } + }) + b.Run("DiscoveryOutput", func(b *testing.B) { + for _, size := range benchmarkBuildLogSizes { + b.Run(size.name, func(b *testing.B) { + benchmarkDiscoveryOutput(b, size.size, chunk) + }) + } + }) +} + +func benchmarkDirectGenerate(b *testing.B, size int64, chunk []byte) { + directory := b.TempDir() + inputFile := filepath.Join(directory, "build.log") + outputFile := filepath.Join(directory, "compile_commands.json") + file, err := os.Create(inputFile) + if err != nil { + b.Fatal(err) + } + if err := writeBuildLogBenchmarkData(file, size, chunk); err != nil { + file.Close() + b.Fatal(err) + } + if err := file.Close(); err != nil { + b.Fatal(err) + } + + tool := buildLogBenchmarkTool(inputFile, outputFile, directory) + b.SetBytes(size) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + tool.Generate() + if tool.StatusCode != 0 { + b.Fatalf("parser status: %d", tool.StatusCode) + } + } +} + +func benchmarkDiscoveryOutput(b *testing.B, size int64, chunk []byte) { + directory := b.TempDir() + tool := buildLogBenchmarkTool("stdin", filepath.Join(directory, "compile_commands.json"), directory) + tool.makeDirectoryMarkers = true + b.SetBytes(size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + var stdout bytes.Buffer + if err := writeBuildLogBenchmarkData(&stdout, size, chunk); err != nil { + b.Fatal(err) + } + buildLog, err := scanBuildLog(stdout.Bytes()) + if err != nil { + b.Fatal(err) + } + tool.Parse(buildLog) + if tool.StatusCode != 0 { + b.Fatalf("parser status: %d", tool.StatusCode) + } + } +} + +func buildLogBenchmarkTool(inputFile, outputFile, buildDir string) *Tool { + logger := logrus.New() + logger.SetOutput(io.Discard) + return NewTool(Config{ + InputFile: inputFile, + OutputFile: outputFile, + BuildDir: buildDir, + RegexCompile: RegexCompile, + RegexFile: RegexFile, + NoStrict: true, + Overwrite: true, + }, logger) +} + +func buildLogBenchmarkChunk() []byte { + const ( + compileCommand = "cc -DBENCHMARK=1 \\\n-Igenerated \\\n-c benchmark.c\n" + noiseLine = "[benchmark] generated target remains unchanged; no compiler command was emitted\n" + ) + + chunk := make([]byte, 0, benchmarkBuildLogChunkSize) + chunk = append(chunk, compileCommand...) + for len(chunk)+len(noiseLine) <= cap(chunk) { + chunk = append(chunk, noiseLine...) + } + for len(chunk) < cap(chunk)-1 { + chunk = append(chunk, 'x') + } + return append(chunk, '\n') +} + +func buildLogBenchmarkData(size int64, chunk []byte) []byte { + data := make([]byte, size) + for offset := 0; offset < len(data); { + offset += copy(data[offset:], chunk) + } + return data +} + +func writeBuildLogBenchmarkData(writer io.Writer, size int64, chunk []byte) error { + for size > 0 { + writeSize := int64(len(chunk)) + if writeSize > size { + writeSize = size + } + written, err := writer.Write(chunk[:writeSize]) + if err != nil { + return err + } + if written != int(writeSize) { + return io.ErrShortWrite + } + size -= writeSize + } + return nil +}