Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ integration/testdata/local/

# Local test and metric artifacts
.coverage/

# Local benchmark comparison output
.bench/
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ THIS_FILE := $(lastword $(MAKEFILE_LIST))
# Go configuration
GO_CMD ?= go
CGO_ENABLED ?= 0
BENCH ?= .
BENCH_COUNT ?= 10
BENCH_TIME ?= 1s
BENCH_BASE ?= main
BENCH_TARGET ?= HEAD
BENCH_KIND ?= all

# Main packages to test/build
MAIN_PACKAGES := $(shell $(GO_CMD) list ./...)
Expand Down Expand Up @@ -92,6 +98,14 @@ test_integration:
@echo "Running all integration tests..."
@$(GO_CMD) test -tags 'manual_integration integration' -race -cover -count=1 -p=1 -parallel=1 $(MAIN_PACKAGES)

test_bench:
@echo "Running benchmarks..."
@$(GO_CMD) test -run '^$$' -bench '$(BENCH)' -benchmem -count=$(BENCH_COUNT) -benchtime=$(BENCH_TIME) $(MAIN_PACKAGES)

bench_diff:
@echo "Running benchmark diff..."
@$(GO_CMD) run ./cmd/benchdiff --base '$(BENCH_BASE)' --target '$(BENCH_TARGET)' --kind '$(BENCH_KIND)' --bench '$(BENCH)' --bench-count '$(BENCH_COUNT)' --benchtime '$(BENCH_TIME)' $(BENCHDIFF_ARGS)

test_neo4j:
@echo "Running Neo4j integration tests..."
@$(GO_CMD) test -tags integration -race -cover -count=1 -p=1 -parallel=1 $(MAIN_PACKAGES)
Expand Down Expand Up @@ -220,6 +234,7 @@ help:
@echo " test_all - Run all tests including integration tests"
@echo " test_integration - Run all integration tests"
@echo " test_bench - Run benchmark test"
@echo " bench_diff - Compare benchmarks between commits"
@echo " test_neo4j - Run Neo4j integration tests"
@echo " test_pg - Run PostgreSQL integration tests"
@echo " plan_corpus - Capture shared corpus query plans for configured backends"
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,35 @@ make quality FUZZ_REPORT=.coverage/fuzz.json MUTATION_REPORT=.coverage/mutation.

`make quality_backend` captures PostgreSQL and Neo4j integration results for backend equivalence comparison. It requires
`PG_CONNECTION_STRING` and `NEO4J_CONNECTION_STRING`. `make quality_bench` writes benchmark markdown and JSON captures
for later baseline comparison. Benchmark drift comparison is performed by
`make quality` through `tools/metrics`; there is no separate benchmark diff
command package.
for later baseline comparison. Benchmark drift comparison can be performed by `make quality` through `tools/metrics` when
`BENCHMARK_REPORT` and `BENCHMARK_BASELINE` are provided.

Run the package benchmark suite with:

```bash
make test_bench
```

Use `cmd/benchdiff` to compare benchmarks between two committed refs without changing the active worktree:

```bash
go run ./cmd/benchdiff -base main -target HEAD -kind unit
```

For integration benchmark comparisons, provide the same `CONNECTION_STRING` used by integration tests:

```bash
export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawgs"
go run ./cmd/benchdiff -base main -target HEAD -kind all -driver pg -fail-regression 10%
```

The harness writes raw outputs and a Markdown report under `.bench/runs/` by default. The report begins with comparison
findings, includes the raw `benchstat` output for each benchmark suite, and ends with a table of all captured benchmark
numbers.

The integration benchmark runner includes committed `base`, `adcs_fanout`, and `traversal_shapes` datasets by default.
The traversal shape suite checks expected result counts for chain, fanout, bounded cycle, disconnected,
edge-kind-selective, and multi-path shortest-path scenarios before recording timings.

