Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/core-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ jobs:
PYTHONUTF8: '1'
TERMFORGE_ALACRITTY_TRACE: ${{ github.workspace }}/${{ matrix.binary }}
TERMFORGE_TRACE: ${{ github.workspace }}/${{ matrix.core_binary }}
TERMFORGE_R54_WRAPPED: ${{ github.workspace }}/experiments/candidates/alacritty/target/release/alacritty-measure${{ matrix.os == 'windows-2025' && '.exe' || '' }}
TERMFORGE_R54_UNWRAPPED: ${{ github.workspace }}/build/measure-no-allocator/release/alacritty-measure${{ matrix.os == 'windows-2025' && '.exe' || '' }}
TERMFORGE_TEST_ARTIFACTS: ${{ github.workspace }}/build/test-artifacts
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -68,6 +70,9 @@ jobs:
- name: Build candidate measurement
working-directory: experiments/candidates/alacritty
run: cargo build --release --locked --bin alacritty-measure
- name: Build candidate measurement without allocator wrapper
working-directory: experiments/candidates/alacritty
run: cargo build --release --locked --no-default-features --bin alacritty-measure --target-dir "${{ github.workspace }}/build/measure-no-allocator"
- name: Configure native trace on Windows
if: runner.os == 'Windows'
run: cmake -S . -B build -A x64 -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=ON
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ if(BUILD_TESTING)
endif()
termforge_add_python_test(core -m unittest experiments.core.test_core experiments.core.test_unicode -v)
termforge_add_python_test(recovery -m unittest experiments.core.test_recovery -v)
termforge_add_python_test(measure -m unittest experiments.measure.test_measure -v)
termforge_add_python_test(measure -m unittest experiments.measure.test_measure experiments.measure.test_r54_contract.CostPlanTests -v)
termforge_add_python_test(binary_paths -m unittest experiments.test_build_paths -v)
termforge_add_python_test(benchmark_tools -m unittest discover -s benchmarks/tests -v)
termforge_add_python_test(differential -m unittest discover -s experiments/differential -p test_*.py -v)
Expand Down
6 changes: 6 additions & 0 deletions experiments/candidates/alacritty/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ publish = false
[workspace]
members = ["vendor/alacritty_terminal"]

[features]
# Preserve historical diagnostics by default. A separate --no-default-features
# build of alacritty-measure removes the allocator wrapper at compile time.
default = ["measure-allocation-counting"]
measure-allocation-counting = []

[dependencies]
alacritty_terminal = { path = "vendor/alacritty_terminal", default-features = false }
serde_json = "1"
Expand Down
88 changes: 62 additions & 26 deletions experiments/candidates/alacritty/src/measure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,66 @@
#[path = "main.rs"]
mod trace;

use std::alloc::{GlobalAlloc, Layout, System};
use std::env;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Instant;

use alacritty_terminal::term::{Config, Term};
use alacritty_terminal::vte::ansi::Processor;
use serde_json::{Value, json};
use trace::{Listener, Size};

struct CountingAllocator;
static OBSERVE: AtomicBool = AtomicBool::new(false);
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
static ALLOCATED_BYTES: AtomicUsize = AtomicUsize::new(0);
#[cfg(feature = "measure-allocation-counting")]
mod allocation {
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if OBSERVE.load(Ordering::Relaxed) {
ALLOCATIONS.fetch_add(1, Ordering::Relaxed);
ALLOCATED_BYTES.fetch_add(layout.size(), Ordering::Relaxed);
struct CountingAllocator;
static OBSERVE: AtomicBool = AtomicBool::new(false);
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
static ALLOCATED_BYTES: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if OBSERVE.load(Ordering::Relaxed) {
ALLOCATIONS.fetch_add(1, Ordering::Relaxed);
ALLOCATED_BYTES.fetch_add(layout.size(), Ordering::Relaxed);
}
unsafe { System.alloc(layout) }
}

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
unsafe { System.alloc(layout) }
}

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;

pub fn start(observe: bool) {
ALLOCATIONS.store(0, Ordering::Relaxed);
ALLOCATED_BYTES.store(0, Ordering::Relaxed);
OBSERVE.store(observe, Ordering::Relaxed);
}

pub fn stop() { OBSERVE.store(false, Ordering::Relaxed); }

pub fn counts(observe: bool) -> (Option<usize>, Option<usize>) {
if observe {
(Some(ALLOCATIONS.load(Ordering::Relaxed)), Some(ALLOCATED_BYTES.load(Ordering::Relaxed)))
} else { (None, None) }
}
}

