Skip to content
Draft
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
2 changes: 2 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
<AnalysisLevel>latest</AnalysisLevel>
<VersionPrefix>0.3.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
<RoyalTerminalEnablePretextTextPipeline Condition="'$(RoyalTerminalEnablePretextTextPipeline)' == '' And '$(PublishAot)' == 'true'">false</RoyalTerminalEnablePretextTextPipeline>
<RoyalTerminalEnablePretextTextPipeline Condition="'$(RoyalTerminalEnablePretextTextPipeline)' == ''">true</RoyalTerminalEnablePretextTextPipeline>
<DefineConstants Condition="'$(RoyalTerminalEnablePretextTextPipeline)' == 'true'">$(DefineConstants);ROYALTERMINAL_PRETEXT_TEXT_PIPELINE</DefineConstants>
<DefineConstants Condition="'$(PublishAot)' == 'true'">$(DefineConstants);ROYALTERMINAL_PUBLISH_AOT</DefineConstants>
</PropertyGroup>

<PropertyGroup>
Expand Down
15 changes: 15 additions & 0 deletions RoyalTerminal.sln
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoyalTerminal.WinFormsHost"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoyalTerminal.Avalonia.App", "src\RoyalTerminal.Avalonia.App\RoyalTerminal.Avalonia.App.csproj", "{37EE5670-3466-4072-B0F5-57CC4FD62A6B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoyalTerminal.PtyIoAotSmoke", "tests\RoyalTerminal.PtyIoAotSmoke\RoyalTerminal.PtyIoAotSmoke.csproj", "{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -565,6 +567,18 @@ Global
{37EE5670-3466-4072-B0F5-57CC4FD62A6B}.Release|x64.Build.0 = Release|Any CPU
{37EE5670-3466-4072-B0F5-57CC4FD62A6B}.Release|x86.ActiveCfg = Release|Any CPU
{37EE5670-3466-4072-B0F5-57CC4FD62A6B}.Release|x86.Build.0 = Release|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Debug|x64.ActiveCfg = Debug|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Debug|x64.Build.0 = Debug|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Debug|x86.ActiveCfg = Debug|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Debug|x86.Build.0 = Debug|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Release|Any CPU.Build.0 = Release|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Release|x64.ActiveCfg = Release|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Release|x64.Build.0 = Release|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Release|x86.ActiveCfg = Release|Any CPU
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -607,5 +621,6 @@ Global
{97841A38-87F5-4045-B2CE-F8BF273F97E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{901DE37F-414D-431D-A2E2-E6C3F533C695} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{37EE5670-3466-4072-B0F5-57CC4FD62A6B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{9DA15B12-980D-4120-AE9B-32CED9F7BB1B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
203 changes: 203 additions & 0 deletions docs/specs/terminal-io-throughput-optimization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Terminal IO Throughput Optimization

Date: 2026-07-06

## Goal

Match Ghostty-style PTY IO throughput behavior for RoyalTerminal's Unix PTY path while preserving interactive latency, MVVM boundaries, existing `IPty` contracts, AOT compatibility, and focused regression coverage.

## Reference Review

Required terminal references inspected before implementation:

- Ghostty [`src/termio/Exec.zig`](https://github.com/ghostty-org/ghostty/blob/main/src/termio/Exec.zig), local shallow checkout commit `2da015cd6ac06cedc89e09756e895d2c1715205d`.
- Windows Terminal [`ConptyConnection.cpp`](https://github.com/microsoft/terminal/blob/main/src/cascadia/TerminalConnection/ConptyConnection.cpp), local shallow checkout commit `e58bd4bdab46f7b0de02b6b3494be5a81e4940ad`.
- xterm.js [`WriteBuffer.ts`](https://github.com/xtermjs/xterm.js/blob/master/src/common/input/WriteBuffer.ts), local shallow checkout commit `8aab310366549d8d865bd8fc4bd509051f2bb2a1`.

Findings:

- Ghostty now uses a two-stage POSIX read pipeline: `io-gather` drains the PTY into a small ring of preallocated 64 KiB buffers, while `io-reader` performs VT processing from completed batches. It treats sub-1 KiB reads as interactive and delivers them quickly, but bridges saturated 1 KiB refill gaps with bounded spin/poll work.
- Windows Terminal does not use the same POSIX PTY strategy. Its ConPTY path uses overlapped pipe IO and queues the next `ReadFile` before raising terminal output so slow output handlers do not stop pipe draining.
- xterm.js is not a PTY implementation, but its `WriteBuffer` confirms the same architectural split at the parser boundary: queue incoming chunks, process in bounded slices, and avoid unbounded parser/render starvation.

Decision:

- RoyalTerminal should follow Ghostty for Unix PTY reads because the previous RoyalTerminal loop had the same serial shape: `read()` then `DataReceived`/VT processing on the same thread.
- RoyalTerminal should not blindly apply the Ghostty POSIX gather strategy to Windows ConPTY. Windows already has a separate pipe implementation and different kernel behavior; a Windows change needs a separate ConPTY measurement pass.
- The tweet text says "3 nanoseconds", but Ghostty source uses `3 * ns_per_ms`, i.e. 3 milliseconds. RoyalTerminal follows the source because 3 ns is below practical syscall/timer granularity.

## Implemented Design

Files:

- `src/RoyalTerminal.Terminal.Pty.Unix/Terminal/UnixPty.cs`
- `src/RoyalTerminal.Terminal.Pty.Unix/Terminal/UnixPtyReadBatchPolicy.cs`

Implementation:

- Split Unix PTY output into two background stages per terminal instance:
- `PTY-Gather`: nonblocking `read(2)`/`poll(2)` loop that owns the PTY read fd.
- `PTY-Dispatcher`: delivers completed batches through the existing `DataReceived` event.
- Added a fixed ring of 4 preallocated buffers, each 64 KiB.
- Added backpressure by blocking gather when all 4 buffers are published and not yet released.
- Added low-latency interactive behavior:
- any positive read below 1024 bytes publishes immediately;
- EAGAIN before 1024 gathered bytes publishes immediately.
- Added saturated-stream behavior:
- after at least 1024 gathered bytes, EAGAIN triggers up to 16 immediate read retries;
- if still dry, poll waits 1 ms at a time;
- total bridge budget is 3 ms per batch.
- Kept the public `IPty` surface unchanged.
- Kept the implementation allocation-free after pipeline startup for the read hot path.

## Modern API, SIMD, And AOT Notes

- The read path uses spans/pointers over preallocated arrays and avoids per-read `byte[]` allocation.
- Fixture generation uses `FileStream.Write(ReadOnlySpan<byte>)` and one reusable buffer.
- SIMD is not applied directly in the PTY syscall loop because no byte transformation happens there. The relevant vectorized work for this change is in benchmark/test marker searching and future parser/scanner paths, where `Span<T>` APIs can map to runtime SIMD. VT parser SIMD should be measured separately before implementation.
- `RoyalTerminal.Terminal.Pty.Unix` and `RoyalTerminal.Terminal.Pty.Platform` now set `IsAotCompatible=true` so the SDK AOT/trim analyzers cover the touched packages.
- `RoyalTerminal.Terminal` now sets `IsAotCompatible=true`; reflection-based `System.Text.Json` serializers were moved to source-generated metadata in `TerminalJsonSerializerContexts`.
- A tiny `RoyalTerminal.PtyIoAotSmoke` executable publishes and runs through NativeAOT to validate the PTY stack end-to-end.
- The demo app has a NativeAOT publish path:
- native library resolution uses `AppContext.BaseDirectory` instead of `Assembly.Location`;
- `PublishAot=true` disables the optional Pretext text pipeline because the current Pretext package uses trim-unsafe reflection;
- `PublishAot=true` excludes `ReactiveUI.Avalonia`; the shell uses a plain Avalonia `Window`, explicit open/close lifetime disposal, and trim-safe property-change observables;
- ViewModel settings-panel completion marshals through `Dispatcher.UIThread` directly instead of `AvaloniaScheduler`.
- Test-only reflection against `UnixPty` was removed by exposing an internal `SlavePtyPath` property to `RoyalTerminal.Tests`.

## Benchmarks And Fixtures

The existing benchmark runner now supports Ghostty-style IO fixture generation:

```bash
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- \
--skip-render \
--io \
--vt-parse \
--io-mode managed-vt \
--io-repeats 5 \
--fixture-size-mb 150 \
--fixtures /tmp/royalterminal-io-fixtures \
--output /tmp/royalterminal-io.md
```

PTY IO modes:

- `--io-mode raw`: measures PTY delivery and marker scanning only.
- `--io-mode managed-vt`: feeds each delivered PTY batch through `BasicVtProcessor`.
- `--io-mode both`: runs both rows for each fixture.

The report includes terminal-side elapsed throughput and child-process wall time parsed from `/usr/bin/time -p cat`. The child columns show whether the writer process is stalling behind PTY read/dispatch behavior.

`--vt-parse` adds a parser-only section that excludes file IO and PTY kernel behavior, reports fixture byte shape, and measures `BasicVtProcessor.Process` directly. This is important because PTY throughput and VT parse/screen mutation are separate bottlenecks.

`--ghostty` adds an installed Ghostty comparison on macOS. It launches `Ghostty.app` with `-e /bin/sh <script> <fixture> <result>`, runs `/usr/bin/time -p cat <fixture>` inside Ghostty, lets `cat` write to the Ghostty PTY, and redirects only the timing output to a sidecar file. Use `--ghostty-app /path/to/Ghostty.app` or `--ghostty-path /path/to/ghostty` when auto-discovery does not find the desired build. The report joins Ghostty's writer-side child time with RoyalTerminal's child time from the PTY IO table.

Generated fixtures:

- `{N}MB_ascii.txt`
- `{N}MB_unicode.txt`
- `{N}MB_csi.txt`

### Profiling Result

The initial managed-VT numbers looked suspicious because the benchmark was mixing PTY dispatch shape with VT parser/screen allocation cost. Parser-only profiling on 16 MiB fixtures showed the actual bottleneck:

| Scenario | Before MiB/s | After MiB/s | Speedup | Before alloc/MiB | After alloc/MiB | Allocation reduction |
|---|---:|---:|---:|---:|---:|---:|
| ASCII | 15.359 | 76.998 | 5.01x | 49,923,344 B | 3,045,861 B | 16.4x |
| Unicode | 19.117 | 60.862 | 3.18x | 37,642,356 B | 5,801,346 B | 6.5x |
| CSI | 81.661 | 114.322 | 1.40x | 1,025 B | 1,025 B | 1.0x |

Root cause:

- Whole-screen scrolling allocated a fresh `TerminalRow` for every new line even after scrollback was already full.
- Printable ASCII took the full Unicode grapheme/category and width path per codepoint.
- CSI-heavy fixtures perform less printable cell mutation, so they were already much faster and allocated almost nothing.

Fixes:

- Recycle the trimmed top `TerminalRow` as the new bottom row once scrollback is at capacity.
- Add printable ASCII fast paths for grapheme-append checks and codepoint width calculation.
- Extend the benchmark report with callback timing/allocation and parser-only profiling.

### Main vs Optimized

Compared `origin/main` (`97d4c4d`) against this branch on macOS arm64 with the same 16 MiB fixtures and five repeats. Each run used a real PTY, `/usr/bin/time -p cat`, and managed VT parsing through `BasicVtProcessor`; the table reports median runs.

| Scenario | Main terminal MiB/s | Optimized terminal MiB/s | Terminal speedup | Main child MiB/s | Optimized child MiB/s | Child speedup | Main batches | Optimized batches | Batch reduction |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| ASCII | 13.778 | 64.584 | 4.69x | 13.913 | 66.667 | 4.79x | 16,590 | 445 | 37.3x |
| Unicode | 17.609 | 50.040 | 2.84x | 17.778 | 51.613 | 2.90x | 16,567 | 408 | 40.6x |
| CSI | 60.073 | 89.657 | 1.49x | 61.538 | 94.118 | 1.53x | 16,627 | 511 | 32.5x |

The optimized branch now improves both sides of the original problem: saturated PTY output is delivered as 64 KiB batches instead of serial 1 KiB dispatches, and the managed parser no longer spends most of its time allocating rows for steady-state scrolling.

### Installed Ghostty Comparison

Compared this branch against installed Ghostty `Ghostty 1.3.2-HEAD+28972454c` from `/Users/wieslawsoltes/GitHub/RoyalTerminal/external/ghostty/macos/build/ReleaseLocal/Ghostty.app`. Both columns use writer-side `/usr/bin/time -p cat` wall time, so this compares how quickly the child process can write the same fixture into each terminal path. RoyalTerminal used `--io-mode managed-vt`.

16 MiB fixtures, five repeats:

| Scenario | RoyalTerminal MiB/s | Ghostty MiB/s | Royal/Ghostty | Royal real (ms) | Ghostty real (ms) |
|---|---:|---:|---:|---:|---:|
| ASCII | 61.538 | 53.333 | 1.154x | 260 | 300 |
| Unicode | 47.059 | 45.714 | 1.029x | 340 | 350 |
| CSI | 80.000 | 39.024 | 2.050x | 200 | 410 |

150 MiB fixtures, three repeats:

| Scenario | RoyalTerminal MiB/s | Ghostty MiB/s | Royal/Ghostty | Royal real (ms) | Ghostty real (ms) |
|---|---:|---:|---:|---:|---:|
| ASCII | 68.807 | 78.534 | 0.876x | 2180 | 1910 |
| Unicode | 56.180 | 70.093 | 0.801x | 2670 | 2140 |
| CSI | 86.705 | 62.241 | 1.393x | 1730 | 2410 |

Interpretation: this RoyalTerminal branch is competitive with the installed Ghostty build on writer-side throughput. Ghostty is ahead on larger ASCII and Unicode fixtures, while RoyalTerminal is ahead on the CSI-heavy fixture. This is not a renderer frame benchmark and does not compare GPU presentation latency.

## Validation Log

Commands run:

```bash
dotnet build src/RoyalTerminal.Terminal.Pty.Unix/RoyalTerminal.Terminal.Pty.Unix.csproj -c Release
dotnet build tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release
dotnet build samples/RoyalTerminal.Demo/RoyalTerminal.Demo.csproj -c Release
dotnet test tests/RoyalTerminal.Tests/RoyalTerminal.Tests.csproj -c Release
dotnet publish tests/RoyalTerminal.PtyIoAotSmoke/RoyalTerminal.PtyIoAotSmoke.csproj -c Release -r osx-arm64 -p:PublishAot=true --self-contained true -o /tmp/royalterminal-pty-aot-smoke-clean
/tmp/royalterminal-pty-aot-smoke-clean/RoyalTerminal.PtyIoAotSmoke
dotnet publish samples/RoyalTerminal.Demo/RoyalTerminal.Demo.csproj -c Release -r osx-arm64 -p:PublishAot=true --self-contained true -o /tmp/royalterminal-demo-aot
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- --skip-render --io --fixture-size-mb 1 --fixtures /tmp/royalterminal-io-fixtures-smoke --output /tmp/royalterminal-io-smoke.md
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- --skip-render --io --io-mode both --io-repeats 2 --fixture-size-mb 1 --fixtures /tmp/royalterminal-io-fixtures-better-smoke --output /tmp/royalterminal-io-better-smoke.md
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- --skip-render --io --io-mode managed-vt --io-repeats 3 --fixture-size-mb 32 --fixtures /tmp/royalterminal-io-compare-fixtures --output /tmp/royalterminal-io-managed-vt-optimized.md
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- --skip-render --vt-parse --io-repeats 5 --fixture-size-mb 16 --fixtures /tmp/royalterminal-io-profile-fixtures --output /tmp/royalterminal-vt-parse-after.md
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- --skip-render --io --io-mode both --io-repeats 5 --fixture-size-mb 16 --fixtures /tmp/royalterminal-io-profile-fixtures --output /tmp/royalterminal-io-profile-after.md
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- --skip-render --io --io-mode managed-vt --io-repeats 5 --fixture-size-mb 16 --fixtures /tmp/royalterminal-io-profile-fixtures --ghostty --ghostty-app /Users/wieslawsoltes/GitHub/RoyalTerminal/external/ghostty/macos/build/ReleaseLocal/Ghostty.app --output /tmp/royalterminal-ghostty-compare-16mb.md
dotnet run --project tests/RoyalTerminal.Benchmarks/RoyalTerminal.Benchmarks.csproj -c Release -- --skip-render --io --io-mode managed-vt --io-repeats 3 --fixture-size-mb 150 --fixtures /tmp/royalterminal-ghostty-150mb-fixtures --ghostty --ghostty-app /Users/wieslawsoltes/GitHub/RoyalTerminal/external/ghostty/macos/build/ReleaseLocal/Ghostty.app --output /tmp/royalterminal-ghostty-compare-150mb.md
dotnet test tests/RoyalTerminal.Tests/RoyalTerminal.Tests.csproj -c Release --filter "FullyQualifiedName~UnicodeWidthTests|FullyQualifiedName~TerminalScreenTests"
```

Results:

- Unix PTY package build: passed.
- Benchmark project build: passed.
- Demo app build: passed.
- Full test suite: 1238 passed, 16 skipped.
- PTY NativeAOT smoke publish and binary run: passed.
- Demo NativeAOT publish for `osx-arm64`: passed. The macOS linker emitted debug-info module-cache warnings only.
- IO benchmark smoke: passed, report written to `/tmp/royalterminal-io-smoke.md`.
- IO benchmark both-mode smoke: passed, report written to `/tmp/royalterminal-io-better-smoke.md`.
- IO benchmark managed-VT 32 MiB run: passed, report written to `/tmp/royalterminal-io-managed-vt-optimized.md`.
- Managed VT parser profile: passed, report written to `/tmp/royalterminal-vt-parse-after.md`.
- PTY IO profile after parser/scroll optimization: passed, report written to `/tmp/royalterminal-io-profile-after.md`.
- Installed Ghostty 16 MiB comparison: passed, report written to `/tmp/royalterminal-ghostty-compare-16mb.md`.
- Installed Ghostty 150 MiB comparison: passed, report written to `/tmp/royalterminal-ghostty-compare-150mb.md`.
- Focused terminal screen and Unicode width tests: passed, 87 tests.

## Follow-Up Work

- Extend the external-terminal comparison to Alacritty, Kitty, and other terminals with identical shell/render settings and hardware.
- Continue profiling deeper VT parser paths after the read-side and steady-state scroll allocation bottlenecks.
- Consider SIMD only in measured byte-processing paths such as UTF-8 classification, ASCII fast paths, CSI scanning, or marker/search helpers.
- Add CI jobs for PTY NativeAOT smoke and demo NativeAOT publish on each supported RID.
- Audit the optional Pretext pipeline again when the package exposes trim/AOT-safe metadata.
4 changes: 4 additions & 0 deletions samples/RoyalTerminal.Demo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// RoyalTerminal.Demo — Sample multi-tab terminal application.

using Avalonia;
#if !ROYALTERMINAL_PUBLISH_AOT
using ReactiveUI.Avalonia;
#endif

namespace RoyalTerminal.Demo;

Expand All @@ -27,7 +29,9 @@ public static AppBuilder BuildAvaloniaApp()
{
DisableDefaultApplicationMenuItems = true,
})
#if !ROYALTERMINAL_PUBLISH_AOT
.UseReactiveUI(_ => { })
#endif
.WithInterFont()
.LogToTrace();
}
2 changes: 1 addition & 1 deletion samples/RoyalTerminal.Demo/RoyalTerminal.Demo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="ReactiveUI.Avalonia" />
<PackageReference Include="ReactiveUI.Avalonia" Condition="'$(PublishAot)' != 'true'" />
</ItemGroup>

<!-- Import native asset targets for file copying (same mechanism as NuGet package) -->
Expand Down
Loading
Loading