d930e46bd3
- add benchmark project for rendering text overlay markdown and image-line paths - document benchmark usage in README - include benchmark project in solution
295 lines
10 KiB
C#
295 lines
10 KiB
C#
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");
|
|
}
|