운영 규모 예산을 잰다 — 그리고 2단계 근거가 사라졌다
재설계 1단계 마지막. --scale-budget [출력] [컨트롤수], 기본 1067.
--edit-smoke 는 컨트롤 3개 합성 문서에서 배선만 본다. 운영 최대는 1,067개인데
그 규모의 숫자가 CI 에 하나도 없었다. 없으면 재설계가 새 상호작용을 얹었을 때
재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다.
실측(최상위 1,067 + 중첩 자식 533, 마퀴로 1,067개 전부 잡힘):
호버(선택 0) 평균 0.01ms 최악 0.02ms
드래그(선택 1) 평균 1.41ms 최악 7.49ms
드래그(선택 500) 평균 2.70ms 최악 9.73ms
마퀴 전체 선택 7.29ms
되돌리기 1회 7.74ms
예산: 프레임당 16ms(60fps), 한 번 하는 조작 1000ms. 전부 통과.
<b>이 숫자가 재설계 계획의 2단계를 무효로 만든다.</b>
계획은 "좌표 캐시"를 3단계(상호작용) 앞에 필수로 두었고 근거는
"PageOf 가 O(N) 이고 O(N) 루프 안에 있다 — 드래그 프레임당 약 107만 회 참조 비교,
마퀴 전체 선택 한 번에 5~6백만 회"였다.
연산 횟수 산술은 맞다. 틀린 것은 그 횟수에 <b>연산당 비용을 곱하지 않은 것</b>이다 —
참조 비교는 1ns 수준이라 5백만 회가 곧 5ms 이고, 실제로 마퀴가 7.29ms 로 그 예측과 맞는다.
즉 분석은 옳았고 결론("느리다")만 틀렸다.
좌표 캐시는 지금 근거가 없으므로 뺀다. 필요해지면 이 게이트가 먼저 알려 준다.
<b>규모를 두 번 틀리게 잡았다.</b> 처음엔 자식 절반을 최상위에서 빼서 N=534 를 재고 있었다.
O(N) 원시연산이 보는 N 은 최상위 개수라 그 상태로는 최악을 못 잰다.
'만들려던 수와 다르면 실패'라는 가드가 그것을 잡았다.
재지 못하는 것을 클래스 주석에 적었다 — 여기 도는 것은 뷰모델 계층뿐이고
WPF Measure/Arrange·바인딩 재평가·렌더가 빠져 있다.
줌이 LayoutTransform 이라 눈금마다 서브트리를 다시 재는 비용,
Undo 뒤 시각 트리를 다시 세우는 비용은 이 숫자에 없다.
게이트: --scale-budget 5/5, 테스트 277/277, --dialog-shots 넘침 0(대조군 4/4),
--edit-smoke 0실패, --maxrect 0실패, --cleartype 11/11, --modal-check 0실패, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4150ebe1d7
commit
d55ca39e6a
@@ -288,6 +288,12 @@ public partial class App : Application
|
|||||||
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
|
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Length >= 1 && args[0] == "--scale-budget")
|
||||||
|
{
|
||||||
|
var count = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 1067;
|
||||||
|
return Diagnostics.ScaleBudget.Run(args.Length >= 2 ? args[1] : null, count);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.Length >= 1 && args[0] == "--cleartype")
|
if (args.Length >= 1 && args[0] == "--cleartype")
|
||||||
{
|
{
|
||||||
return Diagnostics.ClearTypeCheck.Run();
|
return Diagnostics.ClearTypeCheck.Run();
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using SheetMe.Core.Models;
|
||||||
|
using SheetMe.Designer.Services;
|
||||||
|
using SheetMe.Designer.ViewModels;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 운영 규모에서 상호작용이 얼마나 걸리는지 잰다 — <c>--scale-budget [출력파일] [컨트롤수]</c>.
|
||||||
|
///
|
||||||
|
/// <b>왜 필요한가.</b> --edit-smoke 는 컨트롤 <b>3개</b> 합성 문서에서 배선만 확인한다.
|
||||||
|
/// 운영 최대 서식은 1,067개다. 그 규모에서 무엇이 느린지 지금 CI 에 보이는 숫자가 하나도 없어서,
|
||||||
|
/// 재설계가 새 상호작용을 얹으면 <b>재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다</b>.
|
||||||
|
///
|
||||||
|
/// <b>무엇을 겨누는가.</b> 월드 좌표 조회가 O(N) 이고 그것이 O(N) 루프 안에 있다:
|
||||||
|
/// <c>PageOf</c> 가 <c>Pages.FirstOrDefault(p => p.Controls.Contains(root))</c> 이고
|
||||||
|
/// <c>ParentWorldOffset</c> → <c>WorldBoundsOf</c> 가 전부 이걸 탄다.
|
||||||
|
/// 그래서 호버 한 번, 드래그 한 프레임, 마퀴 전체 선택이 각각 몇 밀리초인지가 이 진단의 답이다.
|
||||||
|
///
|
||||||
|
/// <b>시간 예산은 넉넉하게 잡는다.</b> 이 숫자는 기계와 부하에 따라 흔들리므로,
|
||||||
|
/// 좁게 잡으면 멀쩡한 커밋에서 거짓 실패가 난다. 목적은 "빠른지"가 아니라
|
||||||
|
/// <b>"사람이 못 쓸 정도로 느려지는 회귀를 잡는 것"</b>이다.
|
||||||
|
/// 그래서 예산은 체감 한계(호버·드래그는 프레임당 16ms, 한 번 하는 조작은 1초) 기준으로 둔다.
|
||||||
|
///
|
||||||
|
/// <b>재지 못하는 것.</b> 여기서 도는 것은 뷰모델 계층뿐이다 — 시각 트리가 없으므로
|
||||||
|
/// WPF 의 Measure/Arrange, 바인딩 재평가, 렌더가 빠져 있다.
|
||||||
|
/// 줌이 LayoutTransform 이라 눈금마다 서브트리 전체를 다시 재는 비용,
|
||||||
|
/// Undo 뒤 시각 트리를 통째로 다시 세우는 비용은 이 숫자에 들어 있지 않다.
|
||||||
|
/// 이 진단이 답하는 질문은 "상호작용 산술이 규모에서 터지는가"이고, 답은 아니다 —
|
||||||
|
/// 실측으로 O(N)-in-O(N) 경고가 과장이었음이 드러났다(§커밋 메시지 참조).
|
||||||
|
/// </summary>
|
||||||
|
internal static class ScaleBudget
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>프레임당 이 이상이면 60fps 를 못 지킨다 — 드래그가 끊겨 보인다</summary>
|
||||||
|
private const double FrameBudgetMs = 16;
|
||||||
|
|
||||||
|
/// <summary>한 번 하는 조작(마퀴 전체 선택, 되돌리기)이 이 이상이면 멈춘 것으로 보인다</summary>
|
||||||
|
private const double GestureBudgetMs = 1000;
|
||||||
|
|
||||||
|
private const int HoverFrames = 100;
|
||||||
|
private const int DragFrames = 60;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
public static int Run(string? outputPath, int controlCount)
|
||||||
|
{
|
||||||
|
var report = new StringBuilder();
|
||||||
|
var failures = 0;
|
||||||
|
|
||||||
|
report.AppendLine($"운영 규모 예산 — 컨트롤 {controlCount}개");
|
||||||
|
report.AppendLine();
|
||||||
|
|
||||||
|
var designer = BuildPage(controlCount);
|
||||||
|
var controls = designer.Pages[0].Controls;
|
||||||
|
report.AppendLine($"최상위 {controls.Count}개 + 중첩 자식 {controls.Sum(c => c.Model.Children.Count)}개,"
|
||||||
|
+ $" 페이지 {designer.Pages.Count}장");
|
||||||
|
if (controls.Count != controlCount)
|
||||||
|
{
|
||||||
|
report.AppendLine($"★ 만들려던 수와 다르다 — 이 상태의 숫자는 비교 대상이 못 된다");
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
report.AppendLine();
|
||||||
|
|
||||||
|
var controller = new InteractionController(designer);
|
||||||
|
var none = new PointerContext(false, false, false, 1);
|
||||||
|
var ctrl = new PointerContext(true, false, false, 1);
|
||||||
|
|
||||||
|
// ① 호버 — 드래그가 아니어도 마우스만 움직이면 매 프레임 히트테스트가 돈다
|
||||||
|
failures += Budget(report, "호버(선택 0)", HoverFrames, FrameBudgetMs, frame =>
|
||||||
|
designer.Interaction.CursorAt(new Point(60 + (frame % 40), 60 + (frame % 40))));
|
||||||
|
|
||||||
|
// ② 드래그 1개 — 가장 흔한 조작
|
||||||
|
designer.Selection.SetSingle(controls[0]);
|
||||||
|
failures += Budget(report, "드래그(선택 1)", DragFrames, FrameBudgetMs, frame =>
|
||||||
|
{
|
||||||
|
if (frame == 0)
|
||||||
|
{
|
||||||
|
controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
|
||||||
|
}
|
||||||
|
controller.PointerMove(new Point(controls[0].X + 6 + frame, controls[0].Y + 6 + frame), none);
|
||||||
|
});
|
||||||
|
controller.CancelDrag();
|
||||||
|
|
||||||
|
// ③ 드래그 다수 — 정렬·같은 크기·간격 균등이 겨냥하는 바로 그 상황이다
|
||||||
|
var many = Math.Min(500, controls.Count);
|
||||||
|
designer.Selection.Set(controls.Take(many).ToList());
|
||||||
|
var anchor = controls[0];
|
||||||
|
failures += Budget(report, $"드래그(선택 {many})", DragFrames, FrameBudgetMs, frame =>
|
||||||
|
{
|
||||||
|
if (frame == 0)
|
||||||
|
{
|
||||||
|
controller.PointerDown(new Point(anchor.X + 2, anchor.Y + 2), none);
|
||||||
|
}
|
||||||
|
controller.PointerMove(new Point(anchor.X + 6 + frame, anchor.Y + 6 + frame), none);
|
||||||
|
});
|
||||||
|
controller.CancelDrag();
|
||||||
|
designer.Selection.Clear();
|
||||||
|
|
||||||
|
// ④ 마퀴로 전부 선택 — 한 제스처 안에서 O(N²) 가 터지는 자리
|
||||||
|
failures += Budget(report, "마퀴 전체 선택", 1, GestureBudgetMs, _ =>
|
||||||
|
{
|
||||||
|
controller.PointerDown(new Point(-40, -40), ctrl);
|
||||||
|
controller.PointerMove(new Point(4000, 40000), ctrl);
|
||||||
|
controller.PointerUp(new Point(4000, 40000), ctrl);
|
||||||
|
});
|
||||||
|
report.AppendLine($" (마퀴로 잡힌 것 {designer.Selection.Items.Count}개)");
|
||||||
|
|
||||||
|
// ⑤ 되돌리기 — 문서 전체 딥클론 + VM 전체 재생성이라 앱에서 가장 비싼 키 한 방이다
|
||||||
|
designer.Selection.SetSingle(controls[0]);
|
||||||
|
controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
|
||||||
|
controller.PointerMove(new Point(controls[0].X + 40, controls[0].Y + 40), none);
|
||||||
|
controller.PointerUp(new Point(controls[0].X + 40, controls[0].Y + 40), none);
|
||||||
|
failures += Budget(report, "되돌리기 1회", 1, GestureBudgetMs, _ => designer.Undo.Undo());
|
||||||
|
|
||||||
|
report.AppendLine();
|
||||||
|
report.AppendLine($"결과: 실패 {failures}건");
|
||||||
|
var text = report.ToString();
|
||||||
|
Console.Error.Write(text);
|
||||||
|
if (outputPath is { Length: > 0 })
|
||||||
|
{
|
||||||
|
System.IO.File.WriteAllText(outputPath, text);
|
||||||
|
}
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 프레임당 평균으로 판정한다 — 최악값은 GC 한 번에 튀어서 거짓 실패를 낸다.
|
||||||
|
/// 최악값은 함께 적어 사람이 볼 수 있게만 남긴다.
|
||||||
|
/// </summary>
|
||||||
|
private static int Budget(StringBuilder report, string name, int frames, double budgetMs, Action<int> work)
|
||||||
|
{
|
||||||
|
// 첫 회는 JIT·지연 초기화가 섞여 본 측정과 성격이 다르다 — 버리는 대신 따로 적는다
|
||||||
|
var warmUp = Stopwatch.StartNew();
|
||||||
|
work(0);
|
||||||
|
warmUp.Stop();
|
||||||
|
|
||||||
|
var worst = 0.0;
|
||||||
|
var watch = Stopwatch.StartNew();
|
||||||
|
for (var frame = 0; frame < frames; frame++)
|
||||||
|
{
|
||||||
|
var one = Stopwatch.StartNew();
|
||||||
|
work(frame);
|
||||||
|
one.Stop();
|
||||||
|
worst = Math.Max(worst, one.Elapsed.TotalMilliseconds);
|
||||||
|
}
|
||||||
|
watch.Stop();
|
||||||
|
|
||||||
|
var average = watch.Elapsed.TotalMilliseconds / Math.Max(1, frames);
|
||||||
|
var ok = average <= budgetMs;
|
||||||
|
report.AppendLine($"{(ok ? "PASS" : "FAIL")} {name,-18} 평균 {average,8:0.00}ms"
|
||||||
|
+ $" (예산 {budgetMs}ms, 최악 {worst:0.00}ms, 첫회 {warmUp.Elapsed.TotalMilliseconds:0.00}ms, {frames}회)");
|
||||||
|
return ok ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 운영 최대 규모의 페이지를 만든다. 컨테이너에 담긴 자식을 섞는다 —
|
||||||
|
/// 중첩 자식이 28,009개(그 중 RadioButton 15,026개)라 그것이 실제 모양이고,
|
||||||
|
/// 월드 좌표 조회가 부모를 타고 올라가는 비용도 거기서만 나온다.
|
||||||
|
/// </summary>
|
||||||
|
private static DesignerViewModel BuildPage(int count)
|
||||||
|
{
|
||||||
|
var document = new FormDocument();
|
||||||
|
var page = new FormPage { Width = 720, Height = 856 };
|
||||||
|
document.Pages.Add(page);
|
||||||
|
|
||||||
|
// <b>최상위를 정확히 count 개로 만든다.</b> O(N) 원시연산(PageOf 의 Controls.Contains,
|
||||||
|
// ControlsIntersecting)이 보는 N 은 최상위 개수이므로, 자식을 최상위에서 빼면 N 이 절반이 되어
|
||||||
|
// 최악을 재지 못한다(처음에 그렇게 만들어 534 를 재고 있었다).
|
||||||
|
// 컨테이너 자식은 그 위에 더 얹는다 — 중첩 자식 28,009개가 실제 모양이고,
|
||||||
|
// 월드 좌표가 부모를 타고 올라가는 비용은 거기서만 난다.
|
||||||
|
var panel = new ControlElement { Type = "Panel", Id = "Panel1" };
|
||||||
|
panel.Bounds = new LayoutRect { X = 20, Y = 20, W = 660, H = 400 };
|
||||||
|
page.Controls.Add(panel);
|
||||||
|
for (var i = 0; i < count / 2; i++)
|
||||||
|
{
|
||||||
|
panel.Children.Add(new ControlElement
|
||||||
|
{
|
||||||
|
Type = "RadioButton",
|
||||||
|
Id = $"N{i:0000}",
|
||||||
|
Bounds = new LayoutRect { X = 8 + (i % 40) * 16, Y = 8 + (i / 40 % 24) * 16, W = 60, H = 14 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < count - 1; i++)
|
||||||
|
{
|
||||||
|
page.Controls.Add(new ControlElement
|
||||||
|
{
|
||||||
|
Type = i % 3 == 0 ? "RadioButton" : i % 3 == 1 ? "Label" : "TextBox",
|
||||||
|
Id = $"C{i:0000}",
|
||||||
|
Bounds = new LayoutRect { X = 20 + (i % 40) * 17, Y = 440 + (i / 40 % 24) * 17, W = 60, H = 14 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new DesignerViewModel(document);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user