chore: add TinyTUI benchmark pressure scenarios
- add benchmark project for rendering text overlay markdown and image-line paths - document benchmark usage in README - include benchmark project in solution
This commit is contained in:
@@ -94,6 +94,16 @@ dotnet pack src/TinyTUI/TinyTUI.csproj --configuration Release --no-build
|
||||
|
||||
`test/TinyTUI.Tests` 是正式 xUnit 回归测试项目。`test/TinyTUI.TextChecks` 和 `test/TinyTUI.ComponentChecks` 是早期最小 smoke check,后续会逐步收敛到正式测试项目。
|
||||
|
||||
## 压力示例
|
||||
|
||||
仓库提供不依赖真实终端交互的 benchmark/pressure console 项目,用于观察渲染管线在典型高压输入下是否稳定。
|
||||
|
||||
```powershell
|
||||
dotnet run --project benchmark/TinyTUI.Benchmarks/TinyTUI.Benchmarks.csproj --configuration Release
|
||||
```
|
||||
|
||||
当前场景覆盖大文本测量和截断、Markdown render、overlay compose、差分渲染频繁刷新,以及 Kitty/iTerm2 图像行检测保护。该项目会写出每个场景的耗时、分配量和关键计数,只作为本地观察入口,不替代 xUnit 回归测试,也不作为默认 CI test gate。
|
||||
|
||||
## 调试
|
||||
|
||||
渲染器支持环境变量调试日志:
|
||||
|
||||
@@ -744,18 +744,24 @@ SettingsList 后续仍需补齐:
|
||||
- `TinyTUI.csproj` 增加 NuGet 包元数据、tags、README 打包、XML documentation、symbol package 和包输出目录,为 `dotnet pack` 产出可发布包做准备
|
||||
- 新增 GitHub Actions CI,按 restore、Release build、format verify、test、pack 的顺序覆盖工程化闭环
|
||||
- 新增 `docs/public-api.md`,把应用侧稳定入口、推荐组合、组件约束和仍在演进的公开边界写清楚,避免后续重构误伤使用者依赖
|
||||
- 新增 `benchmark/TinyTUI.Benchmarks` console 压力示例项目,并加入 `ttui.slnx`,覆盖大文本测量/截断、Markdown render、overlay compose、差分渲染频繁刷新和图像行检测保护
|
||||
- README 增加压力示例运行命令和场景说明,明确它是本地观察入口,不替代 xUnit,也不作为默认 CI test gate
|
||||
|
||||
为什么先做:
|
||||
|
||||
- 对比 `tmp/tui/README.md` 和 `package.json`,参考实现已经能让使用者快速判断“怎么安装、怎么跑、有哪些 API、怎么测试和发布”;C# 侧 README 和包元数据为空或缺失,会阻塞集成和 NuGet 发布
|
||||
- 第 9 项已经有正式 xUnit 项目,先接 CI 和 pack 可以把测试基础设施转成持续验证能力,后续每个框架增量都有自动守门
|
||||
- 公开 API 边界先用文档声明,不急着重命名或收缩源码命名空间,避免在第 10 项里引入运行时行为变更
|
||||
- benchmark 先选择无真实终端交互的内存输出,可以稳定覆盖参考实现 changelog 和源码里反复出现的性能风险:宽度测量缓存、Markdown 渲染缓存、overlay 按列合成、频繁 diff patch、image line 不进入普通文本测宽
|
||||
|
||||
当前更好的点:
|
||||
|
||||
- C# 侧把 XML documentation 和 artifacts 输出放在根级 `Directory.Build.props`,类库、测试和示例的构建产物路径一致,CI 和本地命令更容易对齐
|
||||
- NuGet README 直接复用仓库根 README,包页面和源码文档不会维护两套快速开始
|
||||
- CI 同时跑 `dotnet format --verify-no-changes` 和 `dotnet pack`,比只跑测试更早暴露工程文件、包元数据和格式问题
|
||||
- 压力示例直接复用 `ITerminalOutput` 抽象和 `DifferentialRenderer`,不会为了 benchmark 绕过真实渲染路径;C# 的接口边界让同一份场景能在本地稳定统计写入次数、输出字节和 redraw 计数
|
||||
- 图像行保护场景启用 `ThrowOnWidthOverflow`,能观察大段 Kitty payload 是否被 `RenderFrameBuilder` 按 image line 跳过普通测宽和截断,比只调用 `IsImageLine` 更贴近真实渲染风险
|
||||
- benchmark 项目加入 solution 参与 restore/build,但 README 明确不作为默认 CI test gate,避免机器性能波动影响回归测试稳定性
|
||||
|
||||
后续仍需补齐:
|
||||
|
||||
@@ -763,7 +769,9 @@ SettingsList 后续仍需补齐:
|
||||
- `PackageProjectUrl` 和 `RepositoryUrl` 先使用占位仓库地址,真实发布前需要替换成实际远端
|
||||
- XML documentation 已启用,但大量 public API 还缺中文 summary,后续需要分模块补齐并决定是否把 CS1591 提升为 CI 约束
|
||||
- CI 已提供 `dotnet format`,但当前仓库还没有 `.editorconfig`,格式规则仍主要依赖 SDK 默认值,后续应补编码、缩进、nullable 和 analyzer 策略
|
||||
- benchmark 目录仍未建立,后续需要补大文本、频繁刷新、overlay 合成、Markdown 缓存和图像行保护的压力场景
|
||||
- benchmark 当前是可读压力示例,不是统计严格的微基准;后续如果要比较版本间性能,需要固定 warmup、迭代次数、结果导出格式和基线阈值,或再引入 BenchmarkDotNet
|
||||
- Markdown 压力场景当前只能观察 C# 轻量逐行 render 成本,尚未具备参考实现 `Markdown` 的 AST 解析和缓存命中对照;后续补 Markdown 缓存时应把命中/失效计数加入输出
|
||||
- overlay 压力场景覆盖普通文本和 ANSI reset 隔离,但 overlay 遇到 image line 的行为仍是已知后续项,暂不在本轮 benchmark 中模拟真实图片区域覆盖
|
||||
- Example 仍是单个综合示例,尚未像参考实现 `test/chat-simple.ts` 那样拆出聊天、设置、图像 fallback、Markdown 浏览等真实场景
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using TinyTUI.Components;
|
||||
using TinyTUI.Overlay;
|
||||
using TinyTUI.Rendering;
|
||||
using TinyTUI.Stdout;
|
||||
using TinyTUI.Terminal.Images;
|
||||
using TinyTUI.Text;
|
||||
|
||||
namespace TinyTUI.Benchmarks;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private const int Width = 100;
|
||||
private const int Rows = 28;
|
||||
|
||||
private static readonly TerminalSize Size = new(Width, Rows);
|
||||
|
||||
private static int Main()
|
||||
{
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
var scenarios = new[]
|
||||
{
|
||||
RunLargeTextMeasureAndTruncate(),
|
||||
RunMarkdownRender(),
|
||||
RunOverlayCompose(),
|
||||
RunDifferentialRefresh(),
|
||||
RunImageLineGuard(),
|
||||
};
|
||||
|
||||
Console.WriteLine("TinyTUI benchmark pressure scenarios");
|
||||
Console.WriteLine($"runtime: {Environment.Version}");
|
||||
Console.WriteLine($"size: {Width}x{Rows}");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var scenario in scenarios)
|
||||
Console.WriteLine(scenario);
|
||||
|
||||
return scenarios.Any(static scenario => scenario.Failed) ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 压测 ANSI 宽度测量和截断路径
|
||||
/// </summary>
|
||||
private static ScenarioResult RunLargeTextMeasureAndTruncate()
|
||||
{
|
||||
var measurer = new TerminalTextMeasurer();
|
||||
var lines = BuildLargeTextLines(lineCount: 2_000);
|
||||
var totalWidth = 0;
|
||||
var truncatedWidth = 0;
|
||||
|
||||
return Measure("large text measure/truncate", lines.Count, () =>
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
totalWidth += measurer.GetWidth(line);
|
||||
truncatedWidth += measurer.GetWidth(measurer.Truncate(line, Width));
|
||||
}
|
||||
}, () => $"totalWidth={totalWidth}, truncatedWidth={truncatedWidth}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 压测 Markdown 组件的轻量转换和宽度截断路径
|
||||
/// </summary>
|
||||
private static ScenarioResult RunMarkdownRender()
|
||||
{
|
||||
var markdown = new Markdown(BuildMarkdownDocument(sectionCount: 240));
|
||||
var renderedLineCount = 0;
|
||||
var checksum = 0;
|
||||
|
||||
return Measure("markdown render", 60, () =>
|
||||
{
|
||||
for (var iteration = 0; iteration < 60; iteration++)
|
||||
{
|
||||
var lines = markdown.Render(Width);
|
||||
renderedLineCount = lines.Count;
|
||||
checksum += lines[iteration % lines.Count].Length;
|
||||
}
|
||||
}, () => $"lines={renderedLineCount}, checksum={checksum}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 压测 overlay 合成中的按列切片和 reset 隔离路径
|
||||
/// </summary>
|
||||
private static ScenarioResult RunOverlayCompose()
|
||||
{
|
||||
var manager = new OverlayManager();
|
||||
var baseLines = BuildLargeTextLines(lineCount: Rows + 40);
|
||||
manager.Show(
|
||||
new StaticComponent(BuildOverlayLines()),
|
||||
new OverlayOptions
|
||||
{
|
||||
Anchor = OverlayAnchor.TopRight,
|
||||
Width = OverlaySize.Columns(42),
|
||||
MaxHeight = OverlaySize.Columns(10),
|
||||
Margin = new OverlayMargin(1),
|
||||
});
|
||||
|
||||
var composedLineCount = 0;
|
||||
var sampleWidth = 0;
|
||||
|
||||
return Measure("overlay compose", 800, () =>
|
||||
{
|
||||
for (var iteration = 0; iteration < 800; iteration++)
|
||||
{
|
||||
var composed = manager.Compose(baseLines, Size);
|
||||
composedLineCount = composed.Count;
|
||||
sampleWidth += composed[iteration % composed.Count].Length;
|
||||
}
|
||||
}, () => $"lines={composedLineCount}, sampledLength={sampleWidth}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 压测差分渲染频繁刷新但只写内存输出
|
||||
/// </summary>
|
||||
private static ScenarioResult RunDifferentialRefresh()
|
||||
{
|
||||
var output = new MemoryTerminalOutput();
|
||||
var renderer = new DifferentialRenderer(
|
||||
output,
|
||||
new TerminalTextMeasurer(),
|
||||
new RenderPipelineOptions { UseSynchronizedOutput = false });
|
||||
var lines = BuildLargeTextLines(lineCount: Rows);
|
||||
|
||||
return Measure("differential refresh", 1_000, () =>
|
||||
{
|
||||
for (var frame = 0; frame < 1_000; frame++)
|
||||
{
|
||||
lines[Rows / 2] = $"tick={frame:0000} Δ refresh path \e[36mchanged\e[39m";
|
||||
renderer.Render(lines, Size);
|
||||
}
|
||||
}, () => $"full={renderer.FullRedrawCount}, diff={renderer.DifferentialRedrawCount}, writes={output.WriteCount}, bytes={output.WrittenBytes}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 压测图像行检测保护 避免大段协议内容进入普通测宽截断路径
|
||||
/// </summary>
|
||||
private static ScenarioResult RunImageLineGuard()
|
||||
{
|
||||
var output = new MemoryTerminalOutput();
|
||||
var renderer = new DifferentialRenderer(
|
||||
output,
|
||||
new TerminalTextMeasurer(),
|
||||
new RenderPipelineOptions
|
||||
{
|
||||
UseSynchronizedOutput = false,
|
||||
ThrowOnWidthOverflow = true,
|
||||
});
|
||||
var imageLine = BuildKittyImageLine();
|
||||
var guarded = false;
|
||||
var ids = 0;
|
||||
|
||||
return Measure("image line guard", 120, () =>
|
||||
{
|
||||
for (var iteration = 0; iteration < 120; iteration++)
|
||||
{
|
||||
guarded |= TerminalImageService.IsImageLine(imageLine);
|
||||
ids += TerminalImageService.ExtractKittyImageIds(imageLine).Count();
|
||||
renderer.Render([$"frame={iteration}", imageLine, "after-image"], new TerminalSize(32, 6));
|
||||
}
|
||||
}, () => $"guarded={guarded}, extractedIds={ids}, bytes={output.WrittenBytes}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一执行场景并把异常转换为可读输出
|
||||
/// </summary>
|
||||
private static ScenarioResult Measure(string name, int operations, Action action, Func<string> getDetails)
|
||||
{
|
||||
try
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var before = GC.GetTotalAllocatedBytes(precise: true);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
action();
|
||||
stopwatch.Stop();
|
||||
var allocated = GC.GetTotalAllocatedBytes(precise: true) - before;
|
||||
|
||||
return ScenarioResult.Passed(name, operations, stopwatch.Elapsed, allocated, getDetails());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return ScenarioResult.FailedResult(name, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造混合 ANSI 中文 emoji 和 tab 的长文本输入
|
||||
/// </summary>
|
||||
private static List<string> BuildLargeTextLines(int lineCount)
|
||||
{
|
||||
var lines = new List<string>(lineCount);
|
||||
|
||||
for (var index = 0; index < lineCount; index++)
|
||||
{
|
||||
// 混合不可见 ANSI 和宽字符 让测宽路径比 ASCII-only 更接近真实 TUI 输出
|
||||
lines.Add($"\e[3{index % 7 + 1}mrow-{index:0000}\e[39m\t中文列 {index % 11} emoji 😀 link \e]8;;https://example.local/{index}\aword-{index}\e]8;;\a trailing text");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造重复 Markdown 文档用于观察轻量渲染成本
|
||||
/// </summary>
|
||||
private static string BuildMarkdownDocument(int sectionCount)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
for (var section = 0; section < sectionCount; section++)
|
||||
{
|
||||
builder.AppendLine($"## Section {section}");
|
||||
builder.AppendLine("- **item** with `code` and 中文内容");
|
||||
builder.AppendLine("> quoted line with wide emoji 😀");
|
||||
builder.AppendLine("plain paragraph that should be truncated by the component renderer when the viewport is narrow");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造 overlay 内容并包含 ANSI 样式用于观察 reset 隔离
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> BuildOverlayLines() =>
|
||||
[
|
||||
"\e[1mBenchmark Overlay\e[22m",
|
||||
"status: \e[32mrunning\e[39m",
|
||||
"wide: 中文 😀 boundary",
|
||||
"short",
|
||||
"long value should be clipped at the overlay boundary",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 构造足够长的 Kitty 图像序列 模拟协议行保护场景
|
||||
/// </summary>
|
||||
private static string BuildKittyImageLine()
|
||||
{
|
||||
var payload = Convert.ToBase64String(Enumerable.Range(0, 24_000).Select(static value => (byte)(value % 251)).ToArray());
|
||||
return $"prefix \e_Ga=T,f=100,i=4242,q=2,c=20,r=8;{payload}\e\\ suffix";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ScenarioResult(string Name, int Operations, TimeSpan Elapsed, long AllocatedBytes, string Details, Exception? Exception)
|
||||
{
|
||||
public bool Failed => Exception is not null;
|
||||
|
||||
public static ScenarioResult Passed(string name, int operations, TimeSpan elapsed, long allocatedBytes, string details) =>
|
||||
new(name, operations, elapsed, allocatedBytes, details, null);
|
||||
|
||||
public static ScenarioResult FailedResult(string name, Exception exception) =>
|
||||
new(name, 0, TimeSpan.Zero, 0, string.Empty, exception);
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (Exception is not null)
|
||||
return $"[fail] {Name}: {Exception.GetType().Name}: {Exception.Message}";
|
||||
|
||||
var perOperation = Operations <= 0 ? 0 : Elapsed.TotalMilliseconds / Operations;
|
||||
return $"[pass] {Name}: {Operations} ops, {Elapsed.TotalMilliseconds:N1} ms, {perOperation:N3} ms/op, {AllocatedBytes / 1024.0:N1} KiB allocated, {Details}";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StaticComponent(IReadOnlyList<string> lines) : IComponent
|
||||
{
|
||||
public IReadOnlyList<string> Render(int width) => lines;
|
||||
}
|
||||
|
||||
internal sealed class MemoryTerminalOutput : ITerminalOutput
|
||||
{
|
||||
public int WriteCount { get; private set; }
|
||||
|
||||
public long WrittenBytes { get; private set; }
|
||||
|
||||
public void Write(string value)
|
||||
{
|
||||
WriteCount++;
|
||||
WrittenBytes += Encoding.UTF8.GetByteCount(value);
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public void ClearScreen() => Write("\e[2J\e[H");
|
||||
|
||||
public void HideCursor() => Write("\e[?25l");
|
||||
|
||||
public void ShowCursor() => Write("\e[?25h");
|
||||
|
||||
public void MoveCursorTo(int row, int column) => Write($"\e[{row};{column}H");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\TinyTUI\TinyTUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -9,6 +9,7 @@
|
||||
</Folder>
|
||||
<Project Path="src/Example/Example.csproj" Id="6656c1ec-b220-4e7a-8875-5a97a85afbc1" />
|
||||
<Project Path="src/TinyTUI/TinyTUI.csproj" Id="f39199aa-6947-4951-b952-a1588045505e" />
|
||||
<Project Path="benchmark/TinyTUI.Benchmarks/TinyTUI.Benchmarks.csproj" Id="f6b9b8d7-44aa-4786-a15d-5d341a4b77f5" />
|
||||
<Project Path="test/TinyTUI.ComponentChecks/TinyTUI.ComponentChecks.csproj" Id="95c72f97-61f0-4806-8a90-28d70a89cd87" />
|
||||
<Project Path="test/TinyTUI.Tests/TinyTUI.Tests.csproj" Id="9b90a587-33b5-4c51-8d18-b1c87ce239d0" />
|
||||
<Project Path="test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj" Id="d827983c-78a1-4f04-85f3-874ccfe2e735" />
|
||||
|
||||
Reference in New Issue
Block a user