diff --git a/ROADMAP.md b/ROADMAP.md index 1b0cd0a..168f226 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,9 +37,24 @@ ### P1 闸门建设(当前) -- **1a 性能基线**:`scripts/perf_baseline.ps1`——同一负载(含 ANSI 色、 - CJK、boxdraw 字符的大流量输出)打进新旧两壳,量吞吐与内存,写入 CSV。 - 首测数字决定 G1 阈值。 +- **1a 性能基线**:`scripts/perf_baseline.ps1`(负载生成 + ConPTY 免焦点 + 注入 `perf_inject.ps1` + 终端内自计时)。 + **首测 2026-08-13(50MB 混合负载:ANSI 256/真彩 + CJK + boxdraw + + Powerline),release 构建,各 3 轮取中位**: + + | 壳 | 中位耗时 | 吞吐 | 内存 | 窗口 | + |---|---|---|---|---| + | winit-old | 21.1s | 2.4 MB/s | 118 MB | 1267x740 | + | gpui-lab | 21.8s | 2.3 MB/s | 85 MB | 1095x729 | + + 判读:吞吐差 3.6%(GPUI 窗口略小,按面积校正视为持平),内存省 28%。 + **G1 阈值定为:等网格下吞吐不低于旧壳的 90%——当前通过**。后续在等 + 网格条件下复测一次收严。 + + 测量中抓到的真 bug(引擎级):无侧载 OpenConsole/conpty.dll 对时走 + 系统 conhost 回退,大流量下会话**完全停摆**(PTY 缓冲写满、相关进程 + 全部 0% CPU)。旧壳发行版始终带侧载对所以从未暴露。修复列入 P2; + 运维规则:任何 nebula 系 exe 旁必须部署侧载对。 - **1b IME 清单**:输出一页人工验收清单,等用户有空亲手敲。 - **1c 视觉对账集**:fastfetch + powerline 提示符 + TUI 边框在新旧两壳的 并排截图脚本与基准图。 @@ -54,6 +69,7 @@ - config 共享化:还掉实验场里复制 config 解析的债——共享 crate,一份 nebula.toml 两壳同读。 - 会话/SSH 逻辑共享化评估(能抽则抽,抽不动的记录原因)。 +- conhost 回退路径的大流量停摆修复(P1-1a 抓到,见上)。 ### P3 接入 `nebula_app`(GPUI 成为产品 UI 层) diff --git a/scripts/perf_baseline.ps1 b/scripts/perf_baseline.ps1 new file mode 100644 index 0000000..e4404a7 --- /dev/null +++ b/scripts/perf_baseline.ps1 @@ -0,0 +1,181 @@ +# 性能基线(G1 闸门):向指定终端 exe 注入同一份大流量混合负载, +# 在终端内部自计时(cmd /c type 大文件),量吞吐与内存,追加到 CSV。 +# +# 用法: +# .\scripts\perf_baseline.ps1 -Exe -Tag winit-old -ExeArgs '--working-directory D:\temp_build\nebula' +# .\scripts\perf_baseline.ps1 -Exe -Tag gpui-lab +# +# 方法论: +# - 负载混合 ANSI 256 色/真彩、CJK 宽字符、boxdraw 框线、Powerline 分隔符, +# 贴近 fastfetch/TUI/powerline 的真实着色与整形压力。 +# - 计时在终端会话内完成(Stopwatch 包住 type),只量"终端消费 PTY 输出" +# 的速度,不含进程启动与提示符渲染。 +# - 命令经 ConPTY 控制台输入缓冲注入(perf_inject.ps1),不碰窗口焦点; +# 窗口置顶但不激活(NOACTIVATE),保证两壳都真实渲染——旧渲染器被完全 +# 遮挡时会跳帧,会虚高吞吐。 +# - 第一轮为预热(文件缓存/JIT),丢弃;报告中位数。 +# - 两壳窗口尺寸可能不同(网格越大每帧工作越多),rect 一并记录供对账。 +param( + [Parameter(Mandatory = $true)][string]$Exe, + [Parameter(Mandatory = $true)][string]$Tag, + # 旧壳必须传 '--working-directory ' 之类的显式意图参数, + # 否则普通启动会转交常驻实例后退出(mux hand-over),拿不到独立进程。 + [string]$ExeArgs = '', + [int]$Runs = 3, + [int]$PayloadMB = 50, + [int]$TimeoutSec = 240 +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$payload = Join-Path $root '.tmp_perf_payload.txt' +$cmdFile = Join-Path $root '.tmp_perf_cmd.ps1' +$resultFile = Join-Path $root '.tmp_perf_result.txt' +$csv = Join-Path $root '.tmp_perf_baseline.csv' +$injector = Join-Path $PSScriptRoot 'perf_inject.ps1' + +# ---- 1. 负载生成(按目标大小幂等)---------------------------------------- +$targetBytes = $PayloadMB * 1MB +if (-not (Test-Path $payload) -or (Get-Item $payload).Length -lt $targetBytes) { + Write-Host "generating payload ~${PayloadMB}MB ..." + $e = [char]27 + $block = [Text.StringBuilder]::new() + for ($i = 0; $i -lt 240; $i++) { + $line = switch ($i % 6) { + 0 { "$e[38;5;$(($i * 7) % 256)m colored line $i lorem ipsum dolor sit amet consectetur adipiscing elit$e[0m" } + 1 { "plain ascii line $i the quick brown fox jumps over the lazy dog 0123456789" } + 2 { "中文宽字符混排 第${i}行 终端网格渲染必须逐格对齐 妙笔生花 龍飛鳳舞 テスト" } + 3 { "┌─────┬─────┐ │ box $i │ ▀▄█░▒▓ ├─────┼─────┤ └─────┴─────┘" } + 4 { "$e[48;5;$(($i * 3) % 256)m$e[38;5;15m$([char]0xE0B0) powerline seg $i $([char]0xE0B0)$e[0m tail" } + 5 { "$e[1;31mbold red$e[0m $e[4;32munderline green$e[0m $e[38;2;$(($i*5)%256);128;200mtruecolor$e[0m line $i" } + } + [void]$block.AppendLine($line) + } + $blockText = $block.ToString() + $writer = [IO.StreamWriter]::new($payload, $false, [Text.UTF8Encoding]::new($false)) + try { + while ($writer.BaseStream.Length -lt $targetBytes) { $writer.Write($blockText) } + } finally { $writer.Dispose() } +} +$payloadLen = (Get-Item $payload).Length + +# ---- 2. 终端内自计时的 runner --------------------------------------------- +@" +`$sw = [Diagnostics.Stopwatch]::StartNew() +cmd /c type "$payload" +`$sw.Stop() +"`$(`$sw.Elapsed.TotalMilliseconds)" | Out-File -FilePath "$resultFile" -Encoding ascii +"@ | Set-Content -Path $cmdFile -Encoding ascii + +# ---- 3. Win32:按 PID 找顶层窗口、置顶但不激活 ----------------------------- +Add-Type @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public class PerfWin { + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr lp); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int w, int hh, uint flags); + public struct RECT { public int L, T, R, B; } + delegate bool EnumProc(IntPtr h, IntPtr lp); + public static List WindowsOf(uint pid) { + var list = new List(); + EnumWindows((h, lp) => { + uint p; GetWindowThreadProcessId(h, out p); + if (p == pid && IsWindowVisible(h)) list.Add(h); + return true; + }, IntPtr.Zero); + return list; + } +} +'@ + +function Get-MainWindow([int]$ProcessId) { + for ($i = 0; $i -lt 40; $i++) { + $wins = [PerfWin]::WindowsOf($ProcessId) + if ($wins.Count -gt 0) { + # 取最宽者(宿主可能带辅助小窗)。 + $best = $null; $bestW = -1 + foreach ($h in $wins) { + $r = New-Object PerfWin+RECT + [void][PerfWin]::GetWindowRect($h, [ref]$r) + if (($r.R - $r.L) -gt $bestW) { $bestW = $r.R - $r.L; $best = $h } + } + return $best + } + Start-Sleep -Milliseconds 500 + } + throw "no visible window for pid $ProcessId" +} + +# ---- 4. 驱动 ---------------------------------------------------------------- +Write-Host "launching $Exe $ExeArgs" +$proc = if ($ExeArgs) { + Start-Process -FilePath $Exe -ArgumentList $ExeArgs -PassThru +} else { + Start-Process -FilePath $Exe -PassThru +} +Start-Sleep -Seconds 7 # 等 shell 与提示符就绪 +if ($proc.HasExited) { throw "terminal exited immediately (mux hand-over? pass -ExeArgs)" } +$hwnd = Get-MainWindow -ProcessId $proc.Id +$rect = New-Object PerfWin+RECT +[void][PerfWin]::GetWindowRect($hwnd, [ref]$rect) +$rectStr = "$($rect.R - $rect.L)x$($rect.B - $rect.T)" +# HWND_TOPMOST=-1; SWP_NOSIZE|NOMOVE|NOACTIVATE = 0x13:可见但不抢焦点。 +[void][PerfWin]::SetWindowPos($hwnd, [IntPtr](-1), 0, 0, 0, 0, 0x13) +Write-Host "window $rectStr pid $($proc.Id)" + +$command = "powershell -NoProfile -ExecutionPolicy Bypass -File $cmdFile" + +function Invoke-Injected([int]$HostPid, [string]$Text) { + for ($attempt = 0; $attempt -lt 5; $attempt++) { + $out = & powershell -NoProfile -ExecutionPolicy Bypass -File $injector ` + -HostPid $HostPid -Text $Text -Enter 2>&1 | Out-String + if ($out -match 'ok=True') { return $true } + Start-Sleep -Seconds 2 + } + Write-Warning "inject failed: $out" + return $false +} + +$times = @() +for ($run = 0; $run -le $Runs; $run++) { + Remove-Item $resultFile -ErrorAction SilentlyContinue + if (-not (Invoke-Injected -HostPid $proc.Id -Text $command)) { continue } + + $deadline = (Get-Date).AddSeconds($TimeoutSec) + while (-not (Test-Path $resultFile) -and (Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Path $resultFile)) { + Write-Warning "run ${run}: timeout waiting for result" + continue + } + Start-Sleep -Milliseconds 300 + $ms = [double](Get-Content $resultFile -Raw).Trim() + $mbps = [Math]::Round($payloadLen / 1MB / ($ms / 1000.0), 1) + if ($run -eq 0) { + Write-Host ("warmup: {0,8:N0} ms {1,6} MB/s (discarded)" -f $ms, $mbps) + } else { + Write-Host ("run ${run}: {0,8:N0} ms {1,6} MB/s" -f $ms, $mbps) + $times += $ms + } + Start-Sleep -Seconds 1 +} + +$ws = [Math]::Round((Get-Process -Id $proc.Id).WorkingSet64 / 1MB, 1) +Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + +if ($times.Count -eq 0) { throw "no successful runs" } +$sorted = $times | Sort-Object +$median = $sorted[[int](($sorted.Count - 1) / 2)] +$medianMbps = [Math]::Round($payloadLen / 1MB / ($median / 1000.0), 1) +Write-Host ("== $Tag median {0:N0} ms {1} MB/s ws {2} MB window $rectStr" -f $median, $medianMbps, $ws) + +if (-not (Test-Path $csv)) { + 'timestamp,tag,window,payload_mb,runs,median_ms,median_mbps,workingset_mb' | Set-Content $csv -Encoding ascii +} +"$(Get-Date -Format s),$Tag,$rectStr,$([Math]::Round($payloadLen/1MB)),$($times.Count),$([Math]::Round($median)),$medianMbps,$ws" | + Add-Content $csv -Encoding ascii diff --git a/scripts/perf_inject.ps1 b/scripts/perf_inject.ps1 new file mode 100644 index 0000000..a5e5706 --- /dev/null +++ b/scripts/perf_inject.ps1 @@ -0,0 +1,75 @@ +# 向 nebula 实例内部的 shell 注入按键:附加其 ConPTY 控制台(sideload 的 +# OpenConsole),对 CONIN$ 调 WriteConsoleInputW。不涉及窗口焦点,不打扰 +# 前台用户。供 perf_baseline.ps1 等探针复用。 +param( + [Parameter(Mandatory)][int]$HostPid, + [Parameter(Mandatory)][string]$Text, + [switch]$Enter +) +$ErrorActionPreference = 'Stop' + +# 沿进程树向下找 shell:nebula -> OpenConsole/conhost -> pwsh +# (不同宿主上 pwsh 的父进程可能是 nebula 也可能是 OpenConsole)。 +$procs = Get-CimInstance Win32_Process +$kids = { param($parent) $procs | Where-Object { $_.ParentProcessId -eq $parent } } +$queue = @($HostPid); $shell = $null +while ($queue.Count -gt 0 -and -not $shell) { + $cur, $queue = $queue + foreach ($child in (& $kids $cur)) { + if ($child.Name -match '^(pwsh|powershell|cmd)') { $shell = $child; break } + $queue += $child.ProcessId + } +} +if (-not $shell) { Write-Output "NO SHELL under $HostPid"; exit 1 } + +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class ConInject { + [StructLayout(LayoutKind.Sequential)] + public struct KEY_EVENT_RECORD { + public int bKeyDown; public ushort wRepeatCount; public ushort wVirtualKeyCode; + public ushort wVirtualScanCode; public ushort UnicodeChar; public uint dwControlKeyState; + } + [StructLayout(LayoutKind.Explicit)] + public struct INPUT_RECORD { + [FieldOffset(0)] public ushort EventType; + [FieldOffset(4)] public KEY_EVENT_RECORD KeyEvent; + } + [DllImport("kernel32.dll", SetLastError=true)] public static extern bool FreeConsole(); + [DllImport("kernel32.dll", SetLastError=true)] public static extern bool AttachConsole(uint pid); + [DllImport("kernel32.dll", SetLastError=true)] + public static extern IntPtr CreateFileW([MarshalAs(UnmanagedType.LPWStr)] string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr tmpl); + [DllImport("kernel32.dll", SetLastError=true)] + public static extern bool WriteConsoleInputW(IntPtr h, INPUT_RECORD[] recs, uint n, out uint written); + public static INPUT_RECORD Key(bool down, ushort vk, ushort uc) { + var r = new INPUT_RECORD(); r.EventType = 1; + r.KeyEvent.bKeyDown = down ? 1 : 0; r.KeyEvent.wRepeatCount = 1; + r.KeyEvent.wVirtualKeyCode = vk; r.KeyEvent.wVirtualScanCode = 0; + r.KeyEvent.UnicodeChar = uc; r.KeyEvent.dwControlKeyState = 0; + return r; + } +} +'@ + +[void][ConInject]::FreeConsole() +if (-not [ConInject]::AttachConsole([uint32]$shell.ProcessId)) { + Write-Output ("ATTACH FAILED err=" + [Runtime.InteropServices.Marshal]::GetLastWin32Error()) + exit 1 +} +$conin = [ConInject]::CreateFileW('CONIN$', [uint32]3221225472, 3, [IntPtr]::Zero, 3, 0, [IntPtr]::Zero) + +$records = New-Object System.Collections.Generic.List[ConInject+INPUT_RECORD] +foreach ($ch in $Text.ToCharArray()) { + $code = [uint16][char]$ch + $records.Add([ConInject]::Key($true, 0, $code)) + $records.Add([ConInject]::Key($false, 0, $code)) +} +if ($Enter) { + $records.Add([ConInject]::Key($true, 13, 13)) + $records.Add([ConInject]::Key($false, 13, 13)) +} +$written = [uint32]0 +$ok = [ConInject]::WriteConsoleInputW($conin, $records.ToArray(), [uint32]$records.Count, [ref]$written) +Write-Output ("inject ok=$ok written=$written of " + $records.Count + " shell=" + $shell.ProcessId) +if (-not $ok) { exit 1 }