`make plan_corpus` captures plan diagnostics for the shared Cypher integration corpus. It accepts either
`CONNECTION_STRING` for one backend or `PG_CONNECTION_STRING` and `NEO4J_CONNECTION_STRING` for both backends, then
Expand Down
23 changes: 18 additions & 5 deletions cmd/benchmark/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
# Benchmark

Runs query scenarios against a real database and outputs a markdown timing table with warm-up row counts. Path-heavy scenarios can also report distinct returned path rows and duplicate returned path rows. PostgreSQL explain capture includes translated SQL, plan text, and optimizer rule/lowering metadata in JSON output.
Runs query scenarios against a real database and outputs markdown, JSON, or benchfmt timing data. Markdown reports include warm-up row counts, path-heavy scenarios can report distinct and duplicate returned path rows, and PostgreSQL explain capture includes translated SQL, plan text, and optimizer rule/lowering metadata in JSON output.

## Usage

```bash
# Default datasets (base and adcs_fanout)
# Default datasets (base, adcs_fanout, and traversal_shapes)
go run ./cmd/benchmark -connection "postgresql://dawgs:dawgs@localhost:5432/dawgs"

# Traversal shape dataset only
go run ./cmd/benchmark -connection "..." -dataset traversal_shapes

# ADCS fanout dataset with PostgreSQL EXPLAIN diagnostics
go run ./cmd/benchmark -connection "..." -dataset adcs_fanout -json-output report.json -explain

# Local dataset (not committed to repo)
go run ./cmd/benchmark -connection "..." -dataset local/phantom

Expand All @@ -23,8 +29,8 @@ go run ./cmd/benchmark -connection "..." -output report.md
# Save markdown and JSON for quality baseline comparison
go run ./cmd/benchmark -connection "..." -output report.md -json-output report.json

# Capture PostgreSQL EXPLAIN (ANALYZE, BUFFERS), translated SQL, and optimizer metadata in JSON output
go run ./cmd/benchmark -connection "..." -dataset adcs_fanout -json-output report.json -explain
# Emit benchfmt for benchstat
go run ./cmd/benchmark -connection "..." -format benchfmt -output report.bench
```

## Flags
Expand All @@ -38,9 +44,16 @@ go run ./cmd/benchmark -connection "..." -dataset adcs_fanout -json-output repor
| `-dataset` | | Run only this dataset |
| `-local-dataset` | | Add a local dataset to the default set |
| `-dataset-dir` | `integration/testdata` | Path to testdata directory |
| `-output` | stdout | Markdown output file |
| `-format` | `markdown` | Output format (`markdown`, `json`, `benchfmt`) |
| `-output` | stdout | Output file for selected format |
| `-json-output` | | JSON output file for baseline comparison |

Use `-format benchfmt` when comparing scenario timings with `benchstat`. Each timed scenario iteration is emitted as a separate `ns/op` sample so two benchmark runs can be compared directly.

The committed default datasets are `base`, `adcs_fanout`, and `traversal_shapes`. `traversal_shapes` covers chain,
fanout, bounded cycle, disconnected, edge-kind-selective, and multi-path shortest-path traversal shapes. Scenarios with
declared expected row counts fail before reporting timings if a query returns the wrong result shape.

## Example: Neo4j on local/phantom

