diff --git a/Reactor.slnx b/Reactor.slnx index 5cea5a46..ec358f5c 100644 --- a/Reactor.slnx +++ b/Reactor.slnx @@ -396,6 +396,10 @@ + + + + diff --git a/tests/stress_perf/StressPerf.Flex/FlexSceneSource.cs b/tests/stress_perf/StressPerf.Flex/FlexSceneSource.cs new file mode 100644 index 00000000..c882f063 --- /dev/null +++ b/tests/stress_perf/StressPerf.Flex/FlexSceneSource.cs @@ -0,0 +1,135 @@ +using System.Globalization; + +namespace StressPerf.Flex; + +/// +/// One leaf cell of the deep flex tree. , and +/// are the per-child flex inputs pushed onto the Yoga node every +/// render (via .Flex(grow, basis, ...) + .Width(...)); mutating a +/// fraction of them each tick forces a real Yoga relayout. is the +/// display text, computed once at construction so the per-render hot path only pays +/// for the element-record allocation + the layout pass under measurement — never +/// per-render string formatting (mirrors how KeyedRow pre-bakes its label). +/// +public readonly record struct FlexLeaf(int Id, string Label, double Grow, double Basis, double Width); + +/// +/// Deterministic deep-flex-tree workload data source for the /perf macro benchmark. +/// +/// Where mutates a flat positional +/// grid and the keyed-list source reorders keyed rows, this source backs a DEEP +/// NESTED flex tree (sections → rows → leaf cells) whose leaves' flex inputs +/// (grow / basis / width) are mutated each tick. When the rendered tree pushes those +/// inputs onto the Yoga nodes, a real Yoga measure/layout pass runs every frame — the +/// FlexPanel/Yoga LAYOUT engine the positional StocksGrid and keyed-list workloads +/// never exercise. +/// +/// The tree SHAPE (section / row / column counts) is fixed for the whole run, so the +/// child reconciler takes its cheap positional arm and the cost under measurement is +/// the layout-engine work, not child diffing. Deterministic (fixed RNG seed) so +/// main-vs-PR /perf runs replay identical mutation sequences; leaf count is held +/// constant so working-set and render-count stay stable across the run. +/// +public sealed class FlexSceneSource +{ + // Tree shape. The single knob below — DefaultLeafTarget — drives the whole scale; + // bump it (and nothing else) if a smoke run shows the alloc-bytes/render or the + // memory delta is too small to clear harness noise. The inner shape (rows × cols per + // section) is fixed so the tree stays a deep three-level nest (root-column → + // section-column → row → leaf); the number of SECTIONS is derived to reach the leaf + // target. ~2000 leaves at meaningful depth so per-node inline-array memory + // (#142/#143) and per-frame list/line pooling (#141/#144) are at a scale that + // survives the noisy Avg-Memory-MB metric — node count is exactly what makes the + // inline-per-node-storage win visible. + public const int DefaultLeafTarget = 2000; + public const int RowsPerSection = 10; + public const int ColsPerRow = 10; + + public int Sections { get; } + public int Rows { get; } + public int Cols { get; } + + private readonly FlexLeaf[] _leaves; + private readonly Random _rng = new(42); // deterministic seed (matches StockDataSource / KeyedListSource) + + public FlexSceneSource(int leafTarget = DefaultLeafTarget) + { + if (leafTarget < 1) leafTarget = 1; + Rows = RowsPerSection; + Cols = ColsPerRow; + int perSection = Rows * Cols; + // Round the section count UP so the realized leaf count is >= the requested + // target (e.g. target 2000 → 20 sections → exactly 2000 leaves). + Sections = Math.Max(1, (leafTarget + perSection - 1) / perSection); + + _leaves = new FlexLeaf[Sections * Rows * Cols]; + for (int i = 0; i < _leaves.Length; i++) + _leaves[i] = MakeLeaf(i); + } + + /// Total leaf-cell count (held constant across ticks). + public int Count => _leaves.Length; + + /// + /// Re-roll the flex inputs (grow / basis / width) on a percentage of the leaf cells + /// for one tick. + /// + /// sets the layout-churn budget + /// k = round(N * percent / 100) (clamped to [0, N]). Those k + /// leaves get NEW grow/basis/width values, so their Yoga nodes are re-dirtied and a + /// real measure/layout pass runs. The remaining N - k leaves keep their + /// EXACT current values — when the tree re-pushes those unchanged inputs each render, + /// that is precisely the path #670's YogaNode setter equality guards optimize + /// (unchanged setter → no re-dirty → the frame-level layout cache HITS). So + /// percent == 0 is the ALL-UNCHANGED FLOOR (every node cache-eligible) and the + /// win grows visible as the churn fraction varies. still + /// allocates a fresh array each tick, so a full render (and layout pass) runs + /// regardless. + /// + /// The layout-churn budget actually applied (for logging parity). + public int Update(double percent) + { + var rng = _rng; + int n = _leaves.Length; + int k = (int)Math.Round(n * percent / 100.0, MidpointRounding.AwayFromZero); + if (k < 0) k = 0; + if (k > n) k = n; + + // Re-roll k distinct-ish leaves. We don't require strict distinctness — a + // repeated index just re-rolls the same leaf twice, which is harmless and keeps + // the per-tick work O(k) with no allocation. + for (int i = 0; i < k; i++) + { + int idx = rng.Next(n); + var leaf = _leaves[idx]; + _leaves[idx] = leaf with + { + Grow = rng.Next(0, 3), // 0..2 + Basis = 40 + rng.Next(0, 80), // 40..119 + Width = 40 + rng.Next(0, 80), // 40..119 + }; + } + + return k; + } + + /// + /// Immutable snapshot of the current leaf inputs. The harness rebuilds its full + /// nested flex child tree from this each render (no positional memo fast-path), so a + /// real Yoga layout pass runs every tick. + /// + public FlexLeaf[] Snapshot() => (FlexLeaf[])_leaves.Clone(); + + private FlexLeaf MakeLeaf(int id) + { + // Deterministic, content-stable label derived from identity, so a leaf's text + // never changes — isolating the LAYOUT-engine signal from per-cell text updates. + string label = string.Create(CultureInfo.InvariantCulture, $"L{id} · {id % 97:000}"); + // Deterministic initial flex inputs from the seeded RNG so main and PR start from + // the identical scene before any mutation. + double grow = _rng.Next(0, 3); + double basis = 40 + _rng.Next(0, 80); + double width = 40 + _rng.Next(0, 80); + return new FlexLeaf(id, label, grow, basis, width); + } +} diff --git a/tests/stress_perf/StressPerf.Flex/Program.cs b/tests/stress_perf/StressPerf.Flex/Program.cs new file mode 100644 index 00000000..a04116ce --- /dev/null +++ b/tests/stress_perf/StressPerf.Flex/Program.cs @@ -0,0 +1,264 @@ +// StressPerf.Flex — a /perf macro workload that exercises Reactor's FlexPanel / Yoga +// LAYOUT engine (the Flex/ + Yoga/ subsystems #670 optimizes) that NO existing /perf +// workload reaches. +// +// The StocksGrid workload (StressPerf.ReactorOptimized) mutates a fixed positional +// Grid in place; the keyed-list workload (StressPerf.KeyedList) reorders keyed rows. +// Neither drives a real Yoga measure/layout pass each frame, so #670's layout-cache +// guards (#138), inline per-node arrays (#142/#143), attached-DP push caching (#147) +// and per-frame list/line pooling (#141/#144) cannot be measured. The existing +// FlexPanel-heavy StressPerf.VirtualList is vsync-capped AND virtualized, which is +// exactly why it can't surface them either. +// +// This harness renders a DEEP NESTED, fully-realized (non-virtualized) flex tree +// (sections → rows → leaf cells, ~2000 leaves) and, every tick, re-rolls the flex +// inputs (grow / basis / width) on a `--percent` fraction of the leaves — forcing a +// real Yoga relayout each frame. The remaining leaves re-push their UNCHANGED inputs, +// which is precisely the YogaNode setter-equality-guard (cache-hit) path #670 targets. +// The win shows up as lower per-frame ALLOCATION + Gen0 on the deep tree and lower +// inline-per-node MEMORY — captured by the shared PerfTracker. +// +// MEASUREMENT CAVEAT (for maintainers + whoever measures #670 against this leg): the +// PerfTracker phase hook (OnRenderComplete, wired below) fires at the END of Reactor's +// render/reconcile, which is BEFORE WinUI runs layout. The real Yoga Measure/Arrange +// work (#138 cache guards, #141/#144 list/line pooling, the layout-side of #147) runs +// LATER, in FlexPanel.MeasureOverride/ArrangeOverride — so it is NOT reflected in the +// `avgReconcileMs` / `avgDiffMs` numbers. It IS captured by `allocBytesPerRender` / +// `gen0` (PerfTracker reads process-wide GC counters across the whole run, layout pass +// included) and largely by `rendersPerSec`. So judge layout-engine wins on the +// allocation table + Renders/sec, NOT the reconcile/diff ms rows; `avgMemoryMB` is too +// coarse to resolve the inline-per-node-array gain at this node count. A post-layout +// timing hook is deliberately NOT added here — that would touch the shared PerfTracker +// (used by the other legs); add it as a follow-up only if #670's /perf actually needs a +// layout-time signal (measure-then-escalate). +// +// The harness contract is mirrored byte-for-byte from StressPerf.KeyedList: the same +// CLI flags (--headless / --percent / --duration / --json via the shared CliOptions), +// the same shutdown emission (report.txt + metrics.json + the REACTOR_PERF_JSON stdout +// line) via the shared PerfTracker, and the same OnRenderComplete phase-capture wiring. +// Only the SCENE and its per-tick mutation differ, so Run-PerfBenchmark.ps1 / +// PerfLib.ps1 can drive it identically. + +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Hooks; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using StressPerf.Flex; +using StressPerf.Shared; +using static Microsoft.UI.Reactor.Factories; + +// Parse CLI args before WinUI starts +var cliOptions = CliOptions.Parse(args); +if (cliOptions.Headless) + ConsoleHelper.EnsureConsole(); + +FlexApp.CliOpts = cliOptions; + +// Children are built via the direct record initializer (`new TextBlockElement(...)`) +// to avoid factory overhead in the hot path, which bypasses the factory's lazy +// handler registration. Opt into the full built-in catalog once at startup so every +// built-in element record has a registered handler before the first reconcile — +// the documented one-line prelude for the direct-record idiom (spec-048 §3.4), +// identical to StressPerf.KeyedList. +ReactorApp.RegisterAllBuiltIns(); + +ReactorApp.Run("StressPerf.Flex", fullScreen: true); + +// --------------------------------------------------------------------------- + +class FlexApp : Component +{ + private const string AppName = "StressPerf.Flex"; + + public static CliOptions CliOpts { get; set; } = new(); + + public override Element Render() + { + var sourceRef = UseRef(null); + if (sourceRef.Current == null) + sourceRef.Current = new FlexSceneSource(); + var source = sourceRef.Current; + + // The full leaf snapshot drives a complete child-tree rebuild every render + // (deliberately NO positional memo fast-path), so a real Yoga layout pass runs + // each tick. + var (data, setData) = UseState(source.Snapshot()); + + var (percent, setPercent) = UseState(CliOpts.Percent); + var (running, setRunning) = UseState(false); + var (fps, setFps) = UseState("FPS: --"); + var (updateMs, setUpdateMs) = UseState("Update: -- ms"); + var (mem, setMem) = UseState("Mem: -- MB"); + + var perfRef = UseRef(null); + var timerRef = UseRef(null); + var shutdownRef = UseRef(null); + var benchmarkUpdatePending = UseRef(false); + var shapeVerifiedRef = UseRef(false); + + if (perfRef.Current == null) + { + perfRef.Current = new PerfTracker(); + var perf = perfRef.Current; + var pending = benchmarkUpdatePending; + ReactorApp.PrimaryWindow!.Host.OnRenderComplete = (treeMs, reconcileMs, effectsMs) => + { + if (pending.Current) + { + pending.Current = false; + perf.RecordPhases(treeMs, reconcileMs, effectsMs); + } + }; + } + + var renderHooked = UseRef(false); + if (!renderHooked.Current) + { + renderHooked.Current = true; + var perf = perfRef.Current; + CompositionTarget.Rendering += (_, _) => perf.FrameRendered(); + } + + UseEffect(() => + { + if (running) + { + var src = sourceRef.Current!; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) }; + timer.Tick += (_, _) => + { + var perf = perfRef.Current!; + perf.BeginUpdate(); + + src.Update(percent); + benchmarkUpdatePending.Current = true; + setData(src.Snapshot()); + + perf.EndUpdate(); + + setFps($"FPS: {perf.CurrentFps:F0}"); + setUpdateMs($"Update: {perf.LastUpdateMs:F1} ms"); + setMem($"Mem: {perf.CurrentMemoryMB} MB"); + }; + timer.Start(); + timerRef.Current = timer; + } + else + { + timerRef.Current?.Stop(); + timerRef.Current = null; + } + + return () => + { + timerRef.Current?.Stop(); + timerRef.Current = null; + }; + }, running, percent); + + UseEffect(() => + { + if (!CliOpts.Headless) return; + setPercent(CliOpts.Percent); + setRunning(true); + + var shutdownTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(CliOpts.DurationSeconds) }; + shutdownTimer.Tick += (_, _) => + { + setRunning(false); + shutdownTimer.Stop(); + var perf = perfRef.Current!; + perf.WriteReportFile(AppName, CliOpts.Percent); + if (CliOpts.Json) + { + perf.WriteMetricsJsonFile(AppName, CliOpts.Percent); + // Echo a single marked line so log scrapers have a fallback to the + // {AppName}.metrics.json file written next to the exe. + Console.WriteLine("REACTOR_PERF_JSON " + perf.GetMetricsJson(AppName, CliOpts.Percent)); + } + Application.Current.Exit(); + }; + shutdownTimer.Start(); + shutdownRef.Current = shutdownTimer; + }, Array.Empty()); + + // ── Build the deep nested flex tree ───────────────────────────────── + // root FlexColumn → S section FlexColumns → R FlexRows → C leaf cells. Each leaf + // pushes its per-child flex inputs (grow / basis via .Flex(...), width via + // .Width(...)) onto its Yoga node; the (1 - percent) of leaves that did NOT + // change this tick re-push identical values — the YogaNode setter-equality-guard + // (cache-hit) path #670 optimizes. The tree SHAPE is fixed, so the child + // reconciler stays on its cheap positional arm and the measured cost is the + // layout-engine work, not child diffing. + int sections = source.Sections, rows = source.Rows, cols = source.Cols; + var sectionEls = new Element[sections]; + Element? sampleLeaf = null; + int li = 0; + for (int s = 0; s < sections; s++) + { + var rowEls = new Element[rows]; + for (int r = 0; r < rows; r++) + { + var cellEls = new Element[cols]; + for (int c = 0; c < cols; c++) + { + var leaf = data[li++]; + cellEls[c] = new TextBlockElement(leaf.Label) { FontSize = 10 } + .Flex(grow: leaf.Grow, basis: leaf.Basis) + .Width(leaf.Width); + } + rowEls[r] = FlexRow(cellEls); + sampleLeaf ??= cellEls[0]; + } + sectionEls[s] = FlexColumn(rowEls).Flex(grow: 1); + } + // Typed as Element so the structural self-check below is a real runtime assertion + // (not a compile-time always-true comparison the analyzer would flag). + Element flexRoot = FlexColumn(sectionEls); + + // Structural self-check (runs once): the representative containers MUST be + // FlexElement-backed AND a representative leaf MUST survive the fluent + // .Flex(...).Width(...) chain as a concrete TextBlockElement. The FlexElement check + // guards against a refactor silently swapping the scene onto Grid/StackPanel (which + // never run the Yoga layout engine). The leaf-type check guards against a modifier + // regression that degrades the leaf to a bare Element and drops its grow/basis/width + // inputs — without those the reconciler pushes nothing onto the Yoga nodes, so a + // mutation would be a no-op (no relayout). Either failure means the workload is NOT + // exercising the Flex/Yoga layout engine, so fail loudly (no metrics.json is written) + // rather than report misleading layout numbers. + if (!shapeVerifiedRef.Current) + { + shapeVerifiedRef.Current = true; + bool rootIsFlex = flexRoot is FlexElement; + bool sectionIsFlex = sectionEls.Length > 0 && sectionEls[0] is FlexElement; + bool leafIsTextBlock = sampleLeaf is TextBlockElement; + if (!rootIsFlex || !sectionIsFlex || !leafIsTextBlock) + { + Console.Error.WriteLine( + $"FATAL: {AppName} scene is not Flex/Yoga-backed (root={rootIsFlex} " + + $"section={sectionIsFlex} leafTextBlock={leafIsTextBlock}) — the layout " + + "engine under measurement would not run (mutations would be no-ops); " + + "results are invalid."); + Environment.FailFast( + $"{AppName}: Flex/Yoga-layout invariant violated (scene not FlexElement-backed " + + "or leaf degraded through the flex modifier chain)."); + } + } + + return VStack( + HStack(12, + Button(running ? "Stop" : "Start", () => setRunning(!running)), + TextBlock("Churn %:").VAlign(Microsoft.UI.Xaml.VerticalAlignment.Center), + Slider(percent, 0, 100, v => setPercent(v)).Width(200), + TextBlock(fps).VAlign(Microsoft.UI.Xaml.VerticalAlignment.Center).Width(100), + TextBlock(updateMs).VAlign(Microsoft.UI.Xaml.VerticalAlignment.Center).Width(120), + TextBlock(mem).VAlign(Microsoft.UI.Xaml.VerticalAlignment.Center).Width(120) + ).Padding(8), + ScrollView( + flexRoot + ) + ); + } +} diff --git a/tests/stress_perf/StressPerf.Flex/StressPerf.Flex.csproj b/tests/stress_perf/StressPerf.Flex/StressPerf.Flex.csproj new file mode 100644 index 00000000..8b55578c --- /dev/null +++ b/tests/stress_perf/StressPerf.Flex/StressPerf.Flex.csproj @@ -0,0 +1,41 @@ + + + WinExe + net10.0-windows10.0.22621.0 + x64;ARM64 + StressPerf.Flex + StressPerf.Flex + enable + enable + true + None + true + + + + + true + win-arm64 + win-x64 + + + + + + + + + + + diff --git a/tests/stress_perf/ci/PerfLib.Tests.ps1 b/tests/stress_perf/ci/PerfLib.Tests.ps1 index b271c0d7..da236557 100644 --- a/tests/stress_perf/ci/PerfLib.Tests.ps1 +++ b/tests/stress_perf/ci/PerfLib.Tests.ps1 @@ -546,6 +546,91 @@ Assert-Match $bothAllocComment 'Allocation (Reactor)' 'full comment keeps the Assert-Match $bothAllocComment 'Allocation (keyed-list)' 'full comment adds the distinct keyed allocation sub-table' +# ── Format-PerfFlexSection + Format-PerfComment: flex/Yoga layout workload ───── +# Same direction-aware, by-significance shape as the keyed block above: rps/reconcile/ +# diff move DOWN main->PR while memory carries a small SYMMETRIC per-pair jitter (mean +# Δ ~0). The verdicts must split exactly as for the keyed leg — proving the flex section +# reuses the shared direction-aware paired-CI machinery rather than a hard-coded verdict. +$flexMainRuns = @(); $flexPrRuns = @() +1..12 | ForEach-Object { + $j = ($_ % 4) * 0.05 + $mj = ((($_ % 2) * 2) - 1) * 0.2 # alternating +0.2 / -0.2 so the paired memory Δ straddles 0 + $flexMainRuns += [pscustomobject]@{ RendersPerSec = 8.0 + $j; AvgReconcileMs = 9.0 + $j; AvgDiffMs = 7.0 + $j; AvgMemoryMB = 250 + $mj; TotalRenders = 80; DurationSeconds = 10 } + $flexPrRuns += [pscustomobject]@{ RendersPerSec = 7.0 + $j; AvgReconcileMs = 7.0 + $j; AvgDiffMs = 5.0 + $j; AvgMemoryMB = 250 - $mj; TotalRenders = 70; DurationSeconds = 10 } +} +$flexMain = Measure-PerfRuns -Runs $flexMainRuns +$flexPr = Measure-PerfRuns -Runs $flexPrRuns + +# Direct section renderer: empty when either side is null, populated when both present. +Assert-Equal 0 @(Format-PerfFlexSection -MainFlex $null -PrFlex $flexPr -Percent 50).Count 'flex section empty when main flex null' +Assert-Equal 0 @(Format-PerfFlexSection -MainFlex $flexMain -PrFlex $null -Percent 50).Count 'flex section empty when pr flex null' +$flexSection = Format-PerfFlexSection -MainFlex $flexMain -PrFlex $flexPr -Percent 50 +$flexSectionText = $flexSection -join "`n" +Assert-Match $flexSectionText 'Flex/Yoga layout workload' 'flex section has heading' +Assert-Match $flexSectionText 'StressPerf.Flex' 'flex heading names the workload' +Assert-Match $flexSectionText 'Avg Reconcile' 'flex section has reconcile row' +Assert-Match $flexSectionText 'Yoga' 'flex preamble names the Yoga layout engine' +Assert-Match $flexSectionText 'layout pass' 'flex preamble cites the per-frame layout pass' +# Direction-awareness: rps and reconcile both DECREASE main->PR, yet rps (higher-is- +# better) must read regression while reconcile (lower-is-better) reads improvement. +$flexRpsRow = ($flexSection | Where-Object { $_ -match 'Renders/sec' }) -join ' ' +$flexReconRow = ($flexSection | Where-Object { $_ -match 'Avg Reconcile' }) -join ' ' +$flexDiffRow = ($flexSection | Where-Object { $_ -match 'Avg Diff' }) -join ' ' +$flexMemRow = ($flexSection | Where-Object { $_ -match 'Avg Memory' }) -join ' ' +Assert-Match $flexRpsRow 'regression' 'flex: rps DOWN reads regression (higher-is-better honored)' +Assert-Match $flexReconRow 'improvement' 'flex: reconcile DOWN reads improvement (lower-is-better honored)' +Assert-Match $flexDiffRow 'improvement' 'flex: diff DOWN reads improvement (lower-is-better honored)' +Assert-Match $flexMemRow 'within noise' 'flex: symmetric memory Δ reads within noise (paired CI straddles 0)' +# -Percent threads into the heading independently of the methodology line. +$flexSection75 = (Format-PerfFlexSection -MainFlex $flexMain -PrFlex $flexPr -Percent 75) -join "`n" +Assert-Match $flexSection75 'Flex/Yoga layout workload*--percent 75' 'flex heading reflects the -Percent argument' + +# Threaded through Format-PerfComment: present when flex aggregates present, sitting +# after the keyed-list table and before the cross-framework table. +$flexComment = Format-PerfComment -Main $main -Pr $pr -WinUI3 $null -Rust $null -MainFloor $floorMain -PrFloor $floorPr -MainKeyed $keyedMain -PrKeyed $keyedPr -MainFlex $flexMain -PrFlex $flexPr -Context $ctx +Assert-Match $flexComment 'Flex/Yoga layout workload' 'comment renders flex table when flex aggregates present' +$idxKeyedF = $flexComment.IndexOf('Keyed-list workload') +$idxFlexF = $flexComment.IndexOf('Flex/Yoga layout workload') +$idxXfwF = $flexComment.IndexOf('Cross-framework reference') +Assert-True (($idxKeyedF -lt $idxFlexF) -and ($idxFlexF -lt $idxXfwF)) 'flex table sits after the keyed-list table and before cross-framework' + +# Omitted entirely when flex aggregates are absent (flex leg disabled / build omitted). +$noFlexComment = Format-PerfComment -Main $main -Pr $pr -WinUI3 $null -Rust $null -MainFlex $null -PrFlex $null -Context $ctx +Assert-True (-not ($noFlexComment -like '*Flex/Yoga layout workload*')) 'flex table omitted when flex aggregates null' + +# Allocation sub-table for the flex leg: shared PerfAllocMetricSpec over flex aggregates. +# Alloc moves DOWN main->PR (an improvement on a lower-is-better metric); tiny jitter +# keeps each paired CI off 0. +$flexAllocMain = Measure-PerfRuns -Runs @( + [pscustomobject]@{ RendersPerSec = 18.5; AvgReconcileMs = 6.98; AvgDiffMs = 6.86; AvgMemoryMB = 186; AllocBytesPerRender = 512000; Gen0PerKRenders = 98.2; Gen0 = 9; Gen1 = 3; Gen2 = 1; TotalRenders = 96; DurationSeconds = 5 } + [pscustomobject]@{ RendersPerSec = 18.6; AvgReconcileMs = 6.98; AvgDiffMs = 6.86; AvgMemoryMB = 186; AllocBytesPerRender = 512200; Gen0PerKRenders = 98.4; Gen0 = 9; Gen1 = 3; Gen2 = 1; TotalRenders = 96; DurationSeconds = 5 } + [pscustomobject]@{ RendersPerSec = 18.4; AvgReconcileMs = 6.98; AvgDiffMs = 6.86; AvgMemoryMB = 186; AllocBytesPerRender = 511800; Gen0PerKRenders = 98.0; Gen0 = 9; Gen1 = 3; Gen2 = 1; TotalRenders = 96; DurationSeconds = 5 } +) +$flexAllocPr = Measure-PerfRuns -Runs @( + [pscustomobject]@{ RendersPerSec = 18.5; AvgReconcileMs = 6.98; AvgDiffMs = 6.86; AvgMemoryMB = 186; AllocBytesPerRender = 384000; Gen0PerKRenders = 74.2; Gen0 = 7; Gen1 = 3; Gen2 = 1; TotalRenders = 96; DurationSeconds = 5 } + [pscustomobject]@{ RendersPerSec = 18.6; AvgReconcileMs = 6.98; AvgDiffMs = 6.86; AvgMemoryMB = 186; AllocBytesPerRender = 384200; Gen0PerKRenders = 74.4; Gen0 = 7; Gen1 = 3; Gen2 = 1; TotalRenders = 96; DurationSeconds = 5 } + [pscustomobject]@{ RendersPerSec = 18.4; AvgReconcileMs = 6.98; AvgDiffMs = 6.86; AvgMemoryMB = 186; AllocBytesPerRender = 383800; Gen0PerKRenders = 74.0; Gen0 = 7; Gen1 = 3; Gen2 = 1; TotalRenders = 96; DurationSeconds = 5 } +) +$flexAllocSection = Format-PerfFlexSection -MainFlex $flexAllocMain -PrFlex $flexAllocPr -Percent 50 +$flexAllocText = $flexAllocSection -join "`n" +Assert-Match $flexAllocText 'Allocation (flex)' 'flex section renders the allocation sub-table when alloc present' +Assert-Match $flexAllocText 'Alloc bytes/render' 'flex alloc sub-table has bytes/render row' +Assert-Match $flexAllocText 'Gen0 GC / 1k renders' 'flex alloc sub-table has Gen0 row' +$flexAllocRow = ($flexAllocSection | Where-Object { $_ -match 'Alloc bytes/render' }) -join ' ' +Assert-Match $flexAllocRow 'improvement' 'flex alloc DOWN main->PR reads improvement (lower-is-better honored)' +$idxFlexHead = $flexAllocText.IndexOf('Avg Reconcile') +$idxFlexAlloc = $flexAllocText.IndexOf('Allocation (flex)') +Assert-True (($idxFlexHead -ge 0) -and ($idxFlexHead -lt $idxFlexAlloc)) 'flex alloc sub-table follows the flex headline metrics table' +# Omitted when the flex aggregates carry no alloc metrics (legacy flex head). +Assert-True (-not ($flexSectionText -like '*Allocation (flex)*')) 'flex alloc sub-table omitted when flex aggregates lack alloc' + +# In a full comment the positional StocksGrid allocation table and the flex allocation +# sub-table are DISTINCT, separately-labelled tables (positional vs flex-layout allocs). +$bothFlexAllocComment = Format-PerfComment -Main $allocMain -Pr $allocPr -WinUI3 $null -Rust $null -MainFlex $flexAllocMain -PrFlex $flexAllocPr -Context $ctx +Assert-Match $bothFlexAllocComment 'Allocation (Reactor)' 'full comment keeps the StocksGrid allocation table' +Assert-Match $bothFlexAllocComment 'Allocation (flex)' 'full comment adds the distinct flex allocation sub-table' + + # ── Reconciler micro-suite: Read-MicroBenchResults / comparison / render ────── function New-MicroRow { param([string]$BenchId, [string]$Name, [string]$Variant, [int]$Rep, [double]$MeanNs, [double]$AllocBytes, [string]$Status = 'ok', [int]$Iterations = 1) diff --git a/tests/stress_perf/ci/PerfLib.ps1 b/tests/stress_perf/ci/PerfLib.ps1 index 2c8c9c72..fcf01f4a 100644 --- a/tests/stress_perf/ci/PerfLib.ps1 +++ b/tests/stress_perf/ci/PerfLib.ps1 @@ -953,6 +953,103 @@ function Format-PerfKeyedListSection { return $lines.ToArray() } +function Format-PerfFlexSection { + <# + .SYNOPSIS + Render the flex workload section: the four headline metrics plus an + allocation sub-table measured on StressPerf.Flex — a deep nested, fully-realized + (non-virtualized) flex tree (~2000 leaf cells) whose per-child flex inputs + (grow / basis / width) are re-rolled on a fraction of the leaves each tick. Empty + array when there is nothing to show. + .DESCRIPTION + Unlike the positional StocksGrid headline/skip-floor legs (cells mutated in place + by index, child reconcile only) and the keyed-list leg (reordered keyed rows, + child reconcile only), this is a SEPARATE macro workload that drives the + FlexPanel / Yoga LAYOUT engine: re-rolling grow/basis/width re-dirties the Yoga + nodes and forces a real measure/layout pass every frame, while the unchanged + leaves re-push identical inputs (the YogaNode setter-equality-guard / layout + cache-hit path). It is the sensitive macro measure for Yoga/Flex layout-engine + allocation + memory work (layout-cache guards, inline per-node arrays, attached-DP + push caching, per-frame list/line pooling) that the positional StocksGrid and the + keyed-list legs can never exercise. Reuses the same paired-Δ 95% CI machinery + (Get-PerfDelta over the index-aligned per-run samples) as the headline table. Also + appends an **allocation** sub-table — the shared PerfAllocMetricSpec (alloc + bytes/render + Gen0 GC / 1k renders) over the flex aggregates — the sensitive + macro signal for LAYOUT-engine allocation reductions that the positional StocksGrid + allocation table can never isolate; rendered only when the flex leg reported + allocation metrics. Returns an empty array when either aggregate is $null (flex leg + disabled, build omitted, or one side produced no metrics), so the caller renders + nothing. + .PARAMETER MainFlex Aggregated baseline flex metrics (Measure-PerfRuns), or $null. + .PARAMETER PrFlex Aggregated PR-head flex metrics, or $null. + .PARAMETER Percent The churn percent the flex leg ran at (heading / preamble). + #> + param( + [AllowNull()][pscustomobject]$MainFlex, + [AllowNull()][pscustomobject]$PrFlex, + [double]$Percent = 50 + ) + if ($null -eq $MainFlex -or $null -eq $PrFlex) { return @() } + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add("### Flex/Yoga layout workload (``StressPerf.Flex``, ``--percent $Percent``)") + $lines.Add('') + $lines.Add("A separate macro workload: a **deep nested, fully-realized** (non-virtualized) flex tree (~2000 leaf cells) whose per-child flex inputs (grow / basis / width) are re-rolled on a ``--percent`` fraction of the leaves each tick, forcing a real **Yoga measure/layout pass** every frame while the unchanged leaves re-push identical inputs (the YogaNode setter-equality-guard / layout-cache-hit path). This drives the **FlexPanel / Yoga LAYOUT engine** — the sensitive macro signal for Yoga/Flex layout-engine **allocation + memory** work the positional StocksGrid and keyed-list legs can never reach. Same interleaved paired-Δ 95% CI as the headline table.") + $lines.Add('') + $lines.Add("> **Reading this table:** the **reconcile / diff timing** rows do **not** capture the deferred Yoga Measure/Arrange pass — it runs *after* the harness' ``OnRenderComplete`` phase hook, in ``FlexPanel.MeasureOverride``/``ArrangeOverride``. Judge layout-engine wins primarily on the **flex allocation table** below (process-wide GC counters capture the whole run, layout pass included) and the **renders-per-second** figure. The working-set memory figure is informational only — too coarse to resolve inline-per-node-array gains at this node count.") + $lines.Add('') + $lines.Add('| Metric | `main` (baseline) | This PR | Δ (95% CI) | Status |') + $lines.Add('|---|--:|--:|--:|:--|') + foreach ($m in $script:PerfMetricSpec) { + $bVal = $MainFlex.($m.Key) + $pVal = $PrFlex.($m.Key) + $spread = [math]::Max([double]$MainFlex."$($m.Key)Spread", [double]$PrFlex."$($m.Key)Spread") + $delta = Get-PerfDelta -Baseline $bVal -Candidate $pVal -LowerIsBetter $m.LowerIsBetter -SpreadPct $spread ` + -BaselineSamples $MainFlex."$($m.Key)Samples" -CandidateSamples $PrFlex."$($m.Key)Samples" + $lines.Add(('| {0} {1} | {2} | {3} | {4} | {5} |' -f ` + $m.Label, $m.Arrow, ` + (Format-PerfNumber $bVal $m.Digits), ` + (Format-PerfNumber $pVal $m.Digits), ` + (Format-PerfDeltaCell $delta), ` + (Get-PerfStatusGlyph $delta.Status))) + } + $lines.Add('') + + # Allocation sub-table for the flex workload: the shared PerfAllocMetricSpec + # (Alloc bytes/render, Gen0 GC / 1k renders) rendered over the flex aggregates with + # the identical paired-Δ 95% CI machinery used above. This is the sensitive MACRO + # signal for LAYOUT-engine allocation reductions — allocBytesPerRender tracks the + # per-frame layout-pass alloc volume (rented child/line lists, attached-DP pushes) on + # the Yoga path, an alloc signal the positional StocksGrid allocation table can never + # isolate. Rendered only when the flex leg reported allocation metrics (every + # StressPerf.Flex build does; n/a only for a legacy head opened before the metric + # landed). + $hasFlexAlloc = ($null -ne $MainFlex.AllocBytesPerRender) -or ($null -ne $PrFlex.AllocBytesPerRender) -or + ($null -ne $MainFlex.Gen0PerKRenders) -or ($null -ne $PrFlex.Gen0PerKRenders) + if ($hasFlexAlloc) { + $lines.Add('**Allocation (flex)** — lower is better') + $lines.Add('') + $lines.Add('| Metric | `main` (baseline) | This PR | Δ (95% CI) | Status |') + $lines.Add('|---|--:|--:|--:|:--|') + foreach ($m in $script:PerfAllocMetricSpec) { + $bVal = $MainFlex.($m.Key) + $pVal = $PrFlex.($m.Key) + $spread = [math]::Max([double]$MainFlex."$($m.Key)Spread", [double]$PrFlex."$($m.Key)Spread") + $delta = Get-PerfDelta -Baseline $bVal -Candidate $pVal -LowerIsBetter $m.LowerIsBetter -SpreadPct $spread ` + -BaselineSamples $MainFlex."$($m.Key)Samples" -CandidateSamples $PrFlex."$($m.Key)Samples" + $lines.Add(('| {0} {1} | {2} | {3} | {4} | {5} |' -f ` + $m.Label, $m.Arrow, ` + (Format-PerfNumber $bVal $m.Digits), ` + (Format-PerfNumber $pVal $m.Digits), ` + (Format-PerfDeltaCell $delta), ` + (Get-PerfStatusGlyph $delta.Status))) + } + $lines.Add('') + } + + return $lines.ToArray() +} + function Format-PerfComment { <# .SYNOPSIS @@ -973,6 +1070,8 @@ function Format-PerfComment { .PARAMETER PrFloor Aggregated PR-head low-mutation skip-floor metrics, or $null. .PARAMETER MainKeyed Aggregated baseline keyed-list workload metrics, or $null. .PARAMETER PrKeyed Aggregated PR-head keyed-list workload metrics, or $null. + .PARAMETER MainFlex Aggregated baseline flex workload metrics, or $null. + .PARAMETER PrFlex Aggregated PR-head flex workload metrics, or $null. .PARAMETER Context Hashtable: Percent, Duration, Reps, Warmup, SkipFloorPercent, BaseSha, HeadSha, Runner, Cpu, Cores, MemoryGB, RunUrl, Timestamp, Note. @@ -988,6 +1087,8 @@ function Format-PerfComment { [AllowNull()][pscustomobject]$PrFloor, [AllowNull()][pscustomobject]$MainKeyed, [AllowNull()][pscustomobject]$PrKeyed, + [AllowNull()][pscustomobject]$MainFlex, + [AllowNull()][pscustomobject]$PrFlex, [Parameter(Mandatory)][hashtable]$Context ) @@ -1070,6 +1171,15 @@ function Format-PerfComment { $keyedPct = if ($Context.ContainsKey('Percent')) { [double]$Context.Percent } else { 50 } foreach ($kline in (Format-PerfKeyedListSection -MainKeyed $MainKeyed -PrKeyed $PrKeyed -Percent $keyedPct)) { & $add $kline } + # ── Flex/Yoga layout workload table (StressPerf.Flex) ──────────────────── + # A separate macro workload driving the FlexPanel / Yoga LAYOUT engine — a deep + # nested, fully-realized flex tree re-laid-out each tick — that neither the positional + # StocksGrid legs nor the keyed-list leg (both child-reconcile only) reach. The + # sensitive macro signal for Yoga/Flex layout-engine alloc + memory optimizations. + # Rendered only when both flex aggregates are present. + $flexPct = if ($Context.ContainsKey('Percent')) { [double]$Context.Percent } else { 50 } + foreach ($fline in (Format-PerfFlexSection -MainFlex $MainFlex -PrFlex $PrFlex -Percent $flexPct)) { & $add $fline } + # ── Reconciler micro-benchmarks (ns-resolution, WinUI-undiluted) ────────── # Rendered only when the PerfBench.ControlModel micro leg produced results for # both sides. Resolves Core/Reconciler time + allocation deltas the macro diff --git a/tests/stress_perf/ci/Run-PerfBenchmark.ps1 b/tests/stress_perf/ci/Run-PerfBenchmark.ps1 index 8166f8b7..1c1c29c2 100644 --- a/tests/stress_perf/ci/Run-PerfBenchmark.ps1 +++ b/tests/stress_perf/ci/Run-PerfBenchmark.ps1 @@ -120,11 +120,23 @@ build is best-effort (a KeyedList build failure just omits the table). Disable with -IncludeKeyedList:$false to skip the extra leg. +.PARAMETER IncludeFlex + Run a fourth interleaved A/B leg on StressPerf.Flex — a deep nested, fully-realized + (non-virtualized) flex tree (~2000 leaf cells) whose per-child flex inputs + (grow / basis / width) are re-rolled on a `--percent` fraction of the leaves each + tick, forcing a real Yoga measure/layout pass every frame — and append its own + PR-vs-main table to the comment (compare mode). This exercises the FlexPanel / Yoga + LAYOUT engine (the Flex/ + Yoga/ subsystems) that the positional StocksGrid and the + keyed-list legs can never reach, so it is the sensitive macro measure for Yoga/Flex + layout-engine allocation + memory optimizations. Default $true; build is best-effort + (a Flex build failure just omits the table). Disable with -IncludeFlex:$false to skip + the extra leg. + .PARAMETER Apps - Which harnesses to run in single-tree mode: ReactorOptimized, Direct, KeyedList. - Ignored in compare mode (which always does ReactorOptimized both sides + - Direct once for the WinUI3 column, and — unless -IncludeKeyedList:$false — - KeyedList both sides). + Which harnesses to run in single-tree mode: ReactorOptimized, Direct, KeyedList, + Flex. Ignored in compare mode (which always does ReactorOptimized both sides + + Direct once for the WinUI3 column, and — unless -IncludeKeyedList:$false / + -IncludeFlex:$false — KeyedList and Flex both sides). .PARAMETER OutDir Where logs, comment.md and result.json land. Defaults to ci\out next to this @@ -198,7 +210,8 @@ param( [double]$SkipFloorPercent = 0, [bool]$IncludeSkipFloor = $true, [bool]$IncludeKeyedList = $true, - [ValidateSet('ReactorOptimized', 'Direct', 'KeyedList')] + [bool]$IncludeFlex = $true, + [ValidateSet('ReactorOptimized', 'Direct', 'KeyedList', 'Flex')] [string[]]$Apps = @('ReactorOptimized', 'Direct'), [string]$OutDir, [switch]$SkipBuild, @@ -231,6 +244,7 @@ $AppRegistry = @{ ReactorOptimized = @{ AppName = 'StressPerf.ReactorOptimized'; ProjectRel = 'tests\stress_perf\StressPerf.ReactorOptimized\StressPerf.ReactorOptimized.csproj' } Direct = @{ AppName = 'StressPerf.Direct'; ProjectRel = 'tests\stress_perf\StressPerf.Direct\StressPerf.Direct.csproj' } KeyedList = @{ AppName = 'StressPerf.KeyedList'; ProjectRel = 'tests\stress_perf\StressPerf.KeyedList\StressPerf.KeyedList.csproj' } + Flex = @{ AppName = 'StressPerf.Flex'; ProjectRel = 'tests\stress_perf\StressPerf.Flex\StressPerf.Flex.csproj' } MicroControlModel = @{ AppName = 'PerfBench.ControlModel'; ProjectRel = 'tests\perf_bench\PerfBench.ControlModel\PerfBench.ControlModel.csproj' } } @@ -843,9 +857,10 @@ Write-Log ("runner: {0} | {1} cores | {2} GB | {3}" -f $runner.Cpu, $runner.Core $modeSuffix = if ($Compare) { # COMPARE mode runs the interleaved A/B legs, so the skip-floor / keyed-list # opt-out switches are what actually decide which legs run. - "skip-floor={0} | keyed-list={1}" -f ` + "skip-floor={0} | keyed-list={1} | flex={2}" -f ` $(if ($IncludeSkipFloor) { "on (--percent $SkipFloorPercent)" } else { 'off' }), ` - $(if ($IncludeKeyedList) { 'on' } else { 'off' }) + $(if ($IncludeKeyedList) { 'on' } else { 'off' }), ` + $(if ($IncludeFlex) { 'on' } else { 'off' }) } else { # LOCAL mode ignores the interleaved-leg switches entirely; the workload set is # whatever -Apps selects, so report that instead of a misleading on/off. @@ -860,6 +875,7 @@ try { $ro = $AppRegistry.ReactorOptimized $direct = $AppRegistry.Direct $keyed = $AppRegistry.KeyedList + $flex = $AppRegistry.Flex $microMeta = $AppRegistry.MicroControlModel if (-not $SkipBuild) { @@ -885,6 +901,15 @@ try { $IncludeKeyedList = $false } } + if ($IncludeFlex -and -not $SkipBuild) { + try { + Build-Harness -TreeRoot $BaselineRoot -AppMeta $flex + Build-Harness -TreeRoot $Root -AppMeta $flex + } catch { + Write-Log "flex workload build failed ($_) — omitting the flex table" 'Yellow' + $IncludeFlex = $false + } + } $mainExe = Resolve-HarnessExe -TreeRoot $BaselineRoot -AppMeta $ro $prExe = Resolve-HarnessExe -TreeRoot $Root -AppMeta $ro $directExe = Resolve-HarnessExe -TreeRoot $Root -AppMeta $direct @@ -952,6 +977,36 @@ try { } } + # Fourth interleaved A/B leg: the flex workload. StressPerf.Flex renders a deep + # nested, fully-realized (non-virtualized) flex tree (~2000 leaf cells) and + # re-rolls the per-child flex inputs (grow / basis / width) on a --percent + # fraction of the leaves each tick, forcing a real Yoga measure/layout pass every + # frame — the FlexPanel / Yoga LAYOUT engine that StocksGrid's positional cells + # and the keyed-list reorder leg never reach. Same paired interleaving + + # drop-both alignment as above. Best-effort: if either exe is missing + # (build omitted/failed) the leg is skipped and the flex table is omitted — the + # StocksGrid comparison is unaffected. + $mainFlexRuns = @(); $prFlexRuns = @() + if ($IncludeFlex) { + $mainFlexExe = Resolve-HarnessExe -TreeRoot $BaselineRoot -AppMeta $flex + $prFlexExe = Resolve-HarnessExe -TreeRoot $Root -AppMeta $flex + if (-not $mainFlexExe -or -not $prFlexExe) { + Write-Log "flex exe not found (main=$([bool]$mainFlexExe) pr=$([bool]$prFlexExe)) — omitting the flex table" 'Yellow' + } else { + Write-Log "interleaving main/PR flex (--percent $Percent; $($Warmup) warmup + $($Reps) measured each)" 'Green' + for ($i = 1; $i -le ($Warmup + $Reps); $i++) { + $mm = Invoke-OneRun -Exe $mainFlexExe -AppMeta $flex -Index $i -Tag 'main-flex' + $pm = Invoke-OneRun -Exe $prFlexExe -AppMeta $flex -Index $i -Tag 'pr-flex' + if ($i -le $Warmup) { Write-Log " (flex warmup pair #$i discarded)" 'DarkGray'; continue } + if ($mm -and $pm) { $mainFlexRuns += $mm; $prFlexRuns += $pm } + elseif ($mm -or $pm) { Write-Log " flex pair #$i incomplete (main=$([bool]$mm) pr=$([bool]$pm)) — dropped to keep the paired CI aligned" 'Yellow' } + } + if ($mainFlexRuns.Count -lt $Reps -or $prFlexRuns.Count -lt $Reps) { + Write-Log " flex leg short (main $($mainFlexRuns.Count)/$Reps, PR $($prFlexRuns.Count)/$Reps) — its paired CI uses fewer samples" 'Yellow' + } + } + } + $winRuns = @() if ($directExe) { Write-Log "vanilla WinUI3 (StressPerf.Direct)" 'Green' @@ -1024,6 +1079,8 @@ try { $prFloor = if ($prFloorRuns.Count) { Measure-PerfRuns -Runs $prFloorRuns } else { $null } $mainKeyed = if ($mainKeyedRuns.Count) { Measure-PerfRuns -Runs $mainKeyedRuns } else { $null } $prKeyed = if ($prKeyedRuns.Count) { Measure-PerfRuns -Runs $prKeyedRuns } else { $null } + $mainFlex = if ($mainFlexRuns.Count) { Measure-PerfRuns -Runs $mainFlexRuns } else { $null } + $prFlex = if ($prFlexRuns.Count) { Measure-PerfRuns -Runs $prFlexRuns } else { $null } $note = $null if ($prRuns.Count -eq 0 -or $mainRuns.Count -eq 0) { @@ -1047,17 +1104,18 @@ try { MainSamples = $mainRuns.Count; PrSamples = $prRuns.Count MainFloorSamples = $mainFloorRuns.Count; PrFloorSamples = $prFloorRuns.Count MainKeyedSamples = $mainKeyedRuns.Count; PrKeyedSamples = $prKeyedRuns.Count + MainFlexSamples = $mainFlexRuns.Count; PrFlexSamples = $prFlexRuns.Count BaseSha = $(if ($BaseSha) { $BaseSha.Substring(0, [Math]::Min(7, $BaseSha.Length)) } else { '' }) HeadSha = $(if ($HeadSha) { $HeadSha.Substring(0, [Math]::Min(7, $HeadSha.Length)) } else { '' }) Runner = $runner.Runner; Cpu = $runner.Cpu; Cores = $runner.Cores; MemoryGB = $runner.MemoryGB RunUrl = $RunUrl; Timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'); Note = $note } - $comment = Format-PerfComment -Main $main -Pr $pr -WinUI3 $winui3 -Rust $rust -Micro $micro -MicroOmitReason $microOmitReason -MainFloor $mainFloor -PrFloor $prFloor -MainKeyed $mainKeyed -PrKeyed $prKeyed -Context $ctx + $comment = Format-PerfComment -Main $main -Pr $pr -WinUI3 $winui3 -Rust $rust -Micro $micro -MicroOmitReason $microOmitReason -MainFloor $mainFloor -PrFloor $prFloor -MainKeyed $mainKeyed -PrKeyed $prKeyed -MainFlex $mainFlex -PrFlex $prFlex -Context $ctx $commentPath = Join-Path $OutDir 'comment.md' Set-Content -LiteralPath $commentPath -Value $comment -Encoding UTF8 Write-Log "comment.md written -> $commentPath" 'Green' - $result = [pscustomobject]@{ main = $main; pr = $pr; winui3 = $winui3; mainFloor = $mainFloor; prFloor = $prFloor; mainKeyed = $mainKeyed; prKeyed = $prKeyed; rust = $rust; micro = $micro; runner = $runner; context = $ctx } + $result = [pscustomobject]@{ main = $main; pr = $pr; winui3 = $winui3; mainFloor = $mainFloor; prFloor = $prFloor; mainKeyed = $mainKeyed; prKeyed = $prKeyed; mainFlex = $mainFlex; prFlex = $prFlex; rust = $rust; micro = $micro; runner = $runner; context = $ctx } $result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $OutDir 'result.json') -Encoding UTF8 Write-Host "`n----- comment.md -----" -ForegroundColor DarkGray diff --git a/tests/stress_perf/ci/RunPerfBenchmark.Tests.ps1 b/tests/stress_perf/ci/RunPerfBenchmark.Tests.ps1 index ab8f4235..a3ecb666 100644 --- a/tests/stress_perf/ci/RunPerfBenchmark.Tests.ps1 +++ b/tests/stress_perf/ci/RunPerfBenchmark.Tests.ps1 @@ -407,6 +407,38 @@ Assert-True ($src -match '\$mainKeyedRuns = @\(\); \$prKeyedRuns = @\(\)\s*\r?\n Assert-True ($src -match 'if \(\$mm -and \$pm\) \{ \$mainKeyedRuns \+= \$mm; \$prKeyedRuns \+= \$pm \}') '[keyed] a complete pair appends both main + pr samples' Assert-True ($src -match 'elseif \(\$mm -or \$pm\) \{ Write-Log " keyed pair #\$i incomplete') '[keyed] a one-sided keyed run drops both halves (paired CI stays aligned)' +# =========================================================================== +# Flex leg — static wiring contract (param + registry + leg + comment) +# =========================================================================== +# Mirrors the keyed-list leg contract above: a fourth interleaved A/B leg +# (StressPerf.Flex) whose opt-out switch defaults on, registry resolves the right +# exe/csproj, interleave runs both sides with drop-both pairing, and aggregates reach +# the renderer. Its Invoke-OneRun threading inherits $Percent (no -RunPercent), as the +# keyed leg does. +$fp = $ast.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq 'IncludeFlex' } | Select-Object -First 1 +Assert-True ($null -ne $fp) '[flex] -IncludeFlex parameter exists' +Assert-True ($fp -and $fp.DefaultValue -and $fp.DefaultValue.Extent.Text -eq '$true') '[flex] -IncludeFlex defaults to $true (on unless opted out)' +Assert-True ($src -match "Flex\s*=\s*@\{\s*AppName\s*=\s*'StressPerf\.Flex';\s*ProjectRel\s*=\s*'tests\\stress_perf\\StressPerf\.Flex") '[flex] AppRegistry maps Flex -> StressPerf.Flex exe + csproj' +Assert-True ($src -match "-Tag 'main-flex'") '[flex] leg interleaves the main side (main-flex)' +Assert-True ($src -match "-Tag 'pr-flex'") '[flex] leg interleaves the PR side (pr-flex)' +Assert-True ($src -match '-MainFlex \$mainFlex') '[flex] Format-PerfComment receives the main flex aggregate' +Assert-True ($src -match '-PrFlex \$prFlex') '[flex] Format-PerfComment receives the PR flex aggregate' + +# Opt-out + best-effort build fallback: the flex build is guarded by the switch, and a +# build failure flips the switch off (omit the table, never throw) so the leg is skipped. +Assert-True ($src -match 'if \(\$IncludeFlex -and -not \$SkipBuild\)') '[flex] build is guarded by -IncludeFlex (and -SkipBuild)' +Assert-True ($src -match '(?s)flex workload build failed.*?\$IncludeFlex = \$false') '[flex] a build failure flips -IncludeFlex off (best-effort: omit table, never throw)' +Assert-True ($src -match '\$mainFlexRuns = @\(\); \$prFlexRuns = @\(\)\s*\r?\n\s*if \(\$IncludeFlex\)') '[flex] the run leg is skipped unless -IncludeFlex is on' + +# Paired drop-both alignment: a complete flex pair appends BOTH sides; a one-sided +# failure drops BOTH halves so the paired CI's main[i]/pr[i] zip stays index-aligned. +Assert-True ($src -match 'if \(\$mm -and \$pm\) \{ \$mainFlexRuns \+= \$mm; \$prFlexRuns \+= \$pm \}') '[flex] a complete pair appends both main + pr samples' +Assert-True ($src -match 'elseif \(\$mm -or \$pm\) \{ Write-Log " flex pair #\$i incomplete') '[flex] a one-sided flex run drops both halves (paired CI stays aligned)' + +# result.json carries the flex aggregates so downstream tooling can read the leg. +Assert-True ($src -match 'mainFlex = \$mainFlex') '[flex] result.json object includes the main flex aggregate' +Assert-True ($src -match 'prFlex = \$prFlex') '[flex] result.json object includes the PR flex aggregate' + # =========================================================================== # Micro rep-interleave — ConvertTo-MicroRepLines + Invoke-MicroInterleaved # ===========================================================================