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
87 changes: 84 additions & 3 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
name: Benchmark

on:
pull_request:
branches: [dev]
types: [opened, synchronize, reopened, labeled]
push:
branches: [dev]
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
inputs:
duration:
Expand All @@ -18,32 +23,75 @@ env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
BENCHER_PROJECT: ${{ secrets.BENCHER_PROJECT }}
BENCHER_DASHBOARD_URL: ${{ vars.BENCHER_DASHBOARD_URL }}

jobs:
benchmark:
permissions:
contents: read
pull-requests: write
issues: write
statuses: write
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Check benchmark trigger gate
id: gate
uses: actions/github-script@v9
with:
script: |
if (context.eventName !== 'pull_request') {
core.setOutput('run', 'true');
return;
}

const labels = context.payload.pull_request.labels.map((label) => label.name);
if (labels.includes('run-benchmark')) {
core.setOutput('run', 'true');
return;
}

const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const shouldRun = files.some((file) => {
const name = file.filename;
return name === 'build.zig' || name.startsWith('src/') || name.startsWith('modules/') || name.startsWith('assets/shaders/');
});
core.setOutput('run', shouldRun ? 'true' : 'false');

- name: Explain skipped benchmark
if: steps.gate.outputs.run != 'true'
run: |
printf 'Skipping benchmark: no benchmark-relevant paths changed and run-benchmark label is absent.\n'

- name: Setup Nix
if: steps.gate.outputs.run == 'true'
uses: ./.github/actions/setup-nix

- name: Setup Zig cache
if: steps.gate.outputs.run == 'true'
uses: ./.github/actions/setup-zig-cache
with:
cache-key-prefix: zig-ci-graphics

- name: Start headless Wayland compositor
if: steps.gate.outputs.run == 'true'
uses: ./.github/actions/start-weston

- name: Setup Lavapipe Vulkan
if: steps.gate.outputs.run == 'true'
uses: ./.github/actions/setup-lavapipe

- name: Run benchmark suite
if: steps.gate.outputs.run == 'true'
id: run_benchmark
uses: ./.github/actions/run-with-log
with:
Expand All @@ -60,18 +108,51 @@ jobs:
BENCHMARK_DURATION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.duration || '5' }}

- name: Stop headless Wayland compositor
if: always()
if: always() && steps.gate.outputs.run == 'true'
uses: ./.github/actions/stop-weston

- name: Compare against baseline
if: steps.gate.outputs.run == 'true'
id: compare
run: |
for preset in low medium high; do
bash scripts/compare_benchmarks.sh docs/benchmarks/baseline.json "benchmark-results/${preset}.json" --preset "$preset"
done

- name: Install Bencher CLI
if: steps.gate.outputs.run == 'true' && env.BENCHER_API_TOKEN != '' && env.BENCHER_PROJECT != ''
uses: bencherdev/bencher@30a740a2e4246560b1a5fd424057d84aa2b188d6

- name: Publish Bencher trends
if: steps.gate.outputs.run == 'true' && env.BENCHER_API_TOKEN != '' && env.BENCHER_PROJECT != ''
env:
BENCHER_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
for preset in low medium high; do
bencher run \
--project "$BENCHER_PROJECT" \
--branch "$BENCHER_BRANCH" \
--testbed "github-actions-lavapipe" \
--token "$BENCHER_API_TOKEN" \
--adapter json \
--err \
"cat benchmark-results/${preset}.json"
done

- name: Comment Bencher dashboard
if: steps.gate.outputs.run == 'true' && github.event_name == 'pull_request' && env.BENCHER_DASHBOARD_URL != ''
uses: actions/github-script@v9
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: `Benchmark trends: ${process.env.BENCHER_DASHBOARD_URL}`,
});

- name: Upload benchmark artifacts
if: always()
if: always() && steps.gate.outputs.run == 'true'
uses: actions/upload-artifact@v7
with:
name: benchmark-results
Expand All @@ -82,7 +163,7 @@ jobs:
retention-days: 30

- name: Publish commit status
if: always()
if: always() && steps.gate.outputs.run == 'true'
uses: actions/github-script@v9
env:
BENCHMARK_RUN_OUTCOME: ${{ steps.run_benchmark.outcome }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/opencode-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
MODULE_PATH: ${{ steps.select-module.outputs.module_path }}

- name: Run opencode audit
uses: anomalyco/opencode/github@latest
uses: anomalyco/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.OPENCODE_PAT }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/opencode-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
- name: Run opencode
id: ai_review
timeout-minutes: 40
uses: anomalyco/opencode/github@latest
uses: anomalyco/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/opencode-test-writer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:

