1 Commits

3 changed files with 159 additions and 1 deletions
+57
View File
@@ -0,0 +1,57 @@
# 场景交互性能研究
## 结论
性能问题有可验证的解决路径,但单改 Aster 字典不能让当前世界 UI 达到稳定 60 FPS。本次保留去除字符串键比较临时分配的小改动,交付可重复基准;面板纹理缓存实验因当前示例收益不足而撤回。没有实现 GPU 后端或宣称完成性能目标。
## 方法
在 Apple M4 Pro、macOS 15.6 本机使用 release 构建,固定 1024×800 逻辑视口、1× / 2× 像素比例,分别加载 world-ui 和 scene3d 示例。每组执行 33 次真实 scene-orbit 运行时分发,前 3 次作为预热,统计后 30 次的均值、P50、P95。每次先测分发到文档更新,再测变化后的软件绘制,最后测相同显示列表的缓存绘制。没有运行 Console 编译来代替原生事件。
```sh
cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
```
这是无窗口的运行时与软件渲染基准,包含回调、快照、布局和运行时收尾,但不包含本地 HTTP、OS 事件排队、窗口呈现或输入到显示延迟。阶段耗时不能简单当作实测 FPS。时间是本机样本,不是跨设备保证;基准默认 ignored,普通回归不以绝对耗时断言通过。
## 对照结果
世界 UI、2× 像素比例,单位 ms:
| 条件 | 更新均值 | 更新 P95 | 变化绘制均值 | 变化绘制 P95 |
| --- | ---: | ---: | ---: | ---: |
| 原始 Aster | 8.456 | 8.843 | 18.704 | 20.742 |
| 借用字符串键比较 | 6.842 | 7.226 | 18.598 | 20.665 |
| 再加面板纹理缓存实验 | 6.823 | 7.053 | 18.410 | 20.482 |
去掉字符串键比较的分配后,本组更新均值减少约 19%,绘制基本不变。面板缓存的绘制收益约 1%,不足以证明值得在本次增加缓存与失效复杂度,因此撤回。
作为对照,普通 scene3d 示例在 2× 下更新约 0.94 ms、变化绘制约 6.15 ms;它的视口尺寸、覆盖像素和 UI 复杂度不同,不能据此把两个示例的全部差值都归给某一层。
## Aster 发现与保留的修改
`EndorDictObject` 用插入有序的数组存储,`dict_position` 逐项调用 `key_equal`。原实现每次比较都会把内联文本或托管 String 转成新的 String;查找名称时会重复分配和复制候选键。
本次只改为借用字符串切片比较。保留线性查找、原 key_equal 分支、对象检查、只读依赖观察、键的插入身份与顺序、写入验证和引用计数流程。新增测试覆盖空文本、普通文本、Unicode、托管 / 内联字符串等价替换、删除和引用释放。
没有把通用字典直接改成 HashMap,也没有绕过 heap.replace_payload 的事务性边验证。前者还需处理托管键相等 / 哈希与顺序语义;后者涉及 GC、失败原子性和只读保护。它们不属于本次已经证明安全的改动。
## 尚未解决的主要成本
1. 世界 UI 的摄像机变化后,当前统计仍为 measured=8、arranged=88;普通 3D 为 measured=0、arranged=14。数据证明相机更新没有彻底绕开排版。
2. 3D 变化帧仍在窗口线程逐像素进行 CPU 绘制。当前世界 UI 的绘制本身已超过 60 Hz 的 16.7 ms 预算,其他 CPU 优化不能消除这个事实。
3. 原生连续输入仍逐项经本地 HTTP 发送,有界队列不等于按显示帧合并;真实排队和呈现延迟需要另外测量。
## 后续实施顺序与条件
1. **相机更新专用路径。** 复用已有 Mount 几何、UI 排版和绘制结果,仅更新视图 / 投影、可见性和命中映射。必须检查源 measure / arrange / paint 的实际读取依赖;源函数若读取相机,应继续正确失效。不能无条件跳过所有布局或绘制。验收:不依赖相机的 UI 在旋转时零测量、零重新排版,依赖相机的源协议回归仍通过。
2. **按帧收集连续输入。** 原生操作直接进入进程内通道,连续手势按捕获会话 / 目标与顺序合并;点击、页面切换和运行时替换保持边界。外部 HTTP API 保留严格版本校验。验收包括缩放锚点、非交换手势顺序、嵌套捕获与过期会话,不能只检查最终数值。
3. **GPU 合成与独立渲染。** 现有不可变网格、变换、相机、深度和 UV 数据可以成为渲染输入;VM 对象留在运行时线程。GPU 应直接向窗口表面呈现,同时合成 2D / 3D 内容,避免每帧 GPU→CPU 回读后再交给 softbuffer。跨线程队列只保留最新可呈现快照;纹理、缓冲与缓存均需资源预算和正确失效。
建议验收目标:固定示例 2× 下相机更新 P95 ≤2 ms、GPU 绘制 P95 ≤8 ms,另外记录真实窗口的帧间隔和输入到呈现延迟。目标尚未达到,也没有用无窗口基准宣称原生 60 FPS。
## 检查说明
Aster 全工作区测试发现 compiler 的 classes 测试中两项解析失败(第三行 expected EOF),以及 core 的 managed_coroutine_hard_budget_accumulates_and_error_releases_frames 指令预算失败。将对象实现恢复为未优化的基线后,这三项失败均能复现,说明不是本次借用字符串比较引入;不把全工作区标为全绿。对象层测试通过,Aster 全工作区 Clippy / 格式通过;GUI 为 186 项通过、1 项默认忽略的基准,GUI Clippy / 格式通过。该基准已经另行在 release 模式实际执行。
本次没有修改用户指向的 builtins.rs 第 176 行(该处是函数对象定义)。实际热路径修改位于 Aster 的 store_builtins.rs。
+101
View File
@@ -4,6 +4,107 @@ use crate::document::{NamespaceState, RenderCommand};
const EXAMPLE: &str = include_str!("../../../examples/hello.endor");
const PAGE: &str = include_str!("../../../examples/page.endor");
/// Opt-in release benchmark; run alone to avoid test-thread interference.
/// cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
#[test]
#[ignore]
fn scene_interaction_performance() {
use std::time::Instant;
fn summary(mut values: Vec<f64>) -> String {
values.sort_by(f64::total_cmp);
format!(
"mean={:.3} p50={:.3} p95={:.3}ms",
values.iter().sum::<f64>() / values.len() as f64,
values[values.len() / 2],
values[(values.len() * 95).div_ceil(100) - 1]
)
}
for (name, source) in [
("world-ui", include_str!("../../../examples/world-ui.endor")),
("scene3d", include_str!("../../../examples/scene3d.endor")),
] {
for dpi in [1., 2.] {
let (mut session, mut document) = ReplSession::load_with_viewport(
source,
"app.main",
1,
988,
Some(crate::layout::Size {
width: 1024.,
height: 800.,
}),
)
.unwrap();
let (width, height) = ((1024. * dpi) as u32, (800. * dpi) as u32);
let mut pixels = vec![0; (width * height) as usize];
let mut updates = Vec::new();
let mut draws = Vec::new();
let mut cached = Vec::new();
for sample in 0..33 {
let target = document
.frame
.mounts
.iter()
.find(|mount| mount.scene3d_viewport)
.unwrap();
let input = crate::events::Input {
window: 1,
session: document.frame.session,
layout_version: document.frame.layout_version,
mount: target.id,
kind: "scene-orbit".into(),
point: Some([target.x + target.width / 2., target.y + target.height / 2.]),
delta: Some([1., 0.]),
press_point: None,
tab_drop: None,
screen_position: None,
};
let start = Instant::now();
document = session.dispatch(&input).unwrap().document.unwrap();
let update = start.elapsed().as_secs_f64() * 1000.;
let list = document.display_list(dpi).unwrap();
let start = Instant::now();
crate::software_renderer::SoftwareRenderer::render(
&list,
&mut pixels,
width,
height,
)
.unwrap();
let draw = start.elapsed().as_secs_f64() * 1000.;
let start = Instant::now();
crate::software_renderer::SoftwareRenderer::render(
&list,
&mut pixels,
width,
height,
)
.unwrap();
if sample >= 3 {
updates.push(update);
draws.push(draw);
cached.push(start.elapsed().as_secs_f64() * 1000.);
}
}
println!(
"PERF {name} dpi={dpi} update {} changed_draw {} cached_draw {}",
summary(updates),
summary(draws),
summary(cached)
);
for stat in ["measured", "arranged", "snapshotVisited"] {
println!(
"PERFSTAT {name} {stat}={}",
session
.execute(&format!("ui.stats().{stat}"), "app.main")
.unwrap()
.value
);
}
}
}
}
#[test]
fn actual_mount_version_reads_still_bypass_measurement_cache() {
let (mut session, initial) = ReplSession::load(
+1 -1