#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
#[cfg(not(feature = "measure-allocation-counting"))]
mod allocation {
// No #[global_allocator], atomic counters or allocator forwarding path is
// compiled in this mode. Projection remains independently selectable.
pub fn start(_: bool) {}
pub fn stop() {}
pub fn counts(_: bool) -> (Option<usize>, Option<usize>) { (None, None) }
}

fn allocator_wrapper_compiled() -> bool { cfg!(feature = "measure-allocation-counting") }

#[cfg(windows)]
mod platform {
Expand Down Expand Up @@ -201,6 +230,15 @@ fn duration_arg(value: &str) -> Result<f64, String> {

fn run() -> Result<(), String> {
let args: Vec<String> = env::args().collect();
if args.len() == 2 && args[1] == "--identity" {
println!("{}", json!({
"schema": "termforge-core-cost-build-v1",
"allocator_wrapper_compiled": allocator_wrapper_compiled(),
"timing_gates_compiled": true,
"projection_selectable": true,
}));
return Ok(());
}
if args.len() != 5 || (args[1] != "project" && args[1] != "interactive") {
return Err("alacritty-measure project|interactive chunk observe(0|1) min_cpu_seconds".into());
}
Expand Down Expand Up @@ -235,9 +273,7 @@ fn run() -> Result<(), String> {
}
drop(warmup);
let preparation = oracle_started.elapsed().as_secs_f64();
ALLOCATIONS.store(0, Ordering::Relaxed);
ALLOCATED_BYTES.store(0, Ordering::Relaxed);
OBSERVE.store(observe, Ordering::Relaxed);
allocation::start(observe);
let mut term: Option<Term<Listener>> = None;
let interval = measure(duration, || {
term = Some(new_term());
Expand All @@ -254,15 +290,14 @@ fn run() -> Result<(), String> {
}
Ok(())
})?;
OBSERVE.store(false, Ordering::Relaxed);
allocation::stop();
let checks = Instant::now();
let (final_hash, final_value) = projection(term.as_mut().unwrap(), &listener)?;
if final_hash != expected_hash || !correct(&final_value, route) {
return Err("final state oracle failed".into());
}
let oracle_seconds = checks.elapsed().as_secs_f64();
let allocations = ALLOCATIONS.load(Ordering::Relaxed);
let allocated_bytes = ALLOCATED_BYTES.load(Ordering::Relaxed);
let (allocations, allocated_bytes) = allocation::counts(observe);
let peak = platform::peak_working_set()?;
let result = json!({
"schema_version": 2,
Expand All @@ -287,9 +322,10 @@ fn run() -> Result<(), String> {
"final_oracle_seconds": oracle_seconds,
"oracle_scope": if observe { "warmup and final; every measured projection also hashed" } else { "warmup and final iteration" },
"state_fnv1a64": expected_hash.to_string(),
"ordinary_allocations": if observe { Some(allocations) } else { None },
"ordinary_allocated_bytes": if observe { Some(allocated_bytes) } else { None },
"allocation_scope": "Rust global allocator requests; not equivalent to C++ new or live memory",
"ordinary_allocations": allocations,
"ordinary_allocated_bytes": allocated_bytes,
"allocator_wrapper_compiled": allocator_wrapper_compiled(),
"allocation_scope": if allocator_wrapper_compiled() { "Rust global allocator requests; not equivalent to C++ new or live memory" } else { "allocator wrapper absent at compile time; allocation metrics unknown" },
"peak_working_set_bytes": peak,
"cpu_interval_differences": 1,
"counted_parser_destructions": interval.iterations - 1,
Expand All @@ -303,7 +339,7 @@ fn run() -> Result<(), String> {

fn main() {
if let Err(error) = run() {
OBSERVE.store(false, Ordering::Relaxed);
allocation::stop();
eprintln!("alacritty measure failed: {error}");
std::process::exit(2);
}
Expand Down
62 changes: 62 additions & 0 deletions experiments/measure/R54-COST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# R2 流式字素成本:测量前合同 v1

本合同是 [#54](https://github.com/JavaZeroo/termforge/issues/54) 的测量准备,不是性能结果。机器可读来源为 [r54_contract.py](r54_contract.py);执行前生成独立计划,固定其提交、`protocol_sha256` 和 harness 文件哈希。计划故意保留 `builds=null`、`environment_evidence=null`:源码身份和二进制哈希不等于已证明二进制来自该源码。实际构建、环境与运行记录必须另附,不能以计划生成成功宣布 #54 完成。

## 固定比较对象

| 对象 | 核心源码 | trace 全对象摘要:project / interactive |
|---|---|---|
| 修前 `.3` | `65988f5f20f62294ff52fcac5b5b81e563f93f3b` | `11966868740884721388` / `8035857386658397897` |
| 修后 `.4` | `3d6d44d3b3e9a9a3e41da98e8711e9f19a211fd8` | `16178973312633948535` / `6154007251599136798` |

两份固定核心使用**同一份已审核的测量 harness**;只把本合同提交的根 `Cargo.toml` 与 `src/measure.rs` 应用到相应候选目录,记录覆盖前后文件哈希与 diff。保留各自原有 `Cargo.lock`、`src/main.rs` 和整个 vendor 树;`.3` 没有新增计数器字段,因此全对象摘要不同,不能统一成一个摘要或略掉新字段。若覆盖产生这些文件之外的变化,停止并审查新配置,不把它算进 v1。

每份用 `cargo build --release --locked --bin alacritty-measure --no-default-features`,独立 target 目录;记录完整命令、退出、编译器/链接器和 Rust target/toolchain、构建环境变量、锁文件、完整源码树身份及 EXE SHA-256。记录 `--identity` 的实际 JSON,但该 JSON 只说明编译配置,不证明源码与二进制绑定。构建步骤的记录和独立哈希审计仍不可省。该对照是 headless `Term/Processor` 层,系统/官方 ConPTY 后端不会进入这些成本数值。

## allocator 和观察端点

`measure-allocation-counting` 是默认 Cargo feature,原命令继续编译历史 Rust global allocator 包装。`observe=0` 只关闭计数和逐轮投影,仍不等于无包装。新增 `--no-default-features` 模式在编译期完全排除 `#[global_allocator]`、atomic 计数器和转发路径;`--identity` 与每个结果都给出 `allocator_wrapper_compiled=false`。该模式的 `observe=1` 仍执行原本的每轮快照/JSON/完整哈希检查,但分配次数/字节均为 null。标准 allocator 的内部开销仍存在。

两个模式都保留区间计时门槛,所以 `counter_off_is_uninstrumented=false` 不改成 true。未执行真正产品级无测量探针对照前,不称为整体“无插桩”。C++ `core_measure` 的替换 new 仍在;本合同没有移除它,也不将 C++ 普通 new 和 Rust global allocator 请求视为等价指标。

为定位包装开销,另做同一固定核心上的 wrapper 开/关 × projection 开/关对照,记录独立二进制身份、次序、全部原始成本与正确性。该诊断不与主 A/B 样本合并,也不从主结果扣掉估算开销。

## 负载、区间与顺序

- project 为 1,000 次 `76*x + CR/LF`,78,000B;分块 512/4096/65536B。
- interactive 为 1,000 次 `abcdefgh`、三次左移、`Z`、退格、`Q`、CR/LF,22,000B;分块 1/16/64/4096/22000B。4096 保留原 12 样本的诊断粒度,其他边界继续覆盖。
- 两者屏幕 24×80、历史 100 行。计划写出完整字节 SHA-256;ASCII 成本负载不代表完整 Unicode 工作流。
- 每个样本至少 2 秒 CPU 且至少 2 秒墙钟,一个连续区间;Windows CPU 为 `GetProcessTimes` kernel+user 的单次结束减开始,Unix 为 `getrusage RUSAGE_SELF` user+system。保留 8 轮墙钟检查与至少约 250ms CPU 查询间距,内部 60 秒、子进程 90 秒期限不放宽。
- 区间含每轮构造、上一轮析构、feed/状态和门槛;输入准备、预热、最终 oracle、结果序列化与最后一次析构在区间外。预热和最终完整状态检查必须成功,中间轮次不逐轮检查。
- 主 A/B 固定 `observe=0` 且无 allocator 包装;两批独立开始的种子为 20261026 / 20261027,每个 route/chunk 各 7 对,单批 112 样本。组顺序冻结为计划中的完整数组;同组两个源码相邻,每轮交替 AB/BA,不根据结果改变次序。
- 112 来自 8 种块配置 × 7 对 × 2 源码,单批最低约 224 秒 CPU,而非长时间 soak。保留两次独立批次用于暴露宿主漂移,7 对便于同时报告配对中位数和离散度;这些选择不保证统计显著性。全块配置防止只在一个 4096B 粒度上推导普遍成本,本合同不自动展开更多负载或无限重跑。
- 记录 OS/build、CPU、电源策略、toolchain、flags、实际二进制、背景 CPU 与已知并行任务。构建和其他测试结束后才能启动正式批次;交错顺序不能证明宿主空闲,共享宿主始终注明限制。不同宿主/电源/编译环境不能无说明拼成同一批。

## 正确性、统计和预算程序

主统计为**同 repeat/route/chunk 配对的 candidate/baseline CPU ns/B 比值的中位数**;同时列出全部配对比值、每组样本 CPU 与墙钟 ns/B 的中位数、最小/最大值和中位绝对偏差。两批、各块大小分别报告;不混合 workload/块大小形成一个总分,也不以比值的中位数冒充两个独立中位数之比。样本量小,不自动生成有保证的置信区间或“显著”结论。

任何非零退出、超时、错误状态/负载/编译身份或缺失端点字段都保留原 stdout/stderr 与当前 manifest,将批次标失败并停止。不原地重跑、丢弃异常值、补替代样本或只保留最快批次。噪声或两批方向不一致时保留不确定性;下一次调查有新身份和明确假设,不能偷偷替换原批次。

**v1 尚无数值回归预算,`performance_gate=not_evaluated`。** 先用受控基线和开销对照了解成本/噪声,在单独可审查记录中说明用户交互与正确性理由、环境、可接受 project/interactive 限值及裁决规则,然后冻结预算;之后才能评估拟提交优化。不能看到优化成绩后把阈值改成刚好通过。#34 的产品决定仍需要真实 GUI/输入/并发/长期资源证据,本 headless 合同不能单独代替。

若优化 segmentation、宽度、Cell 保留、投影或分配,先有同一输入上可证伪的成本归因对照,不能只凭总吞吐推断某一层;完整控制边界、Unicode 17 语料、全部输入分块、实际 glyph、原文选择/复制及取消边界继续回归。接收/输入延迟/实际呈现端点仍为 null,不声明 WindTerm 等价。

原 [12 样本](../shell/results/2026-10-26-streaming-graphemes/cost/) 及其二进制/源码身份保持原样;共享宿主、allocator 包装和旧顺序都不同,只作为历史诊断,不能升格、覆盖或与 v1 合并统计。

## 自动入口和当前缺口

```powershell
# 不执行构建或负载;输出文件必须不存在,父目录先准备好。
python -m experiments.measure.r54_contract --out local/r54-plan-v1.json

# 纯计划、oracle、身份和失败负例检查;不占用正式计时环境。
python -m unittest experiments.measure.test_r54_contract.CostPlanTests -v

# 两个实际构建完成后才设置本进程环境,执行短 sanity;不是正式样本。
$env:TERMFORGE_R54_WRAPPED = '<default-feature alacritty-measure.exe>'
$env:TERMFORGE_R54_UNWRAPPED = '<no-default-feature alacritty-measure.exe>'
python -m unittest experiments.measure.test_r54_contract.OptionalAllocatorBuildSmoke -v
```

该批只交付准备合同与可完全移除包装的构建机制。两个实际 build manifest/EXE、真实空闲环境证据、冻结计划执行器和重复样本、成本归因、数值预算与优化验收仍由 #54 接续;生成计划或短 sanity 都不关闭该 issue。
2 changes: 2 additions & 0 deletions experiments/measure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

`core_measure` 使用原创固定输入与独立状态检查。v2 修正了 v1 将很多短轮次 CPU 差值相加的计量问题;`compare.py` 另提供自研/候选 project 与 interactive 成本诊断。解码、屏幕更新、历史仍未完整独立归因,不能据此判断 WindTerm 性能差距。R2 流式字素成本的受控定位与预算由 [#54](https://github.com/JavaZeroo/termforge/issues/54)承接,完整产品性能和跨目标基线仍按 [R4 工作包 #9](https://github.com/JavaZeroo/termforge/issues/9)验收。

#54 的[测量前合同 v1](R54-COST.md)与 `r54_contract.py` 固定 `.3`/`.4` 源码、负载、相邻配对顺序、统计和预算决定程序;新增 Rust `--no-default-features` 测量构建可在编译期排除 allocator 包装。该准备仍缺实际两构建身份、受控重复测量和数值预算,不改变下述历史结果或性能边界。

```powershell
# 先按 CONTRIBUTING.md 从仓库根目录构建。输出目录必须不存在。
python experiments/measure/run.py --bin-dir build/bin/Release --out local/experiments/core-cost-v2-a --cpu-seconds 2 --repeats 3 --seed 20261023
Expand Down
Loading
Loading