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
136 changes: 136 additions & 0 deletions .github/scripts/benchstat-summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
benchstat-summary.py — parse benchstat output and produce a markdown summary.

Usage: benchstat-summary.py [--threshold PCT] benchstat.txt

Extracts significant regressions/improvements from benchstat output.
Exit code 1 if any regression exceeds the threshold (default: 5%).
"""

import argparse
import re
import sys
from dataclasses import dataclass


@dataclass
class Change:
name: str
base: str
head: str
pct: float
pval: str


# Matches lines like:
# BenchmarkName-4 1.030m ± 3% 1.304m ± 5% +26.57% (p=0.000 n=10)
# BenchmarkName-4 3.997 ± 1% 3.910 ± 0% -2.16% (p=0.000 n=10)
LINE_RE = re.compile(
r"^(\S+)" # benchmark name
r"\s+"
r"(\S+)" # base value
r"\s+±\s+\d+%" # base variance
r"\s+"
r"(\S+)" # head value
r"\s+±\s+\d+%" # head variance
r"\s+"
r"([+-]\d+\.\d+)%" # percentage change
r"\s+"
r"\(p=(\d+\.\d+)" # p-value
)


def parse_benchstat(path: str) -> list[Change]:
changes = []
with open(path) as f:
for line in f:
stripped = line.strip()
if stripped.startswith("geomean"):
continue
m = LINE_RE.match(stripped)
if not m:
continue
changes.append(
Change(
name=m.group(1),
base=m.group(2),
head=m.group(3),
pct=float(m.group(4)),
pval=m.group(5),
)
)
return changes


def render_markdown(changes: list[Change], threshold: float) -> str:
regressions = sorted([c for c in changes if c.pct > 0], key=lambda c: -c.pct)
improvements = sorted([c for c in changes if c.pct < 0], key=lambda c: c.pct)

if not regressions and not improvements:
return "### No significant performance changes detected\n"

lines: list[str] = []

if regressions:
above = [r for r in regressions if r.pct > threshold]
if above:
lines.append(
f"### {len(regressions)} regression(s) detected (threshold: >{threshold:g}%)\n"
)
else:
lines.append(
f"### {len(regressions)} minor regression(s) (all within {threshold:g}% threshold)\n"
)

lines.append("| Benchmark | Base | Head | Change | p-value |")
lines.append("|-----------|------|------|--------|---------|")
for r in regressions:
change = f"+{r.pct:.2f}%"
if r.pct > threshold:
change = f"**{change}**"
lines.append(f"| `{r.name}` | {r.base} | {r.head} | {change} | {r.pval} |")
lines.append("")

if improvements:
lines.append(f"<details>")
lines.append(f"<summary>{len(improvements)} improvement(s)</summary>\n")
lines.append("| Benchmark | Base | Head | Change | p-value |")
lines.append("|-----------|------|------|--------|---------|")
for imp in improvements:
lines.append(
f"| `{imp.name}` | {imp.base} | {imp.head} | {imp.pct:.2f}% | {imp.pval} |"
)
lines.append("")
lines.append("</details>")
lines.append("")

return "\n".join(lines)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", help="Path to benchstat output file")
parser.add_argument(
"--threshold",
type=float,
default=5,
help="Regression percentage threshold to flag as failure (default: 5)",
)
args = parser.parse_args()

changes = parse_benchstat(args.input)
summary = render_markdown(changes, args.threshold)
print(summary)

# Exit 1 if any regression exceeds the threshold
regressions_above_threshold = [c for c in changes if c.pct > args.threshold]
if regressions_above_threshold:
print(f"\nFailed: {len(regressions_above_threshold)} benchmark(s) regressed by more than {args.threshold:g}%:")
for c in sorted(regressions_above_threshold, key=lambda c: -c.pct):
print(f" {c.name}: {c.base} -> {c.head} (+{c.pct:.2f}%)")
sys.exit(1)


if __name__ == "__main__":
main()
153 changes: 153 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
on:
pull_request:

name: Benchmark

permissions:
contents: read
issues: write
pull-requests: write

jobs:
bench:
name: Benchmark
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Install Go
uses: buildjet/setup-go@v5
with:
go-version: 1.25.x
cache: false
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Post "benchmark running" comment
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- benchstat-report -->';
const body = [
marker,
'Benchmark running.',
'',
`Base: \`${{ github.event.pull_request.base.sha }}\``,
`Head: \`${{ github.event.pull_request.head.sha }}\``,
'',
'This comment will be updated when benchmarks complete.',
].join('\n');

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});

const existing = comments.find(c => (c.body || '').includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Install benchstat
run: |
GOBIN="$PWD/.bin" go install golang.org/x/perf/cmd/benchstat@latest
echo "$PWD/.bin" >> "$GITHUB_PATH"
- name: Prepare base worktree
run: |
mkdir -p .bench
git worktree add .bench/base "${{ github.event.pull_request.base.sha }}"
# Override the base's bench file with HEAD's so both runs execute
# the same benchmark suite — only the implementation under test differs.
cp serialize_bench_test.go .bench/base/serialize_bench_test.go
- name: Benchmark base
run: |
cd .bench/base
go test -run=^$ -bench=BenchmarkSerialize -count=6 -timeout 20m \
github.com/flanksource/gomplate/v3 | tee "$GITHUB_WORKSPACE/bench-base.txt"
- name: Benchmark head
run: |
go test -run=^$ -bench=BenchmarkSerialize -count=6 -timeout 20m \
github.com/flanksource/gomplate/v3 | tee bench-head.txt
- name: Compare
run: |
benchstat bench-base.txt bench-head.txt > benchstat.txt

# Generate the summary (ignore exit code here; we check it in the next step)
python3 .github/scripts/benchstat-summary.py benchstat.txt > bench-summary.md || true

{
echo "<!-- benchstat-report -->"
echo "## Benchstat"
echo ""
echo "Base: \`${{ github.event.pull_request.base.sha }}\`"
echo "Head: \`${{ github.event.pull_request.head.sha }}\`"
echo ""
cat bench-summary.md
echo ""
echo '<details>'
echo '<summary>Full benchstat output</summary>'
echo ""
echo '```text'
cat benchstat.txt
echo '```'
echo ""
echo '</details>'
} > bench-report.md
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: |
bench-base.txt
bench-head.txt
benchstat.txt
bench-report.md
retention-days: 14
- name: Post report to PR
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('bench-report.md', 'utf8');
const marker = '<!-- benchstat-report -->';

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});

const existing = comments.find(c => (c.body || '').includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
}
- name: Check for regressions
run: python3 .github/scripts/benchstat-summary.py --threshold 5 benchstat.txt > /dev/null
33 changes: 19 additions & 14 deletions serialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type AsMapper interface {
AsMap(fields ...string) map[string]any
}

type nativeType struct {
path jp.Expr
val any
}

// Serialize iterates over each key-value pair in the input map
// serializes any struct value to map[string]any.
func Serialize(in map[string]any) (out map[string]any, err error) {
Expand All @@ -40,37 +45,37 @@ func Serialize(in map[string]any) (out map[string]any, err error) {

defer func() {
if r := recover(); r != nil {
if _err, ok := r.(error); ok {
err = _err
}
err = fmt.Errorf("panic during serialization: %v", r)
}
}()

// cel supports time.Duration natively - save original and then replace it after decomposition
// FIXME: This does not work for anything inside Structs
nativeTypes := make(map[string]any, len(in))
nativeTypes := make([]nativeType, 0, len(in))
jp.Walk(in, func(path jp.Expr, value any) {
add := func(v any) {
// Copy path so later Walk mutations can not affect the stored expression.
nativeTypes = append(nativeTypes, nativeType{path: append(jp.Expr(nil), path...), val: v})
}

switch v := value.(type) {
case AsMapper:
nativeTypes[path.String()] = v.AsMap()
add(v.AsMap())
case uuid.UUID:
nativeTypes[path.String()] = v.String()
add(v.String())
case *uuid.UUID:
nativeTypes[path.String()] = v.String()
if v != nil {
add(v.String())
}
case time.Duration:
nativeTypes[path.String()] = v
add(v)
}
})

out = alt.Alter(in, &opts).(map[string]any)

for path, v := range nativeTypes {
expr, err := jp.ParseString(path)
if err != nil {
return nil, err
}
if err := expr.SetOne(out, v); err != nil {
for _, native := range nativeTypes {
if err := native.path.SetOne(out, native.val); err != nil {
return nil, err
}
}
Expand Down
Loading
Loading