- name: Run opencode test writer
if: steps.check-existing.outputs.skip != 'true'
uses: anomalyco/opencode/github@latest
uses: anomalyco/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a
env:
GITHUB_TOKEN: ${{ secrets.OPENCODE_PAT }}
GH_TOKEN: ${{ secrets.OPENCODE_PAT }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/opencode-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
with:
prompt-file: .github/prompts/triage.md

- uses: anomalyco/opencode/github@latest
- uses: anomalyco/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a
if: steps.check.outputs.result == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
run: mkdir -p /tmp/opencode-cache && echo "XDG_CACHE_HOME=/tmp/opencode-cache" >> "$GITHUB_ENV"

- name: Run opencode
uses: anomalyco/opencode/github@latest
uses: anomalyco/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a
env:
GITHUB_TOKEN: ${{ secrets.OPENCODE_PAT }}
GH_TOKEN: ${{ secrets.OPENCODE_PAT }}
Expand Down
43 changes: 32 additions & 11 deletions .github/workflows/visual-test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
name: visual-test

on:
schedule:
- cron: "43 4 * * *"
pull_request:
branches: [dev]
types: [labeled]
workflow_dispatch:

concurrency:
Expand All @@ -9,12 +14,14 @@ concurrency:

jobs:
visual-test:
if: github.event_name != 'pull_request' || github.event.label.name == 'run-visual-test'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write
contents: read
issues: write
pull-requests: read

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -52,6 +59,11 @@ jobs:
--description "Issues from automated visual regression tests" \
--color "E06C75"
fi
if ! gh label list --json name --jq '.[].name' | grep -q '^run-visual-test$'; then
gh label create "run-visual-test" \
--description "Run deterministic visual regression workflow on a PR" \
--color "E06C75"
fi
env:
GH_TOKEN: ${{ secrets.OPENCODE_PAT }}

Expand All @@ -66,33 +78,40 @@ jobs:
name: Visual Test
timeout: 20m
log-file: build-output.log
command: nix develop .#ci-graphics --command zig build run -Dscreenshot-path=screenshot.ppm -Dskip-present=true
command: nix develop .#ci-graphics --command zig build run -Dscreenshot-path=screenshot.png -Dskip-present=true
env:
ZIG_GLOBAL_CACHE_DIR: /tmp/zig-cache-global
XDG_RUNTIME_DIR: /tmp/runtime-runner
WAYLAND_DISPLAY: headless
ZIGCRAFT_SMOKE_FRAMES: "5"
ZIGCRAFT_SAFE_RENDER: "1"

- name: Convert PPM to PNG
id: convert
- name: Check screenshot exists
id: check_screenshot
if: always()
run: |
if [ -f screenshot.ppm ]; then
nix run nixpkgs#imagemagick -- screenshot.ppm screenshot.png
if [ -f screenshot.png ]; then
echo "screenshot_exists=true" >> "$GITHUB_OUTPUT"
else
echo "screenshot_exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Compare against golden image
id: golden_diff
if: steps.check_screenshot.outputs.screenshot_exists == 'true'
run: |
nix shell nixpkgs#imagemagick -c bash scripts/compare_visual_golden.sh screenshot.png docs/visual-test/golden/menu.png visual-diff.png
env:
VISUAL_DIFF_RMSE_TOLERANCE: "0.015"

- name: Upload screenshot artifact
if: steps.convert.outputs.screenshot_exists == 'true'
if: always() && steps.check_screenshot.outputs.screenshot_exists == 'true'
uses: actions/upload-artifact@v7
with:
name: menu-screenshot
path: |
screenshot.png
screenshot.ppm
visual-diff.png
retention-days: 30

- name: Check build log exists
Expand All @@ -115,8 +134,9 @@ jobs:
uses: ./.github/actions/stop-weston

- name: Run opencode visual verification
if: steps.convert.outputs.screenshot_exists == 'true'
uses: anomalyco/opencode/github@latest
if: always() && steps.check_screenshot.outputs.screenshot_exists == 'true'
continue-on-error: true
uses: anomalyco/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a
env:
GITHUB_TOKEN: ${{ secrets.OPENCODE_PAT }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
Expand All @@ -125,8 +145,9 @@ jobs:
prompt: ${{ env.VISUAL_VERIFY_PROMPT }}

- name: Run opencode failure diagnosis
if: failure() || steps.screenshot.outcome == 'failure' || steps.convert.outputs.screenshot_exists != 'true'
uses: anomalyco/opencode/github@latest
if: failure() || steps.screenshot.outcome == 'failure' || steps.check_screenshot.outputs.screenshot_exists != 'true'
continue-on-error: true
uses: anomalyco/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a
env:
GITHUB_TOKEN: ${{ secrets.OPENCODE_PAT }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
Expand Down
34 changes: 2 additions & 32 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -866,38 +866,8 @@ fn applySanitizeC(sanitize_c: ?std.zig.SanitizeC, modules: []const *std.Build.Mo
}

fn defineShaderValidation(b: *std.Build, test_step: *std.Build.Step) void {
const shader_paths = [_][]const u8{
"assets/shaders/vulkan/terrain.vert",
"assets/shaders/vulkan/terrain.frag",
"assets/shaders/vulkan/shadow.vert",
"assets/shaders/vulkan/shadow.frag",
"assets/shaders/vulkan/sky.vert",
"assets/shaders/vulkan/sky.frag",
"assets/shaders/vulkan/ui.vert",
"assets/shaders/vulkan/ui.frag",
"assets/shaders/vulkan/ui_tex.vert",
"assets/shaders/vulkan/ui_tex.frag",
"assets/shaders/vulkan/debug_shadow.vert",
"assets/shaders/vulkan/debug_shadow.frag",
"assets/shaders/vulkan/ssao.vert",
"assets/shaders/vulkan/ssao.frag",
"assets/shaders/vulkan/ssao_blur.frag",
"assets/shaders/vulkan/g_pass.frag",
"assets/shaders/vulkan/taa.vert",
"assets/shaders/vulkan/taa.frag",
"assets/shaders/vulkan/lpv_inject.comp",
"assets/shaders/vulkan/lpv_propagate.comp",
"assets/shaders/vulkan/culling.comp",
"assets/shaders/vulkan/depth_pyramid.comp",
"assets/shaders/vulkan/water.vert",
"assets/shaders/vulkan/water.frag",
"assets/shaders/vulkan/mesh.comp",
};

for (shader_paths) |path| {
const validate = b.addSystemCommand(&.{ "glslangValidator", "-V", path });
test_step.dependOn(&validate.step);
}
const validate = b.addSystemCommand(&.{ "bash", "scripts/check_spirv_sizes.sh", "docs/shaders/spirv-sizes.json" });
test_step.dependOn(&validate.step);
}

fn addCimgui(_: *std.Build, compile: *std.Build.Step.Compile) void {
Expand Down
16 changes: 16 additions & 0 deletions docs/benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,19 @@ This gate is intentionally a short canary so every `dev` push gets a bounded per
- Higher `draw_calls_avg` is a regression.

The p1 FPS metric is the primary user-visible smoothness guard. GPU time and draw calls catch rendering-cost regressions even when FPS is noisy on virtualized runners.

## Absolute SLOs

The benchmark harness also enforces absolute service-level objectives before regression comparison. These thresholds are intentionally separate from `baseline.json`: a run can fail even if there is no baseline drift when the absolute floor or ceiling is breached.

| Preset | p1 FPS min | Max frame ms | Draw calls avg max | Vertices avg max | GPU memory max |
| --- | ---: | ---: | ---: | ---: | ---: |
| low | 12 | 260 | 700 | 3,500,000 | 1,800 MB |
| medium | 8 | 260 | 2,600 | 6,000,000 | 2,400 MB |
| high | 6 | 260 | 3,600 | 8,500,000 | 2,800 MB |
| ultra | 4 | 260 | 4,500 | 12,000,000 | 3,400 MB |
| extreme | 3 | 260 | 5,500 | 16,000,000 | 4,096 MB |

The FPS floors are Lavapipe CI canary values, not player-facing hardware targets. The 260 ms spike guard allows the current Lavapipe startup spike in 5-second canary runs while still failing large stalls. Draw-call, vertex, and measured GPU resource memory ceilings are deliberately loose enough for runner variance but strict enough to fail large accidental rendering explosions.

Benchmark history is pushed to Bencher when the `BENCHER_API_TOKEN` and `BENCHER_PROJECT` secrets are configured. Pull-request benchmark runs are gated by either relevant file changes (`src/**`, `modules/**`, `assets/shaders/**`, or `build.zig`) or the `run-benchmark` label.
Loading
Loading