```
Expand Down
12 changes: 8 additions & 4 deletions cmd/benchmark/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ func main() {
driver = flag.String("driver", "pg", "database driver (pg, neo4j)")
connStr = flag.String("connection", "", "database connection string (or CONNECTION_STRING)")
iterations = flag.Int("iterations", 10, "timed iterations per scenario")
output = flag.String("output", "", "markdown output file (default: stdout)")
output = flag.String("output", "", "output file (default: stdout)")
format = flag.String("format", reportFormatMarkdown, "output format (markdown, json, benchfmt)")
jsonOutput = flag.String("json-output", "", "JSON output file for baseline comparison")
explain = flag.Bool("explain", false, "capture PostgreSQL EXPLAIN (ANALYZE, BUFFERS) for Cypher scenarios")
datasetDir = flag.String("dataset-dir", "integration/testdata", "path to testdata directory")
Expand All @@ -54,6 +55,9 @@ func main() {
if err := validateIterations(*iterations); err != nil {
fatal("%v", err)
}
if !isReportFormat(*format) {
fatal("unsupported output format %q", *format)
}

conn := *connStr
if conn == "" {
Expand Down Expand Up @@ -179,7 +183,7 @@ func main() {
}
}

// Write markdown
// Write report
var mdOut *os.File
if *output != "" {
var err error
Expand All @@ -192,8 +196,8 @@ func main() {
mdOut = os.Stdout
}

if err := writeMarkdown(mdOut, report); err != nil {
fatal("failed to write markdown: %v", err)
if err := writeReport(mdOut, report, *format); err != nil {
fatal("failed to write report: %v", err)
}

if *output != "" {
Expand Down
104 changes: 104 additions & 0 deletions cmd/benchmark/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@ import (
"encoding/json"
"fmt"
"io"
"runtime"
"strings"
"time"
"unicode"
)

const (
reportFormatBenchfmt = "benchfmt"
reportFormatJSON = "json"
reportFormatMarkdown = "markdown"
)

// Report holds all benchmark results and metadata.
Expand All @@ -32,6 +41,30 @@ type Report struct {
Results []Result `json:"results"`
}

func writeReport(w io.Writer, r Report, format string) error {
if !isReportFormat(format) {
return fmt.Errorf("unsupported output format %q", format)
}

switch format {
case reportFormatBenchfmt:
return writeBenchfmt(w, r)
case reportFormatJSON:
return writeJSON(w, r)
default:
return writeMarkdown(w, r)
}
}

func isReportFormat(format string) bool {
switch format {
case reportFormatBenchfmt, reportFormatJSON, reportFormatMarkdown:
return true
default:
return false
}
}

func writeJSON(w io.Writer, r Report) error {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
Expand Down Expand Up @@ -82,6 +115,77 @@ func fmtExplainStatus(explain *ExplainResult) string {
return "captured"
}

func writeBenchfmt(w io.Writer, r Report) error {
goos := runtime.GOOS
goarch := runtime.GOARCH
procs := runtime.GOMAXPROCS(0)

fmt.Fprintf(w, "goos: %s\n", goos)
fmt.Fprintf(w, "goarch: %s\n", goarch)
fmt.Fprintf(w, "pkg: github.com/specterops/dawgs/cmd/benchmark\n")

for _, res := range r.Results {
benchName := benchName(r.Driver, res)

for _, sample := range res.Samples {
fmt.Fprintf(w, "%s-%d\t1\t%d ns/op\n", benchName, procs, sample.Nanoseconds())
}
}

return nil
}

func benchName(driver string, res Result) string {
parts := []string{
"BenchmarkDawgsIntegration",
sanitizeBenchNamePart(driver),
sanitizeBenchNamePart(res.Dataset),
sanitizeBenchNamePart(res.Section),
sanitizeBenchNamePart(res.Label),
}

return strings.Join(parts, "/")
}

func sanitizeBenchNamePart(value string) string {
var builder strings.Builder
lastUnderscore := false

for _, char := range value {
switch {
case char == '/' || char == '-' || char == '_':
if char == '_' {
if !lastUnderscore {
builder.WriteRune(char)
}
lastUnderscore = true
} else {
builder.WriteRune(char)
lastUnderscore = false
}
case unicode.IsLetter(char) || unicode.IsDigit(char):
builder.WriteRune(char)
lastUnderscore = false
case unicode.IsSpace(char):
if !lastUnderscore {
builder.WriteByte('_')
}
lastUnderscore = true
default:
if !lastUnderscore {
builder.WriteByte('_')
}
lastUnderscore = true
}
}

if builder.Len() == 0 {
return "unknown"
}

return builder.String()
}

func fmtDuration(d time.Duration) string {
ms := float64(d.Microseconds()) / 1000.0
if ms < 1 {
Expand Down
Loading
Loading