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
63 changes: 63 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: perf

on:
push:
branches: [master, main]
paths:
- ".github/workflows/perf.yml"
- "SemiStep/**"
- "ConfigFiles/**"
- "global.json"
- "!**.md"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
perf-gates:
name: Perf Gates
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
defaults:
run:
shell: bash

steps:
- name: checkout
uses: actions/checkout@v7
with:
persist-credentials: false

- name: setup .NET
uses: actions/setup-dotnet@v5
with:
global-json-file: global.json

- name: build
run: dotnet build SemiStep/SemiStep.Tests/SemiStep.Tests.csproj -c Release

# Dev-testbed byte baselines are not comparable here: an empty metrics document flips byte gates to
# record-only; invariant/ratio gates (thresholds in probe code) keep asserting. See Docs/perf/README.md, CI.
- name: neutralize byte baselines (telemetry-only on CI)
run: echo '{ "context": { "testbed": "ci-hosted" }, "metrics": {} }' > Docs/perf/baselines.json

- name: run perf gates
run: dotnet run --project SemiStep/SemiStep.Tests/SemiStep.Tests.csproj -c Release --no-build -- -explicit only

- name: collect actuals artifact
if: always()
run: |
mkdir -p perf-actuals
cp "$(cygpath -u "$TEMP")"/semistep-perf-actuals-*.json perf-actuals/ || echo "no actuals artifact produced"

- name: upload actuals
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-actuals
path: perf-actuals/
if-no-files-found: ignore
17 changes: 17 additions & 0 deletions .zed/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,21 @@
"args": ["format", "SemiStep/SemiStep.slnx"],
"cwd": "$ZED_WORKTREE_ROOT",
},
{
"label": "Perf Gates (explicit tests, Release)",
"command": "dotnet",
"args": [
"run",
"--project",
"SemiStep/SemiStep.Tests/SemiStep.Tests.csproj",
"-c",
"Release",
"--",
"-explicit",
"only",
],
"cwd": "$ZED_WORKTREE_ROOT",
"reveal": "always",
"use_new_terminal": true,
},
]
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifi
```

Test traits: `[Trait("Component", "Core|Config|UI|Domain|Csv|S7")]`, `[Trait("Area", "<AreaName>")]`,
`[Trait("Category", "Unit|Integration|Performance")]` (Performance = env-gated measurement probes, skipped by default).
`[Trait("Category", "Unit|Integration|Performance")]` (Performance = xunit v3 explicit measurement probes,
`[AvaloniaFact(Explicit = true)]` / `[Fact(Explicit = true)]`, NOT run by default `dotnet test`; run them via
`dotnet run --project SemiStep/SemiStep.Tests/SemiStep.Tests.csproj -c Release -- -explicit only`
(`-method "*Name*"` selective); see `Docs/perf/README.md` for the full harness, gate hierarchy, and
re-baseline procedure).

Invalid config test cases use an overlay pattern: copy `SemiStep.Tests/YamlConfigs/Standard/` to a temp
directory and overlay only the differing files from `SemiStep.Tests/YamlConfigs/Invalid/{CaseName}/`.
Expand Down
25 changes: 16 additions & 9 deletions Docs/architecture/recipe-grid-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,13 @@ same recipe and churned gen0 on every mutation. The reductions land in three pla

**Allocation gate.** The keep-attached panel is only half the recycle fix; the child-recycle half
below ("Child recycled in place") lands the other half and carries the chain's first attached
after-trace. The live-app gc-verbose byte gate — realization-subtree allocation share must drop from
~41–56% to <20%, with `Dictionary.Resize`, `CreateCompositionVisual`, and `StyleBase.Attach` /
`Setter.Instance` absent from the scroll path — is a manual step on the Release build against a
~2100-step recipe and stays with the user (`CreateCompositionVisual` / `CreateSKFont` are
composition/Skia costs headless cannot see). Headless tests pin the keep-attached contract
after-trace. The realization-subtree allocation is now an automated black-box gate: the perf
harness (`SemiStep.Tests/Performance/`, see `Docs/perf/README.md`) asserts `FreshVisualInstances == 0`
across a scrolled round-trip and gates bytes per realized column against a committed baseline, so
the scroll-rebuild regression this fix removed is caught by an explicit test rather than by hand.
Only the composition/Skia share stays a manual step on the Release build against a ~2100-step recipe
(`CreateCompositionVisual` / `CreateSKFont` are costs headless cannot see, so felt smoothness on
real hardware stays the manual oracle). Headless tests pin the keep-attached contract
(container reuse across scroll, no `DetachedFromVisualTree` on scroll, bounded `Children.Count`,
focus-anchor deferral, selection round-trip).

Expand Down Expand Up @@ -425,8 +427,8 @@ same recipe and churned gen0 on every mutation. The reductions land in three pla

- **Measured result.** These figures were captured under the earlier `VirtualizingStackPanel` +
pooled-rebind path; they measure the pooled-presenter reuse, not the recycle-in-place panel that
later replaced VSP (whose byte/gen0 allocation gate is still pending live-app measurement — see the
recycle-in-place panel note below). On the viewport-jump metric (one `ScrollIntoView(last)` frame
later replaced VSP (whose byte/gen0 allocation is now gated by the black-box perf harness — see the
recycle-in-place panel note above and `Docs/perf/README.md`). On the viewport-jump metric (one `ScrollIntoView(last)` frame
after a round-trip, so it exercises container recycling), transposed bytes per realized column dropped
from ~14.5x the canonical recycled-row cost to ~1.03x (WideParams, 36 cells/column) and from ~2.3x to
~0.69x (WithGroups, 5 cells/column); gen0/add fell from ~2.58 to ~0.17-0.25 (WideParams) and from
Expand All @@ -443,6 +445,10 @@ same recipe and churned gen0 on every mutation. The reductions land in three pla
## Performance measurement discipline

Each transposed-grid performance round is gated on measurement, not on felt lag or code review.
The lessons from these rounds are now pinned by the black-box perf harness
(`SemiStep/SemiStep.Tests/Performance/`); its commands, gate hierarchy, re-baseline flow, and
headless blind spots are documented in `Docs/perf/README.md`.

The rules:

- **Open with a weighted trace.** Before touching code, capture a weighted CPU trace
Expand All @@ -455,8 +461,9 @@ The rules:
(for the selection fix: per-selection-event cost must not scale with N). Ship the after-trace
with the change so the collapse is documented, not asserted.
- **Keep a checked-in regression instrument.** `TransposedSelectionCostProbe`
(`SemiStep.Tests/Performance/`, env-gated `SEMISTEP_PROBE=1`, `Category=Performance`, skipped in
CI) holds selection size constant at S=200 while N grows (300 / 1200 / 4800) and asserts the
(`SemiStep.Tests/Performance/`, an xunit v3 explicit test (`Explicit = true`), `Category=Performance`,
not run by default and skipped in CI) holds selection size constant at S=200 while N grows
(300 / 1200 / 4800) and asserts the
per-event cost at N=4800 stays within 3× of N=300. The fixed-S design isolates the `IndexOf`-in-N
regression: select-all would make S=N and force O(N) even with the fix. Restoring the `IndexOf`
scan makes the ratio return at ~7×–16× (linear in N at fixed S); the fix stays flat. This is the
Expand Down
Loading