From 4fab9a043e1a2f5a2de8b28076bea3e6add8cae3 Mon Sep 17 00:00:00 2001 From: chinboy Date: Mon, 12 Oct 2026 07:42:17 +0800 Subject: [PATCH] =?UTF-8?q?perf(Core):=20=E6=8E=A7=E4=BB=B6=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=BF=AB=E7=85=A7=E6=8C=89=20slot=20=E6=83=B0?= =?UTF-8?q?=E6=80=A7=E7=BC=96=E8=AF=91=EF=BC=8CApplyTheme=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=B7=AF=E5=BE=84=E5=86=8D=E7=9C=81=E7=BA=A6=2061ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/systems/theming/runtime.md | 22 +- ...-10-11-macos-startup-remaining-hotspots.md | 28 ++- ...hotspot-c-lazy-control-snapshots-design.md | 155 ++++++++++++++ .../Theme/Compilation/LazyControlSnapshots.cs | 195 ++++++++++++++++++ .../Theme/Compilation/ThemeCompiler.cs | 190 ++++++++++++----- .../ThemeRetainedBytesEstimator.cs | 14 +- .../Theme/Compilation/ThemeSnapshot.cs | 15 +- .../Theme/DenseThemeCompilerTests.cs | 17 +- 8 files changed, 565 insertions(+), 71 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-11-hotspot-c-lazy-control-snapshots-design.md create mode 100644 src/AtomUI.Core/Theme/Compilation/LazyControlSnapshots.cs diff --git a/docs/architecture/systems/theming/runtime.md b/docs/architecture/systems/theming/runtime.md index 878f6f0e2..482075145 100644 --- a/docs/architecture/systems/theming/runtime.md +++ b/docs/architecture/systems/theming/runtime.md @@ -589,9 +589,9 @@ ThemeCompileInput -> apply Map overrides -> format Alias Token -> apply Alias overrides and AtomUI final normalization - -> evaluate each registered Control - -> freeze token values and resources - -> ThemeSnapshot + -> resolve each registered Control slot state (cheap config comparison) + -> freeze global token values and resources + -> ThemeSnapshot (control slots compile on first query) ``` 编译阶段允许使用可变 builder,因为算法天然需要逐步派生。builder 的生命周期只限于一次编译,不能进入 @@ -621,7 +621,15 @@ snapshot,也不能通过公开 API 返回。 Control 默认 Token evaluator 必须接收 Control appearance 和 Effective Global Token,不能继续只读取全局 `IsDark`。 -任何 validation、conversion 或 algorithm 错误都返回失败结果,不返回部分 snapshot。 +控件槽位编译契约:`ApplyTheme` 返回时全局 token 与资源完整提交;控件级 `ControlThemeSnapshot` +按槽位惰性编译,首次查询时经同一 `CompileControl` 函数与同一缓存键编译,结果值与急切编译逐位 +相等(急切参考路径 `CompileControlsEagerForTest` 差分锁定)。首帧内首次实例化的控件一次性支付 +内联编译成本(~0.7ms 量级)。主题切换复用同样惰性:子快照槽位记录父快照引用,首访时物化并共享 +同一对象。失败语义同步后移:控件级编译错误(不支持的算法模式、evaluator 抛出等)不再使 +`ApplyTheme` 整体失败,而是在首次查询该槽位时抛出、后续每次查询重复抛出。 + +任何 validation、conversion 或 algorithm 错误都返回失败结果,不返回部分 snapshot——该原子性指 +全局维度(Seed/算法链/全局 token/资源);控件维度的失败面按槽位延后到首查(见上方契约段)。 ## 8. ThemeSnapshot @@ -636,7 +644,7 @@ ThemeSnapshot +-- GlobalTokenValues: dense immutable TokenValueTable +-- GlobalResources +-- PresetColorPalettes -\-- Controls[registry control slot] +\-- Controls: LazyControlSnapshots[registry control slot] (compile on first query) +-- Appearance +-- EffectiveGlobalTokenDelta +-- ControlTokenValues: dense immutable TokenValueTable @@ -648,6 +656,10 @@ ThemeSnapshot - Token schema 由生成器分配在当前 registry revision 内稳定的 slot;完整 Token 值表使用稠密不可变数组,不使用 object-key 字典保存每个值。 - Control 表按 registry slot 存放引用,`ControlTokenIdentity` 只用于配置边界和诊断。 +- Control 表是按槽位惰性编译的容器(只暴露 Count 与索引器,不实现枚举);构造期解析槽位状态 + (已物化 / 复用父槽 / 待编译),保留字节估算为构造期基线加首访物化增量。缓存在插入时采样 + `EstimatedRetainedBytes`,物化增长不回灌——估算漂移上界为全部槽位字节,只影响驱逐时机, + 不影响正确性。 - 稀疏覆盖、Control Effective Global Token 差量和 Avalonia 资源投影使用不可变集合或 `FrozenDictionary`。 - 颜色资源在 snapshot 中已经转换为不可变 Brush。 - palette、集合和其他引用值必须在冻结前深复制为不可变值。 diff --git a/docs/superpowers/progress/2026-10-11-macos-startup-remaining-hotspots.md b/docs/superpowers/progress/2026-10-11-macos-startup-remaining-hotspots.md index d1be281e6..9fa9f03a6 100644 --- a/docs/superpowers/progress/2026-10-11-macos-startup-remaining-hotspots.md +++ b/docs/superpowers/progress/2026-10-11-macos-startup-remaining-hotspots.md @@ -50,7 +50,7 @@ harness 为空窗口真实桌面注册(UseDesktopControls)。 热点兑现核对:A 估 60-90ms 实测 −24.2ms(部分份额已被前轮 914ce40ef 取走);B 估 50-70ms 实测 −71.7ms(达成,经方案 B 惰性挂载实现——原计划"生成等价 C# wrapper"机制因 Avalonia 编译期 include 展开为急切 Build 调用且运行时 ResourceInclude 在 AOT 下不可用而废弃);D 估 20-30ms 实测 3.1-4.7ms -(估计基于 Task 2 前缀续算前的测量,主份额已被 Task 2 取走);C 待用户批准(契约修订门,见热点 C 节)。 +(估计基于 Task 2 前缀续算前的测量,主份额已被 Task 2 取走);C 估 62-68ms 实测 −60.9ms(SyncTotal,已实施,见热点 C 节"实施与实测")。 正确性验收:Core 528/528、Generator 458/458、Desktop 全套 4184 零崩溃(4 个失败为急切基线同样复现的 存量负载敏感 flaky);Gallery osx-arm64 全裁剪 NativeAOT 发布通过 + 产物运行冒烟通过;整分支新鲜 @@ -225,6 +225,30 @@ ThemeSnapshot.Controls 由 IReadOnlyList 改为按需槽 预期收益:空窗口 CompileControls 72.3 → **~2-5ms**(仅 Window 所需槽位); 代价:每控件首次实例化 +~0.5-0.7ms(Gallery 页面导航首批控件摊销)。 +### 实施与实测(2026-10-11 已实施,契约修订经用户批准) + +实施:新增 `LazyControlSnapshots`(Count + 索引器,不实现枚举)+ `LazyControlSnapshotSource` +(冻结编译输入)+ `ControlSlotState`(Materialized/ReuseParent/Uncompiled 构造期急切解析); +`CompileControls` 改惰性解析循环,旧急切循环原样保留为 `CompileControlsEagerForTest` 差分钩子; +`ThemeSnapshot.EstimatedRetainedBytes` 改实时属性(构造期基线 + `MaterializedBytes` 增量), +估算器删全量枚举;契约修订落入 [runtime.md](../../architecture/systems/theming/runtime.md) §7/§8。 +spec:`docs/superpowers/specs/2026-10-11-hotspot-c-lazy-control-snapshots-design.md`。 + +实测(交错 10× 采样中位数,对照 b3557e188 基线): + +| 指标 | 基线 | 惰性 | Δ | +|---|---:|---:|---:| +| UseAtomUI.SyncTotal | 452.2 | 391.2 | **−60.9** | +| UseAtomUI.PostConfigure | 255.9 | 189.8 | **−66.1** | +| Mark.FrameworkCompleted | 686.7 | 644.9 | −41.8 | +| Mark.FirstRender | 962.4 | 935.6 | −26.8 | +| Mark.DispatcherIdle | 1032.1 | 1001.1 | −31.0 | + +验收:193 槽位真实注册表差分(主变体 + ReuseParent/Uncompiled 混合变体)逐 token/逐资源/逐保留字节 +结构相等 ✓;启动 MaterializedCount 实测 **0**(精确棘轮)✓;并发单飞(同槽/异槽)✓;估算增量恒等 ✓; +主题切换 Committed + 首查契约 ✓。差分方法论修正:Easing/Brush/Transform 每编译克隆自有不可变副本 +(所有权边界),引用不等是既有设计,差分按结构比较(eager-vs-eager 探针证实与惰性化无关)。 + --- ## 热点 D:ThemeManagerBuilder.Build 46.1ms —— Revision 指纹与冻结仍全量 @@ -357,4 +381,4 @@ ICU 初始化与 JIT 无算法空间。`LanguageManager` 的三次小复制已 问题 6 已由 MatchedBundleResolver 实施。 本文所有预期收益为源码复杂度推算,**未经实现验证**;每项实施后回填实测并更新状态。 -【2026-10-11 状态更新:热点 A/B/D 已实施并实测回填(见上方"最终实测收益"表);热点 C 待用户批准(契约修订门);热点 E 维持"不立项"结论。】 +【2026-10-11 状态更新:热点 A/B/C/D 已实施并实测回填(见上方"最终实测收益"表与热点 C 节"实施与实测");热点 E 维持"不立项"结论。】 diff --git a/docs/superpowers/specs/2026-10-11-hotspot-c-lazy-control-snapshots-design.md b/docs/superpowers/specs/2026-10-11-hotspot-c-lazy-control-snapshots-design.md new file mode 100644 index 000000000..e50a1f4da --- /dev/null +++ b/docs/superpowers/specs/2026-10-11-hotspot-c-lazy-control-snapshots-design.md @@ -0,0 +1,155 @@ +# 热点 C 设计:控件主题快照按 slot 惰性编译 + +日期:2026-10-11 +状态:设计已获用户批准(含两个实施细节决策点) +上游材料:`docs/superpowers/progress/2026-10-11-macos-startup-remaining-hotspots.md` 热点 C 节; +`docs/superpowers/plans/2026-10-11-startup-remaining-hotspots-optimization.md` Task 6(契约修订批准门) + +## 背景与基线 + +`ThemeCompiler.CompileControls`(ThemeCompiler.cs:237)在主题编译末期对注册表全部 187 个控件急切编译 +`ControlThemeSnapshot`,实测 72.3ms: + +| 子项 | 实测 | +|---|---:| +| Own Token 计算(87 个控件,1032 个派生公式) | 34.5ms | +| Token 冻结(TokenValueTable.Freeze + 逐值保留字节估算) | 17.5ms | +| 资源投影(BuildResourceMap 装箱/冻结/FrozenDictionary) | 9.9ms | +| 循环、复用判定、快照分配 | ~10.4ms | + +运行时对控件快照的唯一消费是按槽位点查(`snapshot.Controls[slot]`),空窗口首帧前查询次数为零—— +约 62ms 预编译换取零次查询。本设计把控件级快照改为首查时编译,预期同步启动路径再省 ~62-68ms。 + +## 契约修订(用户已在 Task 6 门批准) + +现行(`docs/architecture/systems/theming/runtime.md` §7 编译器管线 "evaluate each registered Control → +freeze → ThemeSnapshot" 为一次同步完成,§8 ThemeSnapshot 的 `Controls[registry control slot]` 为编译期 +全量数组;即计划 Task 6 所称"首帧完整主题提交"): +`ApplyTheme` 返回时全部控件快照已编译、立即可查。 + +修订后:`ApplyTheme` 返回时**全局 token/资源完整提交**,控件级快照**首次查询时编译**。 +runtime.md §7 的管线描述与 §8 的 `Controls` 语义随实施同步修订。 + +- 任何资源查询的**结果值不变**(差分测试机械锁定); +- 公开 API 不变(`ThemeSnapshot`/`ControlThemeSnapshot` 均为 internal); +- 首帧内首次实例化的控件各自一次性支付 ~0.7ms(含 JIT)内联编译; +- "切换主题后控件 token 立即可查"变为"首次查询时可查",结果相同、时点不同。 + +## 消费者清单(源码核实,穷举) + +`ThemeSnapshot.Controls` 的全部消费点共 7 处,形态只有 `Count` 检查与索引器点查两种: + +1. `ThemeTokenResolver.cs:50-54`(token 取值)、`:65-80`(按名回退扫描)、`:107-112`(资源键回退) +2. `ThemeTokenResourceProvider.cs:53-59`、`:87`、`:100-102`(TryGetResource 三路径) +3. `ThemeRetainedBytesEstimator.cs:42-43`(**唯一的枚举消费者**——估算重写对象) +4. `ThemeCompiler.cs:278`(主题切换复用:`controls[slot] = ReusableParent.Controls[slot]`) + +## 设计 + +### 1. LazyControlSnapshots(新 internal 类型) + +替代 `IReadOnlyList`,**只暴露 `Count` 与索引器 `[int slot]`,刻意不实现 +`IEnumerable`**——枚举即全量物化,正是 `EstimateSnapshot` 的陷阱,从类型上禁绝。 + +```csharp +internal sealed class LazyControlSnapshots +{ + // 状态在快照构造期急切解析(廉价配置比较),编译推迟到首访。 + private enum SlotState : byte { Empty, ReuseParent, Uncompiled } + + private readonly SlotState[] _states; + private readonly ControlThemeSnapshot?[] _materialized; // 物化缓存 + private readonly ControlThemeSnapshot[] _eagerEmpty; // Empty 槽位的急切共享实例 + private readonly LazyControlSnapshotSource _source; // Uncompiled 槽位的编译输入(冻结) + private readonly ThemeSnapshot? _reusableParent; // ReuseParent 槽位的父快照 + private readonly object _gate = new(); + private long _materializedBytes; // 估算增量累计(Interlocked) + private int _materializedCount; // 测试观测口 + + internal int Count => _states.Length; + internal int MaterializedCount => _materializedCount; // 测试观测口 + internal long MaterializedBytes => Interlocked.Read(ref _materializedBytes); + + internal ControlThemeSnapshot this[int slot] => + _materialized[slot] ?? Materialize(slot); +} +``` + +`Materialize(slot)`:按状态分派——Empty 返回急切实例;ReuseParent 读父快照索引器 +(父槽未物化则此时物化父槽并共享同一对象);Uncompiled 进 `_gate` 双检后经 +`ControlCompilationCache.GetOrCompile`(缓存键不变:GlobalCompilationCacheKey + descriptor + +config + appearance)编译,回填 `_materialized[slot]` 并 `Interlocked.Add` 估算字节与计数。 + +### 2. 急切解析 + 惰性编译(决策点 1,已批准) + +`CompileControls` 保留每槽位廉价判定循环(FindControlConfig / IsControlChanged / CanReuseControl, +~9.5ms),把每槽位解析为三种状态;只推迟昂贵的 `CompileControl`(~62ms)。 + +被否决的备选"全惰性"(槽位判定也推迟):可多省 ~8ms,但 FindControlConfig 的光标是跨槽位顺序 +扫描优化,惰性化需改为每槽位二分查找并重排 changeSet 游标——复杂度高、收益小。 + +### 3. 主题切换:首访时父槽位物化复用 + +`ThemeCompiler.cs:278` 的复用改写为子槽位记录 `ReuseParent(parent, slot)`,切换期零编译。 +首访时物化父槽并共享——复用语义本就是同一对象引用,物化父槽(~0.7ms)与子槽自编译成本相同。 +未物化槽位在切换期不被被动触发(changeSet 惰性继承成立)。 + +### 4. 估算记账:急切基线 + 增量回填(决策点 2,已批准) + +`ThemeSnapshot.EstimatedRetainedBytes` = 全局部分急切(现状)+ Empty 槽位急切 + +`controls.MaterializedBytes`(物化时增量)。ReuseParent 槽共享父对象只计引用字节。 +`ThemeRetainedBytesEstimator.EstimateSnapshot` 不再枚举槽位(删除 :42-43 的 foreach, +改为读 `MaterializedBytes` 快照值)。 + +已知取舍:两个缓存(ThemeSnapshotCache / ControlCompilationCache)在**插入时**采样 +`EstimatedRetainedBytes`(BoundedLruCache 尺寸委托),物化增长不回灌。估算是启发式容量阀, +漂移上界 = 实消费槽位字节(≤187 槽位 × 单槽 KB 级),可接受,写入代码注释。 + +### 5. 并发 + +查询可从任意线程进入:索引器先无锁读 `_materialized[slot]`,未命中进 `_gate` 双检。 +`BoundedLruCache` 自带 `_gate` 锁与单飞创建(InFlightCount 语义),缓存路径安全。 +编译输入(TokenValueTable 已 Freeze、NormalizedControlThemeConfig、算法列表、外观)在快照 +构造期冻结,`CompileControl` 纯构造新对象,无共享突变。 + +## 等价性论证 + +同一 `ThemeSnapshot` 内编译输入(globalValues/globalResources/palettes/config/appearance/registry) +在构造期冻结,槽位延迟编译与批量编译走**同一个** `CompileControl`(ThemeCompiler.cs:354)函数与 +同一个缓存键——逐位相等是结构性质,由差分测试机械证明。 + +## 测试矩阵(临时测试,验收后按生命周期删除) + +1. **187 槽位差分**:真实桌面注册构建两个同输入快照——急切参考(测试钩子直调现有 + CompileControls 急切路径)与惰性——逐槽位比对 token 值、资源键值、保留字节。 +2. **空窗口物化计数**:启动后 `MaterializedCount` ≈ 0–2(仅 Window 链),精确门限回归。 +3. **主题切换**:Light/Dark/Compact/局部 scope 切换前后同一控件查询结果不变;切换耗时不回归 + (对照急切基线,容差内)。 +4. **并发首查**:N 线程同时首访同一槽位,编译工厂调用计数 == 1,全部返回同一实例。 +5. **估算增量**:物化 N 槽后 `EstimatedRetainedBytes` == 急切基线 + Σ 已物化槽位估算 + + 复用槽引用字节。 +6. **复用共享**:切换后 ReuseParent 槽位首访返回的对象与父槽位 `ReferenceEquals`。 + +## 改动文件 + +- 新增:`src/AtomUI.Core/Theme/Compilation/LazyControlSnapshots.cs` +- 修改:`ThemeSnapshot.cs`(Controls 类型 + 估算接线)、`ThemeCompiler.cs`(CompileControls + 解析循环输出状态机、复用路径改 ReuseParent 标记)、`ThemeRetainedBytesEstimator.cs` + (删枚举改读增量)、`ThemeTokenResolver.cs` / `ThemeTokenResourceProvider.cs` + (类型跟随,索引器签名不变) +- 测试钩子:ThemeCompiler 暴露 internal 急切编译入口(IVT,仅供差分测试对照) +- 文档:`docs/architecture/systems/theming/runtime.md` §7 编译器管线与 §8 ThemeSnapshot + `Controls` 语义修订(全局急切 + 控件级首查编译) + +## 非目标 + +- 不改变任何查询结果、缓存键、公开 API; +- 不改 `BoundedLruCache`(漂移接受,见 §4); +- 不动全局 token/资源的急切提交; +- 不做"全惰性"(槽位判定推迟)。 + +## 风险与回退 + +- 首查线程上 ~0.7ms 内联编译:可感知性极低(与控件实例化同帧既有成本同量级); +- 估算漂移导致缓存提前/滞后驱逐:量级有界,驱逐只影响性能不影响正确性; +- 回退:单提交 revert 恢复急切 CompileControls(无跨模块状态迁移)。 diff --git a/src/AtomUI.Core/Theme/Compilation/LazyControlSnapshots.cs b/src/AtomUI.Core/Theme/Compilation/LazyControlSnapshots.cs new file mode 100644 index 000000000..0507e4cc2 --- /dev/null +++ b/src/AtomUI.Core/Theme/Compilation/LazyControlSnapshots.cs @@ -0,0 +1,195 @@ +using AtomUI.Theme.Configuration; +using AtomUI.Theme.DesignTokens; +using AtomUI.Theme.Schema; + +namespace AtomUI.Theme.Compilation; + +/// +/// 控件快照槽位状态,快照构造期急切解析(见 ThemeCompiler.CompileControls)。 +/// +internal enum ControlSlotState : byte +{ + /// 构造期已物化(无配置且无 Own Token 的槽位在构造期拿到廉价空快照)。 + Materialized, + + /// 复用父快照同槽位对象:切换期零编译,首访时物化父槽并共享同一引用。 + ReuseParent, + + /// 编译输入已冻结,首访时经 CompileControl / ControlCompilationCache 编译。 + Uncompiled, +} + +/// +/// Uncompiled 槽位的冻结编译输入。快照构造期由 ThemeCompiler 填充;CompileControl 纯读这些 +/// 输入构造新对象、无共享突变,因此惰性编译与急切编译逐位等价(同一函数、同一缓存键)。 +/// +internal sealed class LazyControlSnapshotSource +{ + private readonly ThemeSchemaRegistry _registry; + private readonly NormalizedControlThemeConfig?[] _configs; + private readonly IReadOnlyList _globalAlgorithms; + private readonly ThemeAppearance _globalAppearance; + private readonly DesignToken _globalBuilder; + private readonly TokenValueTable _globalValues; + private readonly IReadOnlyDictionary _globalResources; + private readonly ControlCompilationCache? _cache; + private readonly GlobalCompilationCacheKey _globalKey; + + internal LazyControlSnapshotSource( + ThemeCompileInput input, + NormalizedControlThemeConfig?[] configs, + ThemeAppearance globalAppearance, + DesignToken globalBuilder, + TokenValueTable globalValues, + IReadOnlyDictionary globalResources, + ControlCompilationCache? cache) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(configs); + _registry = input.Registry; + _configs = configs; + _globalAlgorithms = input.EffectiveConfig.Algorithms; + _globalAppearance = globalAppearance; + _globalBuilder = globalBuilder; + _globalValues = globalValues; + _globalResources = globalResources; + _cache = cache; + // 与急切路径的 globalCompilationCacheKey ??= 语义等价:source 只在存在 Uncompiled 槽位时构造。 + _globalKey = GlobalCompilationCacheKey.Create(input); + } + + internal ControlThemeSnapshot Compile(int slot) + { + var descriptor = _registry.Controls[slot]; + var config = _configs[slot]; + + ControlThemeSnapshot Factory() + { + return ThemeCompiler.CompileControl( + descriptor, + config, + _globalAlgorithms, + _globalAppearance, + _globalBuilder, + _globalValues, + _globalResources, + _registry.GlobalTokens); + } + + return _cache is null + ? Factory() + : _cache.GetOrCompile(_globalKey, descriptor, config, _globalAppearance, Factory); + } +} + +/// +/// 控件主题快照的按槽位惰性容器。只暴露 Count 与索引器,刻意不实现 IEnumerable—— +/// 枚举即全量物化,正是快照保留字节估算要避免的形态(见 ThemeRetainedBytesEstimator)。 +/// 状态与编译输入在快照构造期急切解析并冻结;昂贵的 CompileControl 推迟到首访 +/// (空窗口首帧前对控件快照的查询次数为零,急切预编译是纯启动损耗)。 +/// 线程契约:索引器可从任意线程进入;未物化槽位经 _gate 双检单飞物化,无锁快读命中 +/// 已物化槽位(引用写入原子、无撕裂;读到过期 null 只是多进一次锁,双检兜底)。 +/// +internal sealed class LazyControlSnapshots +{ + // 与 ThemeRetainedBytesEstimator.ReferenceBytes 对齐:复用槽共享父对象,只计引用字节。 + private const long SharedSlotReferenceBytes = 8; + + private readonly ControlSlotState[] _states; + private readonly ControlThemeSnapshot?[] _materialized; + private readonly ThemeSnapshot? _reusableParent; + private readonly LazyControlSnapshotSource? _source; + private readonly object _gate = new(); + private long _materializedBytes; + private int _materializedCount; + + internal LazyControlSnapshots( + ControlSlotState[] states, + ControlThemeSnapshot?[] preMaterialized, + ThemeSnapshot? reusableParent, + LazyControlSnapshotSource? source) + { + ArgumentNullException.ThrowIfNull(states); + ArgumentNullException.ThrowIfNull(preMaterialized); + if (states.Length != preMaterialized.Length) + { + throw new ArgumentException("The state and pre-materialized arrays must have the same length."); + } + if (source is null && Array.IndexOf(states, ControlSlotState.Uncompiled) >= 0) + { + throw new ArgumentException("Uncompiled slots require a compilation source."); + } + if (reusableParent is null && Array.IndexOf(states, ControlSlotState.ReuseParent) >= 0) + { + throw new ArgumentException("ReuseParent slots require a reusable parent snapshot."); + } + + _states = states; + _materialized = preMaterialized; + _reusableParent = reusableParent; + _source = source; + // 构造期已物化的 Empty 槽位计入估算基线;Materialize 只累计首访物化的增量 + // (设计文档 §4:急切基线 + 增量回填)。 + foreach (var snapshot in preMaterialized) + { + if (snapshot is not null) + { + _materializedBytes += snapshot.EstimatedRetainedBytes; + } + } + } + + internal int Count => _states.Length; + + // 测试观测口:首访物化(ReuseParent + Uncompiled)的槽位计数,不含构造期 Empty 实例。 + internal int MaterializedCount => Volatile.Read(ref _materializedCount); + + internal long MaterializedBytes => Interlocked.Read(ref _materializedBytes); + + internal ControlThemeSnapshot this[int slot] + { + get + { + if ((uint)slot >= (uint)Count) + { + throw new ArgumentOutOfRangeException(nameof(slot)); + } + return _materialized[slot] ?? Materialize(slot); + } + } + + private ControlThemeSnapshot Materialize(int slot) + { + lock (_gate) + { + if (_materialized[slot] is { } existing) + { + return existing; + } + + ControlThemeSnapshot snapshot; + if (_states[slot] == ControlSlotState.ReuseParent) + { + // 物化父槽(父槽未物化则级联物化)并共享同一对象——复用语义本就是同一引用。 + snapshot = _reusableParent!.Controls[slot]; + } + else + { + snapshot = _source!.Compile(slot); + } + + _materialized[slot] = snapshot; + var delta = _states[slot] == ControlSlotState.ReuseParent + ? SharedSlotReferenceBytes + : snapshot.EstimatedRetainedBytes; + // 饱和语义与估算器 Add 对齐:溢出回绕(需 ~9.2e18 保留字节,物理不可达)不允许 + // 产生负值——负尺寸会让 BoundedLruCache 的容量记账抛异常。 + if (Interlocked.Add(ref _materializedBytes, delta) < 0) + { + Interlocked.Exchange(ref _materializedBytes, long.MaxValue); + } + Interlocked.Increment(ref _materializedCount); + return snapshot; + } + } +} diff --git a/src/AtomUI.Core/Theme/Compilation/ThemeCompiler.cs b/src/AtomUI.Core/Theme/Compilation/ThemeCompiler.cs index 747ba9c46..364adef77 100644 --- a/src/AtomUI.Core/Theme/Compilation/ThemeCompiler.cs +++ b/src/AtomUI.Core/Theme/Compilation/ThemeCompiler.cs @@ -151,41 +151,13 @@ internal sealed class ThemeCompiler return Failed(diagnostics); } - var appearance = ResolveAppearance(input.EffectiveConfig.Algorithms); - var reuseGlobal = CanReuseGlobal(input, appearance); - DesignToken globalBuilder; - TokenValueTable globalValues; - IReadOnlyDictionary globalResources; - IReadOnlyDictionary palettes; - if (reuseGlobal) - { - var parent = input.ReusableParent!; - globalValues = parent.GlobalTokenValues; - globalResources = parent.GlobalResources; - palettes = parent.PresetColorPalettes; - globalBuilder = CreateBuilder(globalValues, palettes, input.Registry.GlobalTokens); - } - else - { - globalBuilder = CompileGlobalBuilder(input); - globalValues = TokenValueTable.Freeze(globalBuilder, input.Registry.GlobalTokens); - globalResources = BuildResourceMap(globalBuilder, input.Registry.GlobalTokens, globalValues); - palettes = FreezePresetPalettes(globalBuilder); - } - - var controls = CompileControls( - input, - appearance, - globalBuilder, - globalValues, - globalResources, - reuseGlobal, - controlCompilationCache); + var global = CompileGlobal(input); + var controls = CompileControls(input, global, controlCompilationCache); var fingerprint = ThemeContentFingerprint.Compute( input.DefinitionRevision, input.EffectiveConfig, input.Registry.Revision, - appearance); + global.Appearance); return new ThemeCompileResult( new ThemeSnapshot( @@ -193,13 +165,13 @@ internal sealed class ThemeCompiler input.Definition, input.DefinitionRevision, fingerprint, - appearance, + global.Appearance, input.Registry.Revision, input.Registry, input.EffectiveConfig, - globalValues, - globalResources, - palettes, + global.Values, + global.Resources, + global.Palettes, controls), CopyDiagnostics(diagnostics), null); @@ -210,6 +182,47 @@ internal sealed class ThemeCompiler } } + internal readonly record struct GlobalCompileOutcome( + ThemeAppearance Appearance, + bool ReuseGlobal, + DesignToken Builder, + TokenValueTable Values, + IReadOnlyDictionary Resources, + IReadOnlyDictionary Palettes); + + private static GlobalCompileOutcome CompileGlobal(ThemeCompileInput input) + { + var appearance = ResolveAppearance(input.EffectiveConfig.Algorithms); + var reuseGlobal = CanReuseGlobal(input, appearance); + DesignToken globalBuilder; + TokenValueTable globalValues; + IReadOnlyDictionary globalResources; + IReadOnlyDictionary palettes; + if (reuseGlobal) + { + var parent = input.ReusableParent!; + globalValues = parent.GlobalTokenValues; + globalResources = parent.GlobalResources; + palettes = parent.PresetColorPalettes; + globalBuilder = CreateBuilder(globalValues, palettes, input.Registry.GlobalTokens); + } + else + { + globalBuilder = CompileGlobalBuilder(input); + globalValues = TokenValueTable.Freeze(globalBuilder, input.Registry.GlobalTokens); + globalResources = BuildResourceMap(globalBuilder, input.Registry.GlobalTokens, globalValues); + palettes = FreezePresetPalettes(globalBuilder); + } + + return new GlobalCompileOutcome( + appearance, + reuseGlobal, + globalBuilder, + globalValues, + globalResources, + palettes); + } + private static bool CanReuseGlobal(ThemeCompileInput input, ThemeAppearance appearance) { var parent = input.ReusableParent; @@ -234,15 +247,91 @@ internal sealed class ThemeCompiler input.EffectiveConfig.GlobalTokens); } - private static IReadOnlyList CompileControls( + // 惰性编译:每槽位急切解析状态(FindControlConfig / IsControlChanged / CanReuseControl 廉价 + // 配置比较,实测 ~9.5ms),只推迟昂贵的 CompileControl(~62ms,87 个控件 1032 个派生公式)。 + // 空窗口首帧前对控件快照的查询次数为零——急切预编译换零查询是纯启动损耗。 + private static LazyControlSnapshots CompileControls( ThemeCompileInput input, - ThemeAppearance globalAppearance, - DesignToken globalBuilder, - TokenValueTable globalValues, - IReadOnlyDictionary globalResources, - bool reuseGlobal, + GlobalCompileOutcome global, ControlCompilationCache? controlCompilationCache) { + var count = input.Registry.Controls.Count; + var states = new ControlSlotState[count]; + var preMaterialized = new ControlThemeSnapshot?[count]; + var configs = new NormalizedControlThemeConfig?[count]; + var configIndex = 0; + var parentConfigIndex = 0; + var changedControlIndex = 0; + var hasUncompiled = false; + for (var slot = 0; slot < count; slot++) + { + var descriptor = input.Registry.Controls[slot]; + var config = FindControlConfig( + input.EffectiveConfig.Controls, + descriptor.Identity, + ref configIndex); + var parentConfig = input.ReusableParent is null + ? null + : FindControlConfig( + input.ReusableParent.EffectiveConfig.Controls, + descriptor.Identity, + ref parentConfigIndex); + bool? controlChanged = input.ChangesFromReusableParent is null + ? null + : IsControlChanged( + input.ChangesFromReusableParent.ChangedControls, + descriptor.Identity, + ref changedControlIndex); + if (CanReuseControl( + input.ReusableParent, + descriptor, + config, + parentConfig, + global.ReuseGlobal, + controlChanged)) + { + // 主题切换复用:切换期零编译;首访时物化父槽并共享同一对象。 + states[slot] = ControlSlotState.ReuseParent; + continue; + } + if (config is null && !descriptor.HasOwnTokens) + { + states[slot] = ControlSlotState.Materialized; + preMaterialized[slot] = new ControlThemeSnapshot( + descriptor.Slot, + global.Appearance, + FrozenDictionary.Empty, + FrozenDictionary.Empty, + TokenValueTable.Empty, + FrozenDictionary.Empty); + continue; + } + + states[slot] = ControlSlotState.Uncompiled; + configs[slot] = config; + hasUncompiled = true; + } + + var source = hasUncompiled + ? new LazyControlSnapshotSource( + input, + configs, + global.Appearance, + global.Builder, + global.Values, + global.Resources, + controlCompilationCache) + : null; + return new LazyControlSnapshots(states, preMaterialized, input.ReusableParent, source); + } + + // 仅供差分测试的急切参考路径:与惰性 CompileControls 在相同输入下逐位等价(同一 CompileControl、 + // 同一缓存键)。生产编译一律走惰性路径;保留本入口用于等价性验证与未来回归对照。 + internal IReadOnlyList CompileControlsEagerForTest( + ThemeCompileInput input, + ControlCompilationCache? controlCompilationCache = null) + { + var global = CompileGlobal(input); var controls = new ControlThemeSnapshot[input.Registry.Controls.Count]; GlobalCompilationCacheKey? globalCompilationCacheKey = null; var configIndex = 0; @@ -272,9 +361,10 @@ internal sealed class ThemeCompiler descriptor, config, parentConfig, - reuseGlobal, + global.ReuseGlobal, controlChanged)) { + // 惰性父快照在此物化对应槽位,与惰性子快照共享同一对象,差分语义不变。 controls[slot] = input.ReusableParent!.Controls[slot]; continue; } @@ -282,7 +372,7 @@ internal sealed class ThemeCompiler { controls[slot] = new ControlThemeSnapshot( descriptor.Slot, - globalAppearance, + global.Appearance, FrozenDictionary.Empty, FrozenDictionary.Empty, TokenValueTable.Empty, @@ -296,10 +386,10 @@ internal sealed class ThemeCompiler descriptor, config, input.EffectiveConfig.Algorithms, - globalAppearance, - globalBuilder, - globalValues, - globalResources, + global.Appearance, + global.Builder, + global.Values, + global.Resources, input.Registry.GlobalTokens); } @@ -309,7 +399,7 @@ internal sealed class ThemeCompiler globalCompilationCacheKey ??= GlobalCompilationCacheKey.Create(input), descriptor, config, - globalAppearance, + global.Appearance, CompileCurrentControl); } @@ -351,7 +441,9 @@ internal sealed class ThemeCompiler (config is null ? parentConfig is null : config.Equals(parentConfig)); } - private static ControlThemeSnapshot CompileControl( + // private → internal:LazyControlSnapshotSource 在首访物化时直调本函数(同一编译路径, + // 惰性与急切逐位等价的依据)。仅程序集内与 IVT 测试项目可见。 + internal static ControlThemeSnapshot CompileControl( ControlTokenDescriptor descriptor, NormalizedControlThemeConfig? config, IReadOnlyList globalAlgorithms, diff --git a/src/AtomUI.Core/Theme/Compilation/ThemeRetainedBytesEstimator.cs b/src/AtomUI.Core/Theme/Compilation/ThemeRetainedBytesEstimator.cs index be4d52d89..b7600675e 100644 --- a/src/AtomUI.Core/Theme/Compilation/ThemeRetainedBytesEstimator.cs +++ b/src/AtomUI.Core/Theme/Compilation/ThemeRetainedBytesEstimator.cs @@ -30,7 +30,7 @@ internal static class ThemeRetainedBytesEstimator return retainedBytes; } - internal static long EstimateSnapshot(ThemeSnapshot snapshot) + internal static long EstimateSnapshotBase(ThemeSnapshot snapshot) { var retainedBytes = Add(ObjectOverhead, EstimateValue(snapshot.ThemeId)); retainedBytes = Add(retainedBytes, EstimateValue(snapshot.DefinitionRevision.SourceIdentity)); @@ -39,12 +39,10 @@ internal static class ThemeRetainedBytesEstimator retainedBytes = Add(retainedBytes, snapshot.GlobalTokenValues.EstimatedRetainedBytes); retainedBytes = Add(retainedBytes, EstimateResourceMap(snapshot.GlobalResources)); retainedBytes = Add(retainedBytes, EstimatePalettes(snapshot.PresetColorPalettes)); + // 控件部分 = 槽位表开销 + 首访物化增量(LazyControlSnapshots.MaterializedBytes), + // 由 ThemeSnapshot.EstimatedRetainedBytes 实时求和;此处不枚举槽位——枚举即全量物化, + // 正是惰性化要避免的形态。 retainedBytes = Add(retainedBytes, ObjectOverhead + snapshot.Controls.Count * ReferenceBytes); - foreach (var control in snapshot.Controls) - { - retainedBytes = Add(retainedBytes, control.EstimatedRetainedBytes); - } - return retainedBytes; } @@ -92,7 +90,9 @@ internal static class ThemeRetainedBytesEstimator }; } - private static long Add(long left, long right) + // internal:ThemeSnapshot.EstimatedRetainedBytes 与 LazyControlSnapshots 的增量累计共用 + // 同一饱和语义(long.MaxValue 封顶),负值不允许进入 BoundedLruCache 尺寸记账。 + internal static long Add(long left, long right) { if (right < 0 || left > long.MaxValue - right) { diff --git a/src/AtomUI.Core/Theme/Compilation/ThemeSnapshot.cs b/src/AtomUI.Core/Theme/Compilation/ThemeSnapshot.cs index 3b66d9287..53cdd4bbe 100644 --- a/src/AtomUI.Core/Theme/Compilation/ThemeSnapshot.cs +++ b/src/AtomUI.Core/Theme/Compilation/ThemeSnapshot.cs @@ -19,7 +19,7 @@ internal sealed class ThemeSnapshot TokenValueTable globalTokenValues, IReadOnlyDictionary globalResources, IReadOnlyDictionary presetColorPalettes, - IReadOnlyList controlSlots) + LazyControlSnapshots controlSlots) { ThemeId = themeId; Definition = definition; @@ -33,7 +33,11 @@ internal sealed class ThemeSnapshot GlobalResources = globalResources; PresetColorPalettes = presetColorPalettes; Controls = controlSlots; - EstimatedRetainedBytes = ThemeRetainedBytesEstimator.EstimateSnapshot(this); + // 全局部分 + 槽位表开销在构造期冻结;控件快照字节随首访物化增量累计。 + // 已知取舍(设计文档 §4):ThemeSnapshotCache / ControlCompilationCache 在插入时采样本值, + // 物化增长不回灌——估算是启发式容量阀,漂移上界 = 实消费槽位字节(≤187 槽位 × 单槽 KB 级), + // 驱逐只影响性能不影响正确性。 + _estimatedRetainedBytesBase = ThemeRetainedBytesEstimator.EstimateSnapshotBase(this); } internal string ThemeId { get; } @@ -47,6 +51,9 @@ internal sealed class ThemeSnapshot internal TokenValueTable GlobalTokenValues { get; } internal IReadOnlyDictionary GlobalResources { get; } internal IReadOnlyDictionary PresetColorPalettes { get; } - internal IReadOnlyList Controls { get; } - internal long EstimatedRetainedBytes { get; } + internal LazyControlSnapshots Controls { get; } + private readonly long _estimatedRetainedBytesBase; + + internal long EstimatedRetainedBytes => + ThemeRetainedBytesEstimator.Add(_estimatedRetainedBytesBase, Controls.MaterializedBytes); } diff --git a/tests/AtomUI.Core.Tests/Theme/DenseThemeCompilerTests.cs b/tests/AtomUI.Core.Tests/Theme/DenseThemeCompilerTests.cs index c7abe0f18..c5e4100a6 100644 --- a/tests/AtomUI.Core.Tests/Theme/DenseThemeCompilerTests.cs +++ b/tests/AtomUI.Core.Tests/Theme/DenseThemeCompilerTests.cs @@ -233,7 +233,10 @@ public class DenseThemeCompilerTests result.Success.ShouldBeTrue(); resourceProjections.ShouldBe(1); - var controlSnapshot = result.Snapshot!.Controls.ShouldHaveSingleItem(); + // Controls 为按槽位惰性容器(不实现枚举),单槽位语义用 Count + 索引表达。 + var controls = result.Snapshot!.Controls; + controls.Count.ShouldBe(1); + var controlSnapshot = controls[0]; controlSnapshot.EffectiveGlobalTokenDelta.ShouldBeEmpty(); controlSnapshot.EffectiveGlobalResourceDelta.ShouldBeEmpty(); controlSnapshot.ControlTokenValues.ShouldBeSameAs(TokenValueTable.Empty); @@ -253,7 +256,9 @@ public class DenseThemeCompilerTests result.Success.ShouldBeTrue(); resourceProjections.ShouldBe(1); - var controlSnapshot = result.Snapshot!.Controls.ShouldHaveSingleItem(); + var controls = result.Snapshot!.Controls; + controls.Count.ShouldBe(1); + var controlSnapshot = controls[0]; controlSnapshot.EffectiveGlobalTokenDelta.ShouldBeEmpty(); controlSnapshot.EffectiveGlobalResourceDelta.ShouldBeEmpty(); controlSnapshot.ControlTokenValues.Count.ShouldBe(1); @@ -348,10 +353,14 @@ public class DenseThemeCompilerTests reusableParent: parent, changeSet: changeSet)).Snapshot!; - buttonEvaluations.ShouldBe(2); - inputEvaluations.ShouldBe(1); child.Controls[button.Slot].ShouldNotBeSameAs(parent.Controls[button.Slot]); child.Controls[input.Slot].ShouldBeSameAs(parent.Controls[input.Slot]); + + // 惰性契约(runtime.md §7 控件槽位编译契约):评估发生在首访而非编译期。 + // 首访后 button 被父子两次独立求值(2),input 复用父对象只求值一次(1)—— + // "changeSet 只重算变更控件"的不变量保持不变。 + buttonEvaluations.ShouldBe(2); + inputEvaluations.ShouldBe(1); } [Fact]