Skip to content

Commit 97deb48

Browse files
johnml1135claude
andcommitted
The opt-in large-project view-switch perf harness and its attribution tools are on main again, test and tool code only.
The harness pumps only Input-priority jobs to the first frame and records frame-split, created-control and settle spans per switch; ViewSwitchPerfTests.Switch is now shared with the new LargeProjectViewSwitchPerfTests (3000 words, 200 pending changes). Both stay behind MOTIF_PERF=1 and assert no wall-clock figures. tools/perf-attrib holds split.py, large.py, agg3.py and a short README. Ported from perf/rows: 26a749c5d (attribution diagnostics and large-project harness), 2297fbd53 (Input-priority pump, top created types), cf521c040 (trace and Perf harness basis). Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
1 parent a4b7cfe commit 97deb48

6 files changed

Lines changed: 249 additions & 2 deletions

File tree

‎tests/SIL.Motif.Tests.App/App/ViewSwitchPerfTests.cs‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,12 @@ private static int CountSpans(string path, string spanName, string label) => Fil
193193
return $"{span.GetProperty("kind").GetString()}:{span.GetProperty("to").GetString()}";
194194
}
195195

196-
private static async Task Switch(WorkspaceShellViewModel workspace, Avalonia.Controls.Window window, string name,
196+
internal static async Task Switch(WorkspaceShellViewModel workspace, Avalonia.Controls.Window window, string name,
197197
WorkspacePage page, TextsTab tab)
198198
{
199199
var texts = workspace.PageModel<TextsPageModel>();
200+
var before = new HashSet<Avalonia.Visual>(Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(window),
201+
ReferenceEqualityComparer.Instance as IEqualityComparer<Avalonia.Visual>);
200202
using var wall = MotifTrace.Begin("harness.click-to-frame");
201203
wall?.SetTag("view", name);
202204
if (page == WorkspacePage.Texts && workspace.CurrentPage == WorkspacePage.Texts) texts.Tab = tab;
@@ -206,9 +208,75 @@ private static async Task Switch(WorkspaceShellViewModel workspace, Avalonia.Con
206208
workspace.SelectedPage = workspace.PageOf(page);
207209
}
208210

209-
await WaitForFirstFrame(window, name);
211+
var clock = Stopwatch.StartNew();
212+
var tick = new Stopwatch();
213+
var jobs = new Stopwatch();
214+
var iterations = 0;
215+
var layoutUpdates = 0;
216+
var manager = Avalonia.VisualTree.VisualExtensions.GetLayoutManager(window);
217+
EventHandler onLayout = (_, _) => layoutUpdates++;
218+
manager.LayoutUpdated += onLayout;
219+
while (MotifTrace.CurrentSwitch is not null && clock.Elapsed < TimeSpan.FromSeconds(10))
220+
{
221+
iterations++;
222+
tick.Start();
223+
Avalonia.Headless.AvaloniaHeadlessPlatform.ForceRenderTimerTick();
224+
tick.Stop();
225+
jobs.Start();
226+
// Input priority and above, as the real dispatcher runs them; Background work waits for the settle below.
227+
Dispatcher.UIThread.RunJobs(DispatcherPriority.Input);
228+
jobs.Stop();
229+
}
230+
manager.LayoutUpdated -= onLayout;
231+
using (var split = MotifTrace.Begin("attrib.frame-split"))
232+
{
233+
split?.SetTag("view", name);
234+
split?.SetTag("tick-ms", Math.Round(tick.Elapsed.TotalMilliseconds, 2));
235+
split?.SetTag("jobs-ms", Math.Round(jobs.Elapsed.TotalMilliseconds, 2));
236+
split?.SetTag("loop-ms", Math.Round(clock.Elapsed.TotalMilliseconds, 2));
237+
split?.SetTag("iterations", iterations);
238+
split?.SetTag("layout-updated", layoutUpdates);
239+
}
240+
Assert.True(MotifTrace.CurrentSwitch is null, $"The switch to '{name}' never reached a first frame.");
210241
wall?.Dispose();
242+
using (var tree = MotifTrace.Begin("attrib.tree"))
243+
{
244+
var all = Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(window).ToList();
245+
var visible = all.Where(v => v.IsEffectivelyVisible).ToList();
246+
var afterSet = new HashSet<Avalonia.Visual>(all, ReferenceEqualityComparer.Instance as IEqualityComparer<Avalonia.Visual>);
247+
var created = all.Where(v => !before.Contains(v)).ToList();
248+
tree?.SetTag("view", name);
249+
tree?.SetTag("created", created.Count);
250+
tree?.SetTag("created-visible", created.Count(v => v.IsEffectivelyVisible));
251+
tree?.SetTag("removed", before.Count(v => !afterSet.Contains(v)));
252+
tree?.SetTag("created-owner", string.Join(", ", created.GroupBy(Owner)
253+
.OrderByDescending(g => g.Count()).Take(12).Select(g => $"{g.Key}={g.Count()}")));
254+
tree?.SetTag("created-top", string.Join(", ", created.GroupBy(v => v.GetType().Name)
255+
.OrderByDescending(g => g.Count()).Take(30).Select(g => $"{g.Key}={g.Count()}")));
256+
tree?.SetTag("visual-all", all.Count);
257+
tree?.SetTag("visual-visible", visible.Count);
258+
tree?.SetTag("top-visible", string.Join(", ", visible.GroupBy(v => v.GetType().Name)
259+
.OrderByDescending(g => g.Count()).Take(14).Select(g => $"{g.Key}={g.Count()}")));
260+
}
261+
var settle = Stopwatch.StartNew();
211262
await Quiet(window);
263+
using (var after = MotifTrace.Begin("attrib.settle"))
264+
{
265+
after?.SetTag("view", name);
266+
after?.SetTag("quiet-ms", Math.Round(settle.Elapsed.TotalMilliseconds, 1));
267+
}
268+
}
269+
270+
private static string Owner(Avalonia.Visual visual)
271+
{
272+
for (var parent = Avalonia.VisualTree.VisualExtensions.GetVisualParent(visual); parent is not null;
273+
parent = Avalonia.VisualTree.VisualExtensions.GetVisualParent(parent))
274+
{
275+
var type = parent.GetType();
276+
if (type.Namespace is { } ns && ns.StartsWith("SIL.Motif.App.Views", StringComparison.Ordinal)
277+
&& type.Name is not ("MainWindow" or "WorkspaceLayoutHost")) return type.Name;
278+
}
279+
return "?";
212280
}
213281

214282
private static Task WaitForFirstFrame(Avalonia.Controls.Window window, string name)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using SIL.Motif.App;
2+
using SIL.Motif.App.Services;
3+
using SIL.Motif.App.ViewModels;
4+
using SIL.Motif.Commands;
5+
using SIL.Motif.Commands.Baselines;
6+
using SIL.Motif.Contract.Ids;
7+
using SIL.Motif.Contract.Requests;
8+
using SIL.Motif.Contract.Responses;
9+
using SIL.Motif.Host;
10+
using SIL.Motif.Tests.Parser;
11+
using SIL.Motif.Tests.TestFixtures;
12+
using Xunit;
13+
14+
namespace SIL.Motif.Tests.App.Walkthrough;
15+
16+
/// <summary>Switches every page and Texts tab over the 3000-word large fixture with the trace on (opt-in, MOTIF_PERF=1).</summary>
17+
[Collection(LcmCacheTestCollection.Name)]
18+
[Trait("MotifTestLevel", "System")]
19+
[Trait("Category", "Perf")]
20+
public sealed class LargeProjectViewSwitchPerfTests
21+
{
22+
[PerfFact]
23+
public void EveryPageAndViewSwitchesOverTheLargeProject()
24+
{
25+
var path = Environment.GetEnvironmentVariable("MOTIF_PERF_TRACE")
26+
?? Path.Combine(Path.GetTempPath(), "motif-perf-large-trace.jsonl");
27+
File.Delete(path);
28+
var warmPasses = int.TryParse(Environment.GetEnvironmentVariable("MOTIF_PERF_N"), out var n) && n > 0 ? n : 3;
29+
AvaloniaHeadlessFixture.RunUntilComplete(async () =>
30+
{
31+
using var project = new LargeProjectFixture();
32+
var parser = FakeParser.CopyRecordingInvocations(project.ManagedRoot);
33+
FakeParser.BehaveBesideExecutable(parser, new
34+
{
35+
words = project.Words.Select((word, index) => new
36+
{
37+
word = word.Form,
38+
outcome = index % 5 == 0 ? "capped" : index % 5 == 1 ? "no-analysis" : "complete",
39+
elapsedMs = 1 + index % 20,
40+
analyses = index % 5 < 2 ? Array.Empty<object>() : new object[]
41+
{
42+
new { morphs = new[] { new
43+
{
44+
form = word.AllomorphId.ToString("D"), msa = word.MsaId.ToString("D"),
45+
inflType = (string?)null, guessedString = (string?)null,
46+
} } },
47+
},
48+
}),
49+
});
50+
var client = RealCommandClient.Create(project.ManagedRoot, parser);
51+
Assert.True((await client.CaptureBaselineAsync(new BaselineCaptureRequest(project.FwDataPath), CancellationToken.None)).Succeeded);
52+
Assert.True((await client.SetDefaultSelectionAsync(new SetDefaultSelectionRequest(
53+
project.FwDataPath, "Large Selection", project.TextIds, []), CancellationToken.None)).Succeeded);
54+
Assert.True((await client.SkipSetupAsync(new SkipSetupRequest(project.FwDataPath), CancellationToken.None)).Succeeded);
55+
Assert.True((await client.AssessAsync(new AssessRequest(project.FwDataPath),
56+
new Progress<AssessmentProgress>(), CancellationToken.None)).Succeeded);
57+
var pending = await client.LoadPendingChangesAsync(
58+
new PendingChangesRequest(project.FwDataPath, MotifProductVersion.CurrentText), CancellationToken.None);
59+
var revision = pending.Value!.Revision;
60+
foreach (var word in project.Words.Take(200))
61+
{
62+
var put = await client.PutPendingChangeAsync(new PutPendingChangeRequest(
63+
project.FwDataPath, MotifProductVersion.CurrentText, revision,
64+
new ChangeIntent(CanonicalId.Mint().Value, "incorrect-spelling",
65+
CanonicalId.FromGuid(word.WordformId).Value, word.Form)), CancellationToken.None);
66+
revision = put.Value!.Revision;
67+
}
68+
using var window = new WalkthroughWindow(project.ManagedRoot, project.FwDataPath, parserPath: parser);
69+
window.Show();
70+
window.OpenRecentProjectByClick(project.FwDataPath);
71+
await window.Workspace.Context.EvidencePublication;
72+
window.WaitUntilProjectIsQuiet(TimeSpan.FromSeconds(60), "large project did not finish opening");
73+
window.ShowPage(WorkspacePage.Overview);
74+
PageScreenshots.Settle(window.Window);
75+
// Prime the Texts reader, as a user who has opened Texts once would have.
76+
window.ShowPage(WorkspacePage.Texts);
77+
window.WaitUntil(() => window.Workspace.PageModel<TextsPageModel>().ResultsInText.Texts.Count == LargeProjectFixture.TextCount,
78+
TimeSpan.FromSeconds(60), "the large Texts were not loaded");
79+
window.ShowPage(WorkspacePage.Overview);
80+
PageScreenshots.Settle(window.Window);
81+
82+
using var sink = MotifTrace.StartListening(path);
83+
for (var pass = 0; pass <= warmPasses; pass++)
84+
{
85+
MotifTrace.RunLabel = $"{(pass == 0 ? "cold" : "warm")} large";
86+
foreach (var (name, page, tab) in PageScreenshots.Views())
87+
await ViewSwitchPerfTests.Switch(window.Workspace, window.Window, name, page, tab);
88+
}
89+
MotifTrace.RunLabel = null;
90+
}, TimeSpan.FromMinutes(20));
91+
}
92+
}

‎tools/perf-attrib/README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# View-switch perf harness and attribution tools
2+
3+
Opt-in measurement of page and Texts-tab switches. Nothing here runs in the normal gate: the facts use
4+
`PerfFact`, which skips unless `MOTIF_PERF=1`, and they assert no wall-clock figures.
5+
6+
Tests (project `SIL.Motif.Tests.App`):
7+
8+
- `ViewSwitchPerfTests`: sample project, cold then warm, two window sizes.
9+
- `LargeProjectViewSwitchPerfTests`: the 3000-word large fixture with 200 pending changes and the Texts reader primed.
10+
11+
Run (Linux, from the repo root):
12+
13+
MOTIF_PERF=1 MOTIF_PERF_N=3 MOTIF_PERF_TRACE=/tmp/large.jsonl \
14+
pwsh ./test.ps1 -Project SIL.Motif.Tests.App -Filter LargeProjectViewSwitchPerfTests
15+
16+
`MOTIF_PERF_N` is the warm pass count; `MOTIF_PERF_TRACE` is the JSON-lines trace. Close other test runs first and record
17+
`uptime` with the numbers, because wall-clock figures move with machine load.
18+
19+
Each switch writes extra spans: `attrib.frame-split` (render ticks versus Input-priority jobs, in ms), `attrib.tree`
20+
(visuals created and removed, by owning view and by type) and `attrib.settle` (the Background work after the first
21+
frame). The harness pumps only Input priority and above to the first frame, so work deferred to Background is not
22+
counted as frame wait.
23+
24+
Summaries (Python 3 standard library only):
25+
26+
python3 tools/perf-attrib/split.py TRACE "warm large" # frame split per view, medians
27+
python3 tools/perf-attrib/large.py TRACE "warm large" # loop, created controls, owners, settle per view
28+
python3 tools/perf-attrib/agg3.py SPEEDSCOPE.json FILTER N [incl|self] # needs a speedscope trace of a harness run
29+
30+
`tools/Summarize-MotifTrace.ps1` makes the table for the sample-project run.

‎tools/perf-attrib/agg3.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import json,sys,collections
2+
d=json.load(open(sys.argv[1])); filt=sys.argv[2]; N=int(sys.argv[3]); mode=sys.argv[4] if len(sys.argv)>4 else 'incl'
3+
fr=[f['name'] for f in d['shared']['frames']]
4+
isSw=[('<Switch>d__2.MoveNext' in n) for n in fr]
5+
isRJ=[('Dispatcher.RunJobs(value class System.Nullable`1<value class Avalonia.Threading.DispatcherPriority>)' in n) for n in fr]
6+
isQ=[('<Quiet>' in n) for n in fr]
7+
p=max(d['profiles'],key=lambda p:len(p['events']))
8+
incl=collections.Counter(); stack=[]; last=None; total=0
9+
for e in p['events']:
10+
t=e['at']
11+
if stack and last is not None:
12+
li=-1
13+
for k in range(len(stack)-1,-1,-1):
14+
if isSw[stack[k]]: li=k;break
15+
if li>=0:
16+
sub=stack[li:]
17+
if any(isRJ[i] for i in sub) and not any(isQ[i] for i in sub):
18+
dt=t-last; total+=dt
19+
if mode=='incl':
20+
for i in set(sub):
21+
if filt in fr[i]: incl[i]+=dt
22+
else:
23+
# self: deepest non-CPU frame
24+
j=len(sub)-1
25+
while j>0 and fr[sub[j]] in('CPU_TIME','UNMANAGED_CODE_TIME','?!?'): j-=1
26+
incl[sub[j]]+=dt
27+
last=t
28+
if e['type']=='O': stack.append(e['frame'])
29+
elif stack: stack.pop()
30+
print('total',round(total))
31+
for i,v in incl.most_common(N): print(f'{v:8.0f} {fr[i][:165]}')

‎tools/perf-attrib/large.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import json,sys,statistics as st,collections
2+
f=sys.argv[1]; lab=sys.argv[2]
3+
rows=[json.loads(l) for l in open(f)]
4+
fs={};tr={};sw=collections.defaultdict(list);q=collections.defaultdict(list)
5+
fsl=collections.defaultdict(list)
6+
for r in rows:
7+
if r.get('label')!=lab: continue
8+
if r['name']=='attrib.frame-split': fsl[r['view']].append(r)
9+
if r['name']=='attrib.tree': tr[r['view']]=r
10+
if r['name']=='attrib.settle': q[r['view']].append(r['quiet-ms'])
11+
if r['name']=='view.switch': sw[r['to']].append(r['ms'])
12+
print(f"{'view':22}{'switch':>8}{'loop':>8}{'created':>8}{'vis-all':>8}{'quiet':>7} owner")
13+
for v,l in fsl.items():
14+
t=tr[v]
15+
print(f"{v:22}{st.median(x['loop-ms'] for x in l):8.0f}{max(x['loop-ms'] for x in l):8.0f}{t['created']:8}{t['visual-visible']:8}{st.median(q[v]):7.0f} {t['created-owner'][:110]}")

‎tools/perf-attrib/split.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import json,sys,statistics as st,collections
2+
f=sys.argv[1]; lab=sys.argv[2] if len(sys.argv)>2 else 'warm 1280x800@100'
3+
rows=[json.loads(l) for l in open(f)]
4+
d=collections.defaultdict(list)
5+
for r in rows:
6+
if r['name']=='attrib.frame-split' and r.get('label','').startswith(lab.split()[0]) and lab.split()[1] in r.get('label',''):
7+
d[r['view']].append(r)
8+
print(f"{'view':22}{'loop':>8}{'tick':>8}{'jobs':>8}{'iter':>5}{'lu':>5}")
9+
for v,l in d.items():
10+
m=lambda k: st.median(x[k] for x in l)
11+
print(f"{v:22}{m('loop-ms'):8.0f}{m('tick-ms'):8.0f}{m('jobs-ms'):8.0f}{m('iterations'):5.0f}{m('layout-updated'):5.0f}")

0 commit comments

Comments
 (0)