commit 5e186fd98844ccfd72f6062e35b10e68238d33fb Author: jbjf6666 Date: Thu Oct 29 15:37:18 2026 +0800 feat: GpuMemTool v1.1 + v2.0(显存监控与释放工具,DSH 会话归档) diff --git a/GpuMemTool.exe b/GpuMemTool.exe new file mode 100644 index 0000000..e225f8d Binary files /dev/null and b/GpuMemTool.exe differ diff --git a/GpuMemTool.ps1 b/GpuMemTool.ps1 new file mode 100644 index 0000000..de5bccf --- /dev/null +++ b/GpuMemTool.ps1 @@ -0,0 +1,1582 @@ +#Requires -Version 5.1 +<# +============================================================================= + GPU 显存释放工具 (GpuMemTool) v1.1 + ----------------------------------------------------------------------- + 功能: + 1. 基于 nvidia-smi 读取 GPU 级指标:显存总量/已用/空闲、GPU 利用率、 + 显存带宽利用率、温度、功耗; + 2. 逐进程显示「哪个进程占用了多少显存」,可按任意列排序; + 3. 一键结束(释放)选定进程,可选「正常结束」/「强制结束」,带保护名单; + 4. 显存 / GPU 占用率实时曲线监测 + 监测数据导出 CSV; + 5. 启动时直接请求管理员权限(UAC)——非管理员运行本脚本会自动提权重启, + 用户取消提权时仍可继续以普通权限使用。 + + 技术说明(重要): + Windows(WDDM) 下 `nvidia-smi --query-compute-apps` 的 used_gpu_memory + 通常返回 [N/A](仅 TCC 模式/CUDA 上下文有效),因此本工具在 nvidia-smi + 之外,额外使用 Windows 性能计数器 + Win32_PerfFormattedData_GPUPerformanceCounters_GPUProcessMemory(Local Usage) + 逐进程统计显存占用,并与 nvidia-smi 的 CUDA 数据取较大值、标注来源。 + 实测总量基本吻合,差值即驱动/系统保留部分(界面上单独显示)。 + 注意:首次采集需初始化性能计数器,约需 5~15 秒,之后每次约 0.1~0.8 秒。 + + 用法: + powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 + (直接运行即会自动弹出 UAC 请求管理员权限;也可用同目录的 RunAsAdmin.bat) + powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -SelfTest + (-SelfTest 无界面自检:采集一次并打印表格,便于排错,不会提权) + powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -KillPid 1234,5678 -Force + (-KillPid 命令行结束指定进程,仍受保护名单约束,便于脚本化清理显存,不会提权) +============================================================================= +#> +[CmdletBinding()] +param( + [switch]$SelfTest, # 无 GUI 自检 + [int]$IntervalSeconds = 2, # 默认刷新间隔(秒) + [int[]]$KillPid, # 命令行模式:结束指定 PID + [switch]$Force, # 命令行模式:强制结束 + [switch]$Elevated # 内部标记:已经过一次提权,避免 UAC 循环 +) + +$ErrorActionPreference = 'Continue' +$script:AppName = 'GPU 显存释放工具' +$script:AppVersion = 'v1.1' +$script:Config = @{ Interval = $IntervalSeconds } + +# =========================================================================== +# 启动时直接请求管理员权限(UAC) +# · 非管理员启动 -> 立刻以 RunAs 重新启动自己,然后退出当前实例 +# · 用户点了「否」或已带 -Elevated 标记 -> 不重复弹窗,继续以普通权限运行 +# (窗口右上角会显示红色提示,并可点「以管理员身份重启」再试) +# · -SelfTest / -KillPid 等命令行模式不自动提权,避免脚本/自动化被 UAC 卡住 +# =========================================================================== +if (-not $Elevated) { + $isAdminNow = $false + try { + $wi = [Security.Principal.WindowsIdentity]::GetCurrent() + $wp = New-Object Security.Principal.WindowsPrincipal($wi) + $isAdminNow = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { } + + if (-not $isAdminNow -and -not $SelfTest -and -not $KillPid) { + $selfPath = $PSCommandPath + if ([string]::IsNullOrWhiteSpace($selfPath)) { + try { $selfPath = $MyInvocation.MyCommand.Path } catch { } + } + if (-not [string]::IsNullOrWhiteSpace($selfPath) -and (Test-Path -LiteralPath $selfPath)) { + $q = [char]34 + $argline = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ' + $q + $selfPath + $q + + ' -IntervalSeconds ' + $IntervalSeconds + ' -Elevated' + $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + try { + Start-Process -FilePath $psExe -ArgumentList $argline -Verb RunAs -ErrorAction Stop + exit 0 # 已交给提权后的新实例,当前实例退出 + } catch { + # 用户取消 UAC,或提权失败:继续以普通权限运行,界面会给出提示 + } + } + } +} + +# --------------------------------------------------------------------------- +# 进程保护名单 +# BlockNames : 结束会导致系统崩溃/蓝屏,禁止结束 +# WarnNames : 系统外壳或关键进程,允许结束但需二次确认 +# --------------------------------------------------------------------------- +$script:BlockNames = @( + 'system', 'idle', 'registry', 'memory compression', 'secure system', + 'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon', + 'fontdrvhost', 'msmpeng', 'nissrv', 'sppsvc', 'trustedinstaller' +) +$script:WarnNames = @( + 'dwm', 'explorer', 'svchost', 'audiodg', 'sihost', 'runtimebroker', + 'searchhost', 'startmenuexperiencehost', 'shellexperiencehost', + 'textinputhost', 'applicationframehost', 'widgetservice', 'widgets' +) + +# =========================================================================== +# 通用函数 +# =========================================================================== +function Find-NvSmiPath { + $cands = New-Object System.Collections.ArrayList + $cmd = Get-Command 'nvidia-smi.exe' -ErrorAction SilentlyContinue + if ($cmd -and $cmd.Source) { [void]$cands.Add($cmd.Source) } + [void]$cands.Add((Join-Path $env:SystemRoot 'System32\nvidia-smi.exe')) + [void]$cands.Add((Join-Path ${env:ProgramFiles} 'NVIDIA Corporation\NVSMI\nvidia-smi.exe')) + [void]$cands.Add('C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe') + foreach ($p in $cands) { + if ($p -and (Test-Path -LiteralPath $p)) { return $p } + } + return $null +} + +function Test-IsAdmin { + try { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $pr = New-Object Security.Principal.WindowsPrincipal($id) + return $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { return $false } +} + +function Get-Argb { + param([int]$A, [int]$R, [int]$G, [int]$B) + return [System.Drawing.Color]::FromArgb($A, $R, $G, $B) +} + +# --------------------------------------------------------------------------- +# 保护名单判定:本工具自身 + 直接父进程(通常是控制台宿主 powershell/cmd/ +# Windows Terminal——结束它会连带关掉本窗口)。不再向上追溯更多层, +# 否则从资源管理器启动时会误把 explorer.exe 一并保护,导致无法释放它的显存。 +# --------------------------------------------------------------------------- +function Get-ProtectedPidSet { + $set = @{} + $set[[int]$PID] = $true + $self = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction SilentlyContinue + if ($self) { + $pp = [int]$self.ParentProcessId + if ($pp -gt 0) { $set[$pp] = $true } + } + return $set +} + +# --------------------------------------------------------------------------- +# 判断进程是否真的退出了 +# Stop-Process 有可能「假成功」:调用没报错,但进程被驱动/反作弊挡住而不退出 +# (典型:卡死的游戏进程只剩 1 个线程、工作集接近 0,却仍占着显存)。 +# 所以结束之后必须轮询确认,不能相信返回值。 +# --------------------------------------------------------------------------- +function Test-ProcessExited { + param([int]$ProcessId, [int]$TimeoutMs = 2000) + $sw = [System.Diagnostics.Stopwatch]::StartNew() + while ($sw.ElapsedMilliseconds -lt $TimeoutMs) { + if (-not (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { return $true } + Start-Sleep -Milliseconds 200 + } + return $false +} + +# --------------------------------------------------------------------------- +# 结束进程核心逻辑(GUI 与命令行共用) +# $Targets : 形如 @{ Pid = ..; Name = ..; Vram = .. } 的对象数组 +# 返回 : Ok / Warned / Blocked / Killed / Failed / FreedMiB / Messages +# --------------------------------------------------------------------------- +function Stop-GpuProcess { + param( + [array]$Targets, + [switch]$Force, + [switch]$PumpGui, # GUI 模式:等待期间处理消息循环,避免界面假死 + [switch]$DryRun # 只做分类判定,不真正结束进程 + ) + + $protect = Get-ProtectedPidSet + $blocked = @(); $warned = @(); $ok = @() + foreach ($t in @($Targets)) { + $base = ([string]$t.Name -replace '\.exe$', '').ToLower() + if ($script:BlockNames -contains $base) { $blocked += $t; continue } + if ($protect.ContainsKey([int]$t.Pid)) { $blocked += $t; continue } + if ($script:WarnNames -contains $base) { $warned += $t; continue } + $ok += $t + } + + if ($DryRun) { + return [pscustomobject]@{ + Ok = $ok; Warned = $warned; Blocked = $blocked + Killed = @(); Failed = @(); FreedMiB = 0.0; Messages = @() + } + } + + $messages = New-Object System.Collections.ArrayList + $killed = @(); $failed = @(); $freed = 0.0 + + foreach ($t in @($ok + $warned)) { + $vramTxt = '{0:N1}' -f [double]$t.Vram + $proc = Get-Process -Id ([int]$t.Pid) -ErrorAction SilentlyContinue + if (-not $proc) { + [void]$messages.Add("跳过 $($t.Name) (PID $($t.Pid)):进程已不存在") + continue + } + $expect = ([string]$t.Name -replace '\.exe$', '') + if (-not [string]::IsNullOrWhiteSpace($expect) -and $proc.ProcessName -ne $expect) { + [void]$messages.Add("跳过 $($t.Name) (PID $($t.Pid)):PID 已被复用(当前为 $($proc.ProcessName).exe),为避免误杀已中止") + $failed += $t + continue + } + $targetPid = [int]$t.Pid + $lastErr = '' + $how = '' + + # ---- 第一步:正常关闭窗口 / 直接结束 ---- + try { + if ($Force) { + Stop-Process -Id $targetPid -Force -ErrorAction Stop + $how = '强制结束' + } else { + $closed = $false + try { $closed = $proc.CloseMainWindow() } catch { $closed = $false } + if ($closed) { + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if ($PumpGui) { [System.Windows.Forms.Application]::DoEvents() } + Start-Sleep -Milliseconds 120 + if (-not (Get-Process -Id $targetPid -ErrorAction SilentlyContinue)) { break } + } + } + if (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) { + Stop-Process -Id $targetPid -ErrorAction Stop + $how = '结束' + } else { + $how = '正常关闭' + } + } + } catch { + $lastErr = $_.Exception.Message + } + + # ---- 第二步:确认是否真的退出了;没退就换 WMI(以 SYSTEM 身份执行)再杀一次 ---- + $gone = Test-ProcessExited -ProcessId $targetPid + if (-not $gone) { + try { + $ci = Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction Stop + if ($ci) { + $wr = Invoke-CimMethod -InputObject $ci -MethodName Terminate -ErrorAction Stop + if ($null -ne $wr -and $wr.ReturnValue -ne 0) { + if ([string]::IsNullOrWhiteSpace($lastErr)) { $lastErr = "WMI Terminate 返回码 $($wr.ReturnValue)" } + } + } + } catch { + if ([string]::IsNullOrWhiteSpace($lastErr)) { $lastErr = $_.Exception.Message } + } + $gone = Test-ProcessExited -ProcessId $targetPid + if ($gone) { $how = '强制结束(WMI)' } + } + + # ---- 第三步:如实上报 ---- + if ($gone) { + [void]$messages.Add("已$how $($t.Name) (PID $targetPid),释放约 $vramTxt MB") + $killed += $t; $freed += [double]$t.Vram + } else { + $reason = $lastErr + if ([string]::IsNullOrWhiteSpace($reason)) { $reason = '已发送结束指令但进程未退出' } + [void]$messages.Add("结束 $($t.Name) (PID $targetPid) 失败:$reason(进程仍未退出,通常是驱动/反作弊保护或进程已挂起卡死)") + $failed += $t + } + } + + return [pscustomobject]@{ + Ok = $ok + Warned = $warned + Blocked = $blocked + Killed = $killed + Failed = $failed + FreedMiB = [math]::Round($freed, 1) + Messages = @($messages.ToArray()) + } +} + +# =========================================================================== +# 数据采集脚本块(在独立 Runspace 中执行,返回 JSON 字符串) +# =========================================================================== +$script:CollectScript = { + param([string]$NvSmiPath) + + $ErrorActionPreference = 'SilentlyContinue' + + # 注意:必须以「字符串数组」形式传参;PowerShell 不会对字符串变量做分词, + # 否则整串会被当作单个参数传给 nvidia-smi(表现为退出码 2)。 + $SmiArgs = @( + '--query-gpu=index,name,memory.total,memory.used,memory.free,utilization.gpu,utilization.memory,temperature.gpu,power.draw,power.limit' + '--format=csv,noheader,nounits' + ) + $ComputeArgs = @( + '--query-compute-apps=pid,process_name,used_gpu_memory' + '--format=csv,noheader,nounits' + ) + function Convert-Num([string]$s) { + if ($null -eq $s) { return $null } + $t = $s.Trim() + if ($t -eq '' -or $t -like '*N/A*' -or $t -eq '[Not Supported]') { return $null } + $v = 0.0 + if ([double]::TryParse($t, [ref]$v)) { return $v } + return $null + } + + $res = [ordered]@{ + ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + smiOk = $false + smiErr = '' + cntOk = $false + cntErr = '' + gpus = @() + procs = @() + smiUsedMiB = 0 + sumProcMiB = 0 + luidCount = 0 + } + + # ---------------- 1) nvidia-smi:GPU 级指标 ---------------- + if ($NvSmiPath -and (Test-Path -LiteralPath $NvSmiPath)) { + try { + $raw = & $NvSmiPath $SmiArgs 2>&1 + $code = $LASTEXITCODE + $rawLines = @($raw | Where-Object { $_ -is [string] }) + $rawErrs = @($raw | Where-Object { $_ -isnot [string] }) + if ($code -eq 0 -and $rawLines.Count -gt 0) { + $res.smiOk = $true + foreach ($line in $rawLines) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $f = @(($line -split ',') | ForEach-Object { $_.Trim() }) + if ($f.Count -lt 10) { continue } + $res.gpus += , ([pscustomobject]@{ + index = [int](Convert-Num $f[0]) + name = $f[1] + memTotal = (Convert-Num $f[2]) + memUsed = (Convert-Num $f[3]) + memFree = (Convert-Num $f[4]) + utilGpu = (Convert-Num $f[5]) + utilMem = (Convert-Num $f[6]) + temp = (Convert-Num $f[7]) + power = (Convert-Num $f[8]) + powerMax = (Convert-Num $f[9]) + }) + } + } else { + $msg = "nvidia-smi 退出码 $code" + if ($rawErrs.Count -gt 0) { $msg += ':' + ([string]$rawErrs[0]).Trim() } + $res.smiErr = $msg + } + + # ------------- 2) nvidia-smi:CUDA 计算进程显存 ------------- + $craw = & $NvSmiPath $ComputeArgs 2>&1 + $ccode = $LASTEXITCODE + $crawLines = @($craw | Where-Object { $_ -is [string] }) + if ($ccode -eq 0 -and $crawLines.Count -gt 0) { + foreach ($line in $crawLines) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $f = @(($line -split ',') | ForEach-Object { $_.Trim() }) + if ($f.Count -lt 3) { continue } + $pidv = 0 + if (-not [int]::TryParse($f[0], [ref]$pidv)) { continue } + $res.procs += , ([pscustomobject]@{ + pid = $pidv + path = $f[1] + cudaMiB = (Convert-Num $f[2]) + }) + } + } + } catch { + $res.smiErr = $_.Exception.Message + } + } else { + $res.smiErr = 'nvidia-smi 未找到' + } + + # ------------- 3) Windows 计数器:逐进程显存 / GPU 占用 ------------- + $memByPid = @{} + $utilByPid = @{} + try { + $mem = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUProcessMemory -ErrorAction Stop + $res.cntOk = $true + $luids = @{} + foreach ($m in @($mem)) { + if ($m.Name -notmatch '^pid_(\d+)_luid_(0x[0-9A-Fa-f]+)_(0x[0-9A-Fa-f]+)_phys_(\d+)$') { continue } + $pidv = [int]$Matches[1] + $luids[$Matches[2] + '_' + $Matches[3]] = $true + $bytes = 0.0 + [void][double]::TryParse([string]$m.LocalUsage, [ref]$bytes) + if ($bytes -lt 0) { $bytes = 0 } + if ($memByPid.ContainsKey($pidv)) { $memByPid[$pidv] += $bytes } else { $memByPid[$pidv] = $bytes } + } + $res.luidCount = $luids.Count + + $eng = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine -ErrorAction SilentlyContinue + foreach ($e in @($eng)) { + if ($e.Name -notmatch '^pid_(\d+)_luid_') { continue } + $pidv = [int]$Matches[1] + $u = 0.0 + [void][double]::TryParse([string]$e.UtilizationPercentage, [ref]$u) + if ($u -lt 0) { $u = 0 } + if ($utilByPid.ContainsKey($pidv)) { $utilByPid[$pidv] += $u } else { $utilByPid[$pidv] = $u } + } + } catch { + $res.cntErr = $_.Exception.Message + } + + # ---------------- 4) 进程元信息 ---------------- + $procMeta = @{} + foreach ($p in @(Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue)) { + $procMeta[[int]$p.ProcessId] = $p + } + $psMeta = @{} + foreach ($p in @(Get-Process -ErrorAction SilentlyContinue)) { + $psMeta[[int]$p.Id] = $p + } + + # ---------------- 5) 合并 ---------------- + $allPids = @{} + foreach ($k in $memByPid.Keys) { $allPids[$k] = $true } + foreach ($k in $utilByPid.Keys) { $allPids[$k] = $true } + $cudaMap = @{} + foreach ($c in $res.procs) { $cudaMap[[int]$c.pid] = $c } + foreach ($k in $cudaMap.Keys) { $allPids[$k] = $true } + + $rows = New-Object System.Collections.ArrayList + $sumProc = 0.0 + foreach ($pidv in $allPids.Keys) { + $cudaMiB = $null + if ($cudaMap.ContainsKey($pidv)) { $cudaMiB = $cudaMap[$pidv].cudaMiB } + $cntMiB = $null + if ($memByPid.ContainsKey($pidv)) { $cntMiB = [math]::Round($memByPid[$pidv] / 1MB, 1) } + $util = 0.0 + if ($utilByPid.ContainsKey($pidv)) { $util = [math]::Round($utilByPid[$pidv], 1) } + + $src = '' + $vram = 0.0 + if ($null -ne $cudaMiB -and $null -ne $cntMiB) { + $vram = [math]::Max($cudaMiB, $cntMiB); $src = 'CUDA+WDDM' + } elseif ($null -ne $cudaMiB) { + $vram = $cudaMiB; $src = 'CUDA' + } elseif ($null -ne $cntMiB) { + $vram = $cntMiB; $src = 'WDDM' + } + + if ($vram -lt 1 -and $util -lt 0.5) { continue } + $sumProc += $vram + + $name = ''; $path = ''; $cmd = ''; $title = '' + if ($procMeta.ContainsKey($pidv)) { + $pm = $procMeta[$pidv] + $name = [string]$pm.Name + $path = [string]$pm.ExecutablePath + $cmd = [string]$pm.CommandLine + } + if ($psMeta.ContainsKey($pidv)) { + $ps = $psMeta[$pidv] + if ([string]::IsNullOrWhiteSpace($name)) { $name = $ps.ProcessName + '.exe' } + if ([string]::IsNullOrWhiteSpace($path)) { try { $path = [string]$ps.Path } catch { } } + try { $title = [string]$ps.MainWindowTitle } catch { } + } + if ([string]::IsNullOrWhiteSpace($name)) { $name = '(已退出? PID ' + $pidv + ')' } + if ([string]::IsNullOrWhiteSpace($path) -and $cudaMap.ContainsKey($pidv)) { $path = $cudaMap[$pidv].path } + + $desc = $title + if ([string]::IsNullOrWhiteSpace($desc) -and -not [string]::IsNullOrWhiteSpace($cmd)) { + $desc = $cmd + if ($desc.Length -gt 200) { $desc = $desc.Substring(0, 200) + '...' } + } + + [void]$rows.Add([pscustomobject]@{ + pid = $pidv + name = $name + vramMiB = [math]::Round($vram, 1) + util = [math]::Min($util, 100.0) + src = $src + desc = $desc + path = $path + }) + } + + $res.procs = @($rows.ToArray() | Sort-Object -Property vramMiB -Descending) + $res.sumProcMiB = [math]::Round($sumProc, 1) + $tot = 0.0; $used = 0.0 + foreach ($g in $res.gpus) { + if ($null -ne $g.memTotal) { $tot += $g.memTotal } + if ($null -ne $g.memUsed) { $used += $g.memUsed } + } + $res.smiTotalMiB = [math]::Round($tot, 1) + $res.smiUsedMiB = [math]::Round($used, 1) + return ($res | ConvertTo-Json -Depth 6 -Compress) +} + +function Invoke-Collect { + param([string]$NvSmiPath) + $json = & $script:CollectScript $NvSmiPath + if ([string]::IsNullOrWhiteSpace($json)) { throw '采集脚本未返回数据' } + return ($json | ConvertFrom-Json) +} + +# =========================================================================== +# 自检模式(无界面) +# =========================================================================== +if ($SelfTest) { + $smi = Find-NvSmiPath + Write-Host ("== {0} {1} 自检 ==" -f $script:AppName, $script:AppVersion) -ForegroundColor Cyan + Write-Host ("nvidia-smi : {0}" -f $(if ($smi) { $smi } else { '未找到' })) + Write-Host ("管理员 : {0}" -f (Test-IsAdmin)) + $sw = [Diagnostics.Stopwatch]::StartNew() + $d = Invoke-Collect -NvSmiPath $smi + Write-Host ("采集耗时 : {0} ms" -f $sw.ElapsedMilliseconds) + Write-Host ("nvidia-smi : smiOk={0} {1}" -f $d.smiOk, $d.smiErr) + Write-Host ("性能计数器 : cntOk={0} {1}" -f $d.cntOk, $d.cntErr) + Write-Host '' + foreach ($g in @($d.gpus)) { + Write-Host ("GPU {0}: {1}" -f $g.index, $g.name) -ForegroundColor Yellow + $pmaxTxt = 'N/A' + if ($null -ne $g.powerMax) { $pmaxTxt = ('{0:N0} W' -f $g.powerMax) } + $pwrTxt = 'N/A' + if ($null -ne $g.power) { $pwrTxt = ('{0:N1} W' -f $g.power) } + Write-Host (" 显存 {0} / {1} MiB (空闲 {2}) GPU利用率 {3}% 带宽利用率 {4}% 温度 {5}C 功耗 {6} / 上限 {7}" -f ` + $g.memUsed, $g.memTotal, $g.memFree, $g.utilGpu, $g.utilMem, $g.temp, $pwrTxt, $pmaxTxt) + } + if ($d.smiOk) { + Write-Host ("`n进程级合计 {0} MiB ;nvidia-smi 报告已用 {1} MiB ;差值 {2} MiB(驱动/系统保留)" -f ` + $d.sumProcMiB, $d.smiUsedMiB, [math]::Round($d.smiUsedMiB - $d.sumProcMiB, 1)) -ForegroundColor Green + } else { + Write-Host ("`n进程级合计 {0} MiB (nvidia-smi 不可用,无法与驱动报告的总量对比)" -f $d.sumProcMiB) -ForegroundColor Green + } + Write-Host ("`n{0,-8} {1,-34} {2,10} {3,8} {4,-11} {5}" -f 'PID', '进程名', '显存(MB)', 'GPU%', '来源', '说明') + Write-Host ('-' * 120) + foreach ($p in @($d.procs)) { + $desc = [string]$p.desc; if ($desc.Length -gt 44) { $desc = $desc.Substring(0, 44) + '...' } + Write-Host ("{0,-8} {1,-34} {2,10:N1} {3,8:N1} {4,-11} {5}" -f $p.pid, $p.name, $p.vramMiB, $p.util, $p.src, $desc) + } + Write-Host ("`n共 {0} 个进程占用 GPU 资源。" -f @($d.procs).Count) + exit 0 +} + +# =========================================================================== +# 命令行结束进程模式(无界面) +# powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -KillPid 1234,5678 -Force +# =========================================================================== +if ($KillPid -and @($KillPid).Count -gt 0) { + Write-Host ("== {0} {1} 结束进程 ==" -f $script:AppName, $script:AppVersion) -ForegroundColor Cyan + if (-not (Test-IsAdmin)) { Write-Host '[!] 当前不是管理员,其他用户的进程可能无法结束。' -ForegroundColor Yellow } + + $smi = Find-NvSmiPath + $d = $null + try { $d = Invoke-Collect -NvSmiPath $smi } catch { Write-Host ("[!] 采集失败:{0}" -f $_.Exception.Message) -ForegroundColor Yellow } + + $targets = @() + foreach ($id in @($KillPid)) { + $id = [int]$id + $name = ''; $vram = 0.0 + if ($d) { + $info = @($d.procs) | Where-Object { [int]$_.pid -eq $id } | Select-Object -First 1 + if ($info) { $name = [string]$info.name; $vram = [double]$info.vramMiB } + } + if ([string]::IsNullOrWhiteSpace($name)) { + $proc = Get-Process -Id $id -ErrorAction SilentlyContinue + if ($proc) { $name = $proc.ProcessName + '.exe' } + } + if ([string]::IsNullOrWhiteSpace($name)) { $name = '(未知进程)' } + $targets += [pscustomobject]@{ Pid = $id; Name = $name; Vram = $vram } + } + + # 命令行模式下没有确认对话框:系统外壳/关键进程默认跳过,必须显式加 -Force + $pre = Stop-GpuProcess -Targets $targets -DryRun + if ($pre.Warned.Count -gt 0 -and -not $Force) { + foreach ($t in $pre.Warned) { + Write-Host ("[跳过] {0} (PID {1}):系统外壳/关键进程,需显式加 -Force 才会结束" -f $t.Name, $t.Pid) -ForegroundColor Yellow + } + $targets = @($pre.Ok) + } + + $res = Stop-GpuProcess -Targets $targets -Force:$Force + foreach ($t in $res.Blocked) { + Write-Host ("[阻止] {0} (PID {1}):系统关键进程,或本工具自身/父进程" -f $t.Name, $t.Pid) -ForegroundColor Red + } + foreach ($m in $res.Messages) { Write-Host ("[执行] " + $m) -ForegroundColor Green } + foreach ($t in $res.Failed) { Write-Host ("[失败] {0} (PID {1})" -f $t.Name, $t.Pid) -ForegroundColor Red } + Write-Host ("`n共结束 {0} 个进程,预计释放 {1:N1} MB 显存。" -f @($res.Killed).Count, $res.FreedMiB) -ForegroundColor Cyan + exit 0 +} + +# =========================================================================== +# GUI +# =========================================================================== +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() +[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) + +# 双缓冲面板(消除重绘闪烁);编译失败则回退到普通 Panel +$panelClass = 'GpuTool.BufferedPanel' +if (-not ($panelClass -as [type])) { + try { + Add-Type -TypeDefinition @' +using System; +using System.Windows.Forms; +namespace GpuTool { + public class BufferedPanel : Panel { + public BufferedPanel() { this.DoubleBuffered = true; this.ResizeRedraw = true; } + } +} +'@ -ReferencedAssemblies 'System.Windows.Forms', 'System.Drawing' -ErrorAction Stop + } catch { + $panelClass = 'System.Windows.Forms.Panel' + } +} + +# --------------------------- 配色 --------------------------- +$C_BG = Get-Argb 255 24 26 32 +$C_PANEL = Get-Argb 255 32 35 43 +$C_CARD = Get-Argb 255 38 42 52 +$C_LINE = Get-Argb 255 56 61 74 +$C_FG = Get-Argb 255 228 231 238 +$C_FG_DIM = Get-Argb 255 150 157 172 +$C_ACCENT = Get-Argb 255 76 160 255 +$C_GREEN = Get-Argb 255 70 200 120 +$C_AMBER = Get-Argb 255 240 180 60 +$C_RED = Get-Argb 255 235 80 80 +$C_PURPLE = Get-Argb 255 170 120 255 + +$fontUI = New-Object System.Drawing.Font('Microsoft YaHei UI', 9) +$fontSmall = New-Object System.Drawing.Font('Microsoft YaHei UI', 8) +$fontAxis = New-Object System.Drawing.Font('Microsoft YaHei UI', 7.5) +$fontBold = New-Object System.Drawing.Font('Microsoft YaHei UI', 10, [System.Drawing.FontStyle]::Bold) +$fontBig = New-Object System.Drawing.Font('Microsoft YaHei UI', 14, [System.Drawing.FontStyle]::Bold) +$fontTitle = New-Object System.Drawing.Font('Microsoft YaHei UI', 12, [System.Drawing.FontStyle]::Bold) +$fontMono = New-Object System.Drawing.Font('Consolas', 9) + +function New-Label { + param($Text, $X, $Y, $W, $H, $Font = $fontUI, $Color = $C_FG, $Align = 'MiddleLeft') + $l = New-Object System.Windows.Forms.Label + $l.Text = $Text + $l.Location = New-Object System.Drawing.Point($X, $Y) + $l.Size = New-Object System.Drawing.Size($W, $H) + $l.Font = $Font; $l.ForeColor = $Color + $l.BackColor = [System.Drawing.Color]::Transparent + $l.TextAlign = [System.Enum]::Parse([System.Drawing.ContentAlignment], $Align) + return $l +} + +function New-Button { + param($Text, $X, $Y, $W, $H, $Back = $C_PANEL, $Fore = $C_FG, $Font = $fontUI) + $b = New-Object System.Windows.Forms.Button + $b.Text = $Text + $b.Location = New-Object System.Drawing.Point($X, $Y) + $b.Size = New-Object System.Drawing.Size($W, $H) + $b.FlatStyle = 'Flat' + $b.FlatAppearance.BorderColor = $C_LINE + $b.BackColor = $Back; $b.ForeColor = $Fore; $b.Font = $Font + $b.UseVisualStyleBackColor = $false + return $b +} + +# --------------------------- 主窗体 --------------------------- +# 尺寸与位置都由「屏幕可用区域」显式算出并夹取: +# StartPosition=CenterScreen 在某些 DPI/多屏环境下会把窗口放到屏幕外, +# 导致底部状态栏被截断,因此这里改为 Manual + 手动居中。 +$wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea +$ncW = 16 # 左右边框近似值 +$ncH = 39 # 标题栏近似值 +$clientW = [int][math]::Min(1220, [math]::Max(980, $wa.Width - $ncW - 24)) +$clientH = [int][math]::Min(900, [math]::Max(600, $wa.Height - $ncH - 24)) +$outerW = $clientW + $ncW +$outerH = $clientH + $ncH +$posX = [int]($wa.X + [math]::Max(0, ($wa.Width - $outerW) / 2)) +$posY = [int]($wa.Y + [math]::Max(0, ($wa.Height - $outerH) / 2)) + +$form = New-Object System.Windows.Forms.Form +$form.Text = "$($script:AppName) $($script:AppVersion)" +$form.ClientSize = New-Object System.Drawing.Size($clientW, $clientH) +$form.MinimumSize = New-Object System.Drawing.Size([int][math]::Min(1020, $clientW), [int][math]::Min(840, $outerH)) +$form.StartPosition = 'Manual' +$form.Location = New-Object System.Drawing.Point($posX, $posY) +$form.BackColor = $C_BG +$form.ForeColor = $C_FG +$form.Font = $fontUI +$form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None +$form.KeyPreview = $true + +# ---------- 顶部标题栏 ---------- +$header = New-Object System.Windows.Forms.Panel +$header.Location = New-Object System.Drawing.Point(0, 0) +$header.Size = New-Object System.Drawing.Size($clientW, 46) +$header.BackColor = $C_PANEL +$header.Anchor = 'Top,Left,Right' +$form.Controls.Add($header) + +$header.Controls.Add((New-Label $script:AppName 12 8 300 30 $fontTitle $C_FG)) + +$lblGpuName = New-Label '' 316 8 430 30 $fontUI $C_FG_DIM +$header.Controls.Add($lblGpuName) + +$lblAdmin = New-Label '' 750 8 250 30 $fontBold $C_RED 'MiddleRight' +$lblAdmin.Anchor = 'Top,Right' +$header.Controls.Add($lblAdmin) + +$btnElevate = New-Button '以管理员身份重启' 1010 8 195 30 (Get-Argb 255 120 60 40) $C_FG +$btnElevate.Anchor = 'Top,Right' +$header.Controls.Add($btnElevate) + +# ---------- 工具栏 ---------- +$toolbar = New-Object System.Windows.Forms.Panel +$toolbar.Location = New-Object System.Drawing.Point(0, 46) +$toolbar.Size = New-Object System.Drawing.Size($clientW, 40) +$toolbar.BackColor = $C_BG +$toolbar.Anchor = 'Top,Left,Right' +$form.Controls.Add($toolbar) + +$toolbar.Controls.Add((New-Label '刷新间隔' 12 6 60 28 $fontUI $C_FG_DIM)) + +$cmbInterval = New-Object System.Windows.Forms.ComboBox +$cmbInterval.DropDownStyle = 'DropDownList' +$cmbInterval.Location = New-Object System.Drawing.Point(74, 8) +$cmbInterval.Size = New-Object System.Drawing.Size(76, 26) +$cmbInterval.FlatStyle = 'Flat' +$cmbInterval.BackColor = $C_CARD; $cmbInterval.ForeColor = $C_FG +[void]$cmbInterval.Items.AddRange(@('1 秒', '2 秒', '3 秒', '5 秒', '10 秒')) +$cmbInterval.SelectedIndex = $(if ($script:Config.Interval -ge 10) { 4 } elseif ($script:Config.Interval -ge 5) { 3 } elseif ($script:Config.Interval -ge 3) { 2 } elseif ($script:Config.Interval -ge 2) { 1 } else { 0 }) +$toolbar.Controls.Add($cmbInterval) + +$chkAuto = New-Object System.Windows.Forms.CheckBox +$chkAuto.Text = '自动刷新'; $chkAuto.Checked = $true +$chkAuto.Location = New-Object System.Drawing.Point(160, 10) +$chkAuto.Size = New-Object System.Drawing.Size(84, 24) +$chkAuto.ForeColor = $C_FG +$chkAuto.BackColor = [System.Drawing.Color]::Transparent +$toolbar.Controls.Add($chkAuto) + +$chkOnlyGpu = New-Object System.Windows.Forms.CheckBox +$chkOnlyGpu.Text = '只看显存 ≥ 50MB' +$chkOnlyGpu.Checked = $false +$chkOnlyGpu.Location = New-Object System.Drawing.Point(248, 10) +$chkOnlyGpu.Size = New-Object System.Drawing.Size(150, 24) +$chkOnlyGpu.ForeColor = $C_FG +$chkOnlyGpu.BackColor = [System.Drawing.Color]::Transparent +$toolbar.Controls.Add($chkOnlyGpu) + +$btnRefresh = New-Button '立即刷新 (F5)' 406 6 118 28 $C_ACCENT ([System.Drawing.Color]::White) +$toolbar.Controls.Add($btnRefresh) + +$btnKill = New-Button '结束选中进程 (Del)' 532 6 158 28 (Get-Argb 255 150 45 45) $C_FG +$toolbar.Controls.Add($btnKill) + +$btnKillForce = New-Button '强制结束' 698 6 86 28 (Get-Argb 255 110 30 30) $C_FG +$toolbar.Controls.Add($btnKillForce) + +$btnExport = New-Button '导出监测数据' 792 6 108 28 $C_PANEL $C_FG +$toolbar.Controls.Add($btnExport) + +$lblSummary = New-Label '' 906 6 306 28 $fontUI $C_FG_DIM 'MiddleRight' +$lblSummary.Anchor = 'Top,Right' +$toolbar.Controls.Add($lblSummary) + +# ---------- 指标卡片区 ---------- +$stats = New-Object $panelClass +$stats.Location = New-Object System.Drawing.Point(0, 86) +$stats.Size = New-Object System.Drawing.Size($clientW, 190) +$stats.BackColor = $C_BG +$stats.Anchor = 'Top,Left,Right' +$form.Controls.Add($stats) + +# ---------- 进程表格 ---------- +$grid = New-Object System.Windows.Forms.DataGridView +$grid.Location = New-Object System.Drawing.Point(10, 286) +$grid.Size = New-Object System.Drawing.Size(($clientW - 20), 330) +$grid.Anchor = 'Top,Left,Right' +$grid.BackgroundColor = $C_PANEL +$grid.BorderStyle = 'None' +$grid.GridColor = $C_LINE +$grid.EnableHeadersVisualStyles = $false +$grid.ColumnHeadersDefaultCellStyle.BackColor = $C_CARD +$grid.ColumnHeadersDefaultCellStyle.ForeColor = $C_FG +$grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = $C_CARD +$grid.ColumnHeadersDefaultCellStyle.Font = $fontBold +$grid.ColumnHeadersHeight = 30 +$grid.ColumnHeadersHeightSizeMode = 'DisableResizing' +$grid.DefaultCellStyle.BackColor = $C_PANEL +$grid.DefaultCellStyle.ForeColor = $C_FG +$grid.DefaultCellStyle.SelectionBackColor = (Get-Argb 255 40 80 140) +$grid.DefaultCellStyle.SelectionForeColor = [System.Drawing.Color]::White +$grid.AlternatingRowsDefaultCellStyle.BackColor = Get-Argb 255 28 31 38 +$grid.RowHeadersVisible = $false +$grid.AllowUserToAddRows = $false +$grid.AllowUserToDeleteRows = $false +$grid.AllowUserToResizeRows = $false +$grid.ReadOnly = $true +$grid.SelectionMode = 'FullRowSelect' +$grid.MultiSelect = $true +$grid.RowTemplate.Height = 24 +$grid.AutoGenerateColumns = $false +$grid.AutoSizeColumnsMode = 'None' +$form.Controls.Add($grid) + +try { + $pi = [System.Windows.Forms.DataGridView].GetProperty('DoubleBuffered', [System.Reflection.BindingFlags]'Instance,NonPublic') + $pi.SetValue($grid, $true, $null) +} catch { } + +$dt = New-Object System.Data.DataTable 'Procs' +[void]$dt.Columns.Add('PID', [int]) +[void]$dt.Columns.Add('Name', [string]) +[void]$dt.Columns.Add('VramMiB', [double]) +[void]$dt.Columns.Add('VramPct', [double]) +[void]$dt.Columns.Add('Util', [double]) +[void]$dt.Columns.Add('Src', [string]) +[void]$dt.Columns.Add('Desc', [string]) +[void]$dt.Columns.Add('Path', [string]) +$dt.PrimaryKey = @($dt.Columns['PID']) + +$colDefs = @( + @{ N = 'PID'; H = 'PID'; W = 68; F = ''; A = 'MiddleCenter' }, + @{ N = 'Name'; H = '进程名'; W = 190; F = ''; A = 'MiddleLeft' }, + @{ N = 'VramMiB'; H = '显存 (MB)'; W = 100; F = 'N1'; A = 'MiddleRight' }, + @{ N = 'VramPct'; H = '显存占比'; W = 84; F = 'N1'; A = 'MiddleRight' }, + @{ N = 'Util'; H = 'GPU 占用 %'; W = 94; F = 'N1'; A = 'MiddleRight' }, + @{ N = 'Src'; H = '数据来源'; W = 96; F = ''; A = 'MiddleCenter' }, + @{ N = 'Desc'; H = '窗口标题 / 命令行'; W = 320; F = ''; A = 'MiddleLeft' }, + @{ N = 'Path'; H = '可执行文件路径'; W = 240; F = ''; A = 'MiddleLeft' } +) +foreach ($c in $colDefs) { + $col = New-Object System.Windows.Forms.DataGridViewTextBoxColumn + $col.Name = $c.N + $col.HeaderText = $c.H + $col.DataPropertyName = $c.N + $col.Width = $c.W + $col.SortMode = 'Automatic' + $col.DefaultCellStyle.Alignment = [System.Enum]::Parse([System.Windows.Forms.DataGridViewContentAlignment], $c.A) + if ($c.F) { $col.DefaultCellStyle.Format = $c.F } + [void]$grid.Columns.Add($col) +} +$grid.DataSource = $dt +$dt.DefaultView.Sort = 'VramMiB DESC' + +# ---------- 图表区 ---------- +$chart = New-Object $panelClass +$chart.Location = New-Object System.Drawing.Point(10, 626) +$chart.Size = New-Object System.Drawing.Size(($clientW - 20), 250) +$chart.Anchor = 'Top,Left,Right,Bottom' +$chart.BackColor = $C_PANEL +$form.Controls.Add($chart) + +# ---------- 状态栏 ---------- +$status = New-Object System.Windows.Forms.StatusStrip +$status.BackColor = $C_PANEL +$status.ForeColor = $C_FG_DIM +$status.SizingGrip = $false +$lblStatus = New-Object System.Windows.Forms.ToolStripStatusLabel +$lblStatus.Text = '正在初始化...' +$lblStatus.Spring = $true +$lblStatus.TextAlign = 'MiddleLeft' +[void]$status.Items.Add($lblStatus) +$lblFreed = New-Object System.Windows.Forms.ToolStripStatusLabel +$lblFreed.Text = '本次已释放: 0 MB' +[void]$status.Items.Add($lblFreed) +$form.Controls.Add($status) + +# ---------- 右键菜单 ---------- +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$menu.BackColor = $C_CARD; $menu.ForeColor = $C_FG +$miKill = $menu.Items.Add('结束进程') +$miForce = $menu.Items.Add('强制结束进程') +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +$miCopy = $menu.Items.Add('复制 PID') +$miPath = $menu.Items.Add('打开文件所在位置') +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +$miDetail = $menu.Items.Add('查看详细信息') +$grid.ContextMenuStrip = $menu + +# =========================================================================== +# 运行状态 +# =========================================================================== +$script:State = @{ + Gpu = $null + GpuName = '' + TotalMiB = 0.0 + SumProcMiB = 0.0 + SmiUsedMiB = 0.0 + Rows = @() + Hist = New-Object System.Collections.ArrayList + NvSmi = Find-NvSmiPath + LastTs = '' + Admin = Test-IsAdmin + FreedMiB = 0.0 + Busy = $false + Handle = $null + Ps = $null + Rs = $null + Ready = $false + NextRun = [datetime]::MinValue + Err = '' +} + +# =========================================================================== +# 绘制:圆角矩形 +# =========================================================================== +function New-RoundRect { + param([single]$X, [single]$Y, [single]$W, [single]$H, [single]$R) + $p = New-Object System.Drawing.Drawing2D.GraphicsPath + $d = $R * 2 + $p.AddArc($X, $Y, $d, $d, 180, 90) + $p.AddArc($X + $W - $d, $Y, $d, $d, 270, 90) + $p.AddArc($X + $W - $d, $Y + $H - $d, $d, $d, 0, 90) + $p.AddArc($X, $Y + $H - $d, $d, $d, 90, 90) + $p.CloseFigure() + return $p +} + +# =========================================================================== +# 绘制:指标卡片(3 列 × 2 行) +# =========================================================================== +$stats.Add_Paint({ + param($sender, $e) + $g = $e.Graphics + $g.SmoothingMode = 'AntiAlias' + $g.Clear($C_BG) + + $W = $sender.Width + $padX = 6; $padY = 4 + $cols = 3; $rows = 2 + $cw = [int](($W - $padX * ($cols + 1)) / $cols) + $ch = [int](($sender.Height - $padY * ($rows + 1)) / $rows) + + $gpu = $script:State.Gpu + $totMiB = $script:State.TotalMiB + $usedMiB = 0.0; $utilGpu = 0.0; $utilMem = 0.0 + $temp = $null; $power = $null; $pmax = $null + if ($gpu) { + if ($null -ne $gpu.memUsed) { $usedMiB = [double]$gpu.memUsed } + if ($null -ne $gpu.utilGpu) { $utilGpu = [double]$gpu.utilGpu } + if ($null -ne $gpu.utilMem) { $utilMem = [double]$gpu.utilMem } + $temp = $gpu.temp; $power = $gpu.power; $pmax = $gpu.powerMax + } + $memPct = 0.0 + if ($totMiB -gt 0) { $memPct = [math]::Min(100.0, $usedMiB / $totMiB * 100.0) } + + $unattributed = [math]::Max(0.0, $script:State.SmiUsedMiB - $script:State.SumProcMiB) + $sumPct = 0.0 + if ($script:State.SmiUsedMiB -gt 0) { $sumPct = [math]::Min(100.0, $script:State.SumProcMiB / $script:State.SmiUsedMiB * 100.0) } + $tempBar = 0.0 + if ($null -ne $temp) { $tempBar = [math]::Min(100.0, [double]$temp) } + $powBar = 0.0 + if ($null -ne $power -and $null -ne $pmax -and [double]$pmax -gt 0) { $powBar = [math]::Min(100.0, [double]$power / [double]$pmax * 100.0) } + + $cards = @( + @{ T = '显存占用 (nvidia-smi)'; V = ('{0:N0} / {1:N0} MB' -f $usedMiB, $totMiB); S = ('{0:N1}% 空闲 {1:N0} MB' -f $memPct, [math]::Max(0, $totMiB - $usedMiB)); Bar = $memPct; K = 'mem' }, + @{ T = 'GPU 核心利用率'; V = ('{0:N0} %' -f $utilGpu); S = 'nvidia-smi utilization.gpu'; Bar = $utilGpu; K = 'util' }, + @{ T = '显存带宽利用率'; V = ('{0:N0} %' -f $utilMem); S = 'nvidia-smi utilization.memory'; Bar = $utilMem; K = 'mem' }, + @{ T = 'GPU 温度'; V = $(if ($null -ne $temp) { '{0:N0} °C' -f $temp } else { 'N/A' }); S = '核心温度'; Bar = $tempBar; K = 'temp' }, + @{ T = '功耗'; V = $(if ($null -ne $power) { '{0:N1} W' -f $power } else { 'N/A' }); S = $(if ($null -ne $pmax) { '上限 {0:N0} W' -f $pmax } else { '上限 N/A' }); Bar = $powBar; K = 'pow' }, + @{ T = '进程显存合计'; V = ('{0:N0} MB' -f $script:State.SumProcMiB); S = ('未归属(驱动/系统) {0:N0} MB' -f $unattributed); Bar = $sumPct; K = 'mem' } + ) + + for ($i = 0; $i -lt $cards.Count; $i++) { + $c = $cards[$i] + $r = [int][math]::Floor($i / $cols) + $col = $i % $cols + $x = $padX + $col * ($cw + $padX) + $y = $padY + $r * ($ch + $padY) + + $path = New-RoundRect $x $y $cw $ch 6 + $br = New-Object System.Drawing.SolidBrush($C_CARD) + $g.FillPath($br, $path) + $br.Dispose() + $pen = New-Object System.Drawing.Pen($C_LINE, 1) + $g.DrawPath($pen, $path) + $pen.Dispose() + $path.Dispose() + + $pctv = [double]$c.Bar + if ($pctv -lt 0) { $pctv = 0 } + if ($pctv -gt 100) { $pctv = 100 } + $vcol = $C_FG + switch ($c.K) { + 'mem' { if ($pctv -ge 90) { $vcol = $C_RED } elseif ($pctv -ge 70) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } } + 'util' { if ($pctv -ge 90) { $vcol = $C_RED } elseif ($pctv -ge 60) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } } + 'temp' { if ($null -eq $temp) { $vcol = $C_FG_DIM } elseif ($temp -ge 85) { $vcol = $C_RED } elseif ($temp -ge 70) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } } + 'pow' { if ($null -eq $power) { $vcol = $C_FG_DIM } else { $vcol = $C_ACCENT } } + } + + $b1 = New-Object System.Drawing.SolidBrush($C_FG_DIM) + $g.DrawString($c.T, $fontUI, $b1, ($x + 12), ($y + 8)) + $b2 = New-Object System.Drawing.SolidBrush($vcol) + $g.DrawString($c.V, $fontBig, $b2, ($x + 10), ($y + 26)) + + $barX = $x + 12; $barY = $y + $ch - 32; $barW = $cw - 24; $barH = 8 + $bg = New-Object System.Drawing.SolidBrush((Get-Argb 255 20 22 28)) + $g.FillRectangle($bg, $barX, $barY, $barW, $barH) + $bg.Dispose() + $fw = [int]($barW * $pctv / 100.0) + if ($fw -gt 0) { + $fbr = New-Object System.Drawing.SolidBrush($vcol) + $g.FillRectangle($fbr, $barX, $barY, $fw, $barH) + $fbr.Dispose() + } + $b3 = New-Object System.Drawing.SolidBrush($C_FG_DIM) + $g.DrawString($c.S, $fontSmall, $b3, ($x + 12), ($barY + 11)) + $b1.Dispose(); $b2.Dispose(); $b3.Dispose() + } +}) + +# =========================================================================== +# 绘制:实时曲线 +# =========================================================================== +$chart.Add_Paint({ + param($sender, $e) + $g = $e.Graphics + $g.SmoothingMode = 'AntiAlias' + $g.Clear($C_PANEL) + + $W = $sender.Width; $H = $sender.Height + $padL = 66; $padR = 56; $padT = 26; $padB = 22 + $plotW = $W - $padL - $padR + $plotH = $H - $padT - $padB + if ($plotW -lt 60 -or $plotH -lt 30) { return } + + $hist = $script:State.Hist + $n = $hist.Count + + $penGrid = New-Object System.Drawing.Pen((Get-Argb 255 52 57 68), 1) + $brAxis = New-Object System.Drawing.SolidBrush($C_FG_DIM) + $brTitle = New-Object System.Drawing.SolidBrush($C_FG) + $penMem = New-Object System.Drawing.Pen($C_ACCENT, 2) + $penUtil = New-Object System.Drawing.Pen($C_PURPLE, 2) + $brAccent = New-Object System.Drawing.SolidBrush($C_ACCENT) + $brPurple = New-Object System.Drawing.SolidBrush($C_PURPLE) + + for ($i = 0; $i -le 4; $i++) { + $y = $padT + $plotH * $i / 4.0 + $g.DrawLine($penGrid, $padL, $y, ($padL + $plotW), $y) + $s = [string]::Format('{0:N0}', ($script:State.TotalMiB * (4 - $i) / 4.0)) + $sz = $g.MeasureString($s, $fontAxis) + $g.DrawString($s, $fontAxis, $brAxis, ($padL - $sz.Width - 5), ($y - $sz.Height / 2)) + $g.DrawString(('{0:N0}%' -f (100.0 * (4 - $i) / 4.0)), $fontAxis, $brAxis, ($padL + $plotW + 5), ($y - $sz.Height / 2)) + } + + $g.DrawString('显存 / GPU 占用实时监测', $fontBold, $brTitle, $padL, 4) + + $lgX = $padL + 214 + $g.DrawLine($penMem, $lgX, 13, ($lgX + 22), 13) + $g.DrawString('显存占用 (MB)', $fontSmall, $brAxis, ($lgX + 26), 6) + $g.DrawLine($penUtil, ($lgX + 130), 13, ($lgX + 152), 13) + $g.DrawString('GPU 利用率 (%)', $fontSmall, $brAxis, ($lgX + 156), 6) + + if ($n -lt 2) { + $msg = '正在采集数据...' + if ($script:State.Err) { $msg = '采集异常: ' + $script:State.Err } + elseif ($n -eq 0) { $msg = '正在初始化 GPU 性能计数器,首次采集约需 5~15 秒...' } + $sz = $g.MeasureString($msg, $fontBold) + $g.DrawString($msg, $fontBold, $brAxis, ($padL + ($plotW - $sz.Width) / 2), ($padT + $plotH / 2 - 10)) + $penGrid.Dispose(); $brAxis.Dispose(); $brTitle.Dispose(); $penMem.Dispose(); $penUtil.Dispose(); $brAccent.Dispose(); $brPurple.Dispose() + return + } + + $maxSamples = [math]::Max(2, $plotW / 2) + $start = [int][math]::Max(0, $n - $maxSamples) + $cnt = $n - $start + $total = $script:State.TotalMiB + if ($total -le 0) { $total = 1 } + + $y90 = $padT + $plotH * 0.1 + $penWarn = New-Object System.Drawing.Pen((Get-Argb 200 235 80 80), 1) + $penWarn.DashStyle = 'Dash' + $g.DrawLine($penWarn, $padL, $y90, ($padL + $plotW), $y90) + $brWarn = New-Object System.Drawing.SolidBrush((Get-Argb 200 235 80 80)) + $g.DrawString('90%', $fontAxis, $brWarn, ($padL + 3), ($y90 - 14)) + + $ptsMem = New-Object System.Collections.ArrayList + $ptsUtil = New-Object System.Collections.ArrayList + for ($i = 0; $i -lt $cnt; $i++) { + # 注意:PowerShell 变量名不区分大小写,循环变量绝不能叫 $h, + # 否则会覆盖上面的面板高度 $H,导致后面的 ($H - 17) 报错。 + $sample = $hist[$start + $i] + $x = $padL + $plotW * ($i / [double]($cnt - 1)) + $mv = [double]$sample.mem + if ($mv -gt $total) { $mv = $total } + [void]$ptsMem.Add((New-Object System.Drawing.PointF($x, ($padT + $plotH * (1 - $mv / $total))))) + $uv = [double]$sample.util + if ($uv -lt 0) { $uv = 0 } + if ($uv -gt 100) { $uv = 100 } + [void]$ptsUtil.Add((New-Object System.Drawing.PointF($x, ($padT + $plotH * (1 - $uv / 100.0))))) + } + + if ($ptsMem.Count -ge 2) { + $poly = New-Object System.Drawing.Drawing2D.GraphicsPath + $poly.AddLines($ptsMem.ToArray()) + $last = $ptsMem[$ptsMem.Count - 1] + $first = $ptsMem[0] + $baseY = $padT + $plotH + $poly.AddLine($last.X, $last.Y, $last.X, $baseY) + $poly.AddLine($last.X, $baseY, $first.X, $baseY) + $poly.CloseFigure() + $fill = New-Object System.Drawing.SolidBrush((Get-Argb 60 76 160 255)) + $g.FillPath($fill, $poly) + $fill.Dispose(); $poly.Dispose() + + $g.DrawLines($penMem, $ptsMem.ToArray()) + $g.DrawLines($penUtil, $ptsUtil.ToArray()) + } + + # 注意:在方法调用的参数里直接写 $hist[$n - 1] 会被解析成 ($hist[$n]) - 1, + # 因此所有下标运算先行算好,再传给 DrawString。 + $idxLast = $n - 1 + $lastSample = $hist[$idxLast] + $firstSample = $hist[$start] + $capMem = '{0:N0} MB' -f [double]$lastSample.mem + $capUtil = '{0:N0} %' -f [double]$lastSample.util + $capAxis = '{0} ~ {1} ({2} 个采样点)' -f $firstSample.t, $lastSample.t, $n + $g.DrawString($capMem, $fontBold, $brAccent, ($padL + $plotW - 72), ($padT + 2)) + $g.DrawString($capUtil, $fontBold, $brPurple, ($padL + $plotW + 3), ($padT + 2)) + $g.DrawString($capAxis, $fontAxis, $brAxis, $padL, ($H - 17)) + + $penGrid.Dispose(); $brAxis.Dispose(); $brTitle.Dispose(); $penMem.Dispose(); $penUtil.Dispose() + $penWarn.Dispose(); $brWarn.Dispose(); $brAccent.Dispose(); $brPurple.Dispose() +}) + +# =========================================================================== +# 后台采集(Runspace 异步,避免界面卡顿) +# =========================================================================== +function Initialize-Collector { + if ($script:State.Ready) { return } + $rs = [runspacefactory]::CreateRunspace() + $rs.ApartmentState = 'MTA' + $rs.ThreadOptions = 'ReuseThread' + $rs.Open() + $ps = [powershell]::Create() + $ps.Runspace = $rs + $script:State.Rs = $rs + $script:State.Ps = $ps + $script:State.Ready = $true +} + +function Start-Collect { + Initialize-Collector + if ($script:State.Handle) { return } + $ps = $script:State.Ps + [void]$ps.AddScript($script:CollectScript) + [void]$ps.AddArgument($script:State.NvSmi) + $script:State.Handle = $ps.BeginInvoke() + $script:State.Busy = $true +} + +function Complete-Collect { + if (-not $script:State.Handle) { return $false } + $ps = $script:State.Ps + if (-not $script:State.Handle.IsCompleted) { return $false } + + $out = $null + try { $out = $ps.EndInvoke($script:State.Handle) } catch { $script:State.Err = $_.Exception.Message } + $script:State.Handle = $null + $script:State.Busy = $false + + $errs = @() + try { $errs = @($ps.Streams.Error) } catch { } + try { $ps.Commands.Clear() } catch { } + try { $ps.Streams.Error.Clear(); $ps.Streams.Warning.Clear() } catch { } + + if ($null -eq $out) { + if ($errs.Count -gt 0) { $script:State.Err = $errs[0].ToString() } + return $false + } + $text = ($out | Where-Object { $_ -is [string] } | Select-Object -Last 1) + if ([string]::IsNullOrWhiteSpace($text)) { + if ($errs.Count -gt 0) { $script:State.Err = $errs[0].ToString() } + return $false + } + try { + $data = $text | ConvertFrom-Json + } catch { + $script:State.Err = '解析采集结果失败: ' + $_.Exception.Message + return $false + } + Apply-Data $data + return $true +} + +function Apply-Data { + param($data) + $script:State.Err = '' + + $gpus = @($data.gpus) + $g = $null + if ($gpus.Count -gt 0) { $g = $gpus[0] } + $script:State.Gpu = $g + $script:State.TotalMiB = 0.0 + if ($g -and $null -ne $g.memTotal) { $script:State.TotalMiB = [double]$g.memTotal } + $script:State.SumProcMiB = 0.0 + $script:State.SmiUsedMiB = 0.0 + if ($null -ne $data.sumProcMiB) { $script:State.SumProcMiB = [double]$data.sumProcMiB } + if ($null -ne $data.smiUsedMiB) { $script:State.SmiUsedMiB = [double]$data.smiUsedMiB } + $script:State.LastTs = [string]$data.ts + + $rows = @($data.procs) + if ($chkOnlyGpu.Checked) { $rows = @($rows | Where-Object { $_.vramMiB -ge 50 }) } + $script:State.Rows = $rows + + $selPids = @{} + foreach ($r in @($grid.SelectedRows)) { + try { $selPids[[int]$r.Cells['PID'].Value] = $true } catch { } + } + + $seen = @{} + $changed = 0 + foreach ($p in $rows) { + $pidv = [int]$p.pid + $seen[$pidv] = $true + $row = $dt.Rows.Find($pidv) + if ($null -eq $row) { + $row = $dt.NewRow() + $row['PID'] = $pidv + $row['Name'] = ''; $row['Src'] = ''; $row['Desc'] = ''; $row['Path'] = '' + $dt.Rows.Add($row) + $changed++ + } + $pct = 0.0 + if ($script:State.TotalMiB -gt 0) { $pct = [math]::Round($p.vramMiB / $script:State.TotalMiB * 100, 2) } + $vals = @{ + 'Name' = [string]$p.name + 'VramMiB' = [double]$p.vramMiB + 'VramPct' = $pct + 'Util' = [double]$p.util + 'Src' = [string]$p.src + 'Desc' = [string]$p.desc + 'Path' = [string]$p.path + } + foreach ($k in $vals.Keys) { + if ("$($row[$k])" -ne "$($vals[$k])") { $row[$k] = $vals[$k]; $changed++ } + } + } + $stale = @() + foreach ($r in $dt.Rows) { if (-not $seen[[int]$r['PID']]) { $stale += $r } } + foreach ($r in $stale) { $dt.Rows.Remove($r); $changed++ } + + if ($selPids.Count -gt 0) { + $grid.ClearSelection() + foreach ($r in @($grid.Rows)) { + try { if ($selPids[[int]$r.Cells['PID'].Value]) { $r.Selected = $true } } catch { } + } + } + + $memNow = 0.0; $utilNow = 0.0 + if ($g) { + if ($null -ne $g.memUsed) { $memNow = [double]$g.memUsed } + if ($null -ne $g.utilGpu) { $utilNow = [double]$g.utilGpu } + } + [void]$script:State.Hist.Add([pscustomobject]@{ t = (Get-Date).ToString('HH:mm:ss'); mem = $memNow; util = $utilNow }) + while ($script:State.Hist.Count -gt 900) { $script:State.Hist.RemoveAt(0) } + + $stats.Invalidate() + $chart.Invalidate() + if ($changed -gt 0) { $grid.Invalidate() } + + # 状态栏 / 摘要 + $parts = @() + if ($script:State.LastTs) { $parts += ('更新于 ' + $script:State.LastTs) } + $parts += ('进程 ' + $rows.Count + ' 个') + if (-not $script:State.NvSmi) { $parts += '⚠ 未找到 nvidia-smi(仅性能计数器数据)' } + elseif (-not $data.smiOk) { $parts += ('⚠ nvidia-smi 异常: ' + $data.smiErr) } + if (-not $data.cntOk) { $parts += ('⚠ GPU 计数器异常: ' + $data.cntErr) } + if ($script:State.TotalMiB -gt 0) { $parts += ('显存 ' + ('{0:N1}' -f ($script:State.SmiUsedMiB / $script:State.TotalMiB * 100)) + '%') } + $lblStatus.Text = ($parts -join ' | ') + + if ($rows.Count -gt 0) { + $top = $rows[0] + $lblSummary.Text = ('占用最高: {0} (PID {1}) {2:N0} MB' -f $top.name, $top.pid, $top.vramMiB) + } else { + $lblSummary.Text = '当前没有进程占用显存' + } + + if ($g) { + $t = 'N/A' + if ($null -ne $g.temp) { $t = ('{0:N0} °C' -f $g.temp) } + $lblGpuName.Text = ('{0} · 显存 {1:N0} MB · 温度 {2}' -f $g.name, $g.memTotal, $t) + } elseif (-not $script:State.NvSmi) { + $lblGpuName.Text = '未检测到 nvidia-smi,GPU 级指标不可用' + } +} + +# =========================================================================== +# 结束进程 +# =========================================================================== +function Show-Msg { + param([string]$Text, [string]$Icon = 'Information', [string]$Buttons = 'OK') + $btns = [System.Enum]::Parse([System.Windows.Forms.MessageBoxButtons], $Buttons) + $ico = [System.Enum]::Parse([System.Windows.Forms.MessageBoxIcon], $Icon) + # 必须显式指定 owner=$form:否则对话框在某些窗口层级下会跑到主窗口后面, + # 表现为「点了按钮没反应」,而且此时线程被模态阻塞,两个按钮都会失效。 + if ($form -and $form.IsHandleCreated) { + return [System.Windows.Forms.MessageBox]::Show($form, $Text, $script:AppName, $btns, $ico) + } + return [System.Windows.Forms.MessageBox]::Show($Text, $script:AppName, $btns, $ico) +} + +function Show-HandlerError { + param($ErrorRecord, [string]$Where = '操作') + $m = '' + try { $m = [string]$ErrorRecord.Exception.Message } catch { $m = [string]$ErrorRecord } + $st = '' + try { $st = [string]$ErrorRecord.ScriptStackTrace } catch { } + [void](Show-Msg "$Where 执行失败:`n`n$m`n`n$st" 'Error') +} + +function Invoke-Kill { + param([switch]$Force) + + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { + [void](Show-Msg '请先在列表中选择要结束的进程(可按住 Ctrl / Shift 多选)。') + return + } + + $targets = @() + foreach ($r in $sel) { + $targets += [pscustomobject]@{ + Pid = [int]$r.Cells['PID'].Value + Name = [string]$r.Cells['Name'].Value + Vram = [double]$r.Cells['VramMiB'].Value + } + } + + # 先做一遍干跑,得到分类结果用于确认对话框 + $preview = Stop-GpuProcess -Targets $targets -Force:$Force -DryRun + if ($preview.Blocked.Count -gt 0) { + $msg = "以下进程属于系统关键进程或本工具自身,已阻止结束:`n`n" + foreach ($t in $preview.Blocked) { $msg += (" · {0} (PID {1})`n" -f $t.Name, $t.Pid) } + [void](Show-Msg $msg 'Warning') + } + $list = @($preview.Ok + $preview.Warned) + if ($list.Count -eq 0) { return } + + $sum = 0.0 + $msg = "即将结束以下 $($list.Count) 个进程:`n`n" + foreach ($t in $list) { + $msg += (" · {0} (PID {1}) 占用 {2:N1} MB`n" -f $t.Name, $t.Pid, $t.Vram) + $sum += $t.Vram + } + $msg += "`n预计可释放约 $([math]::Round($sum, 1)) MB 显存。`n" + if ($preview.Warned.Count -gt 0) { $msg += "`n⚠ 包含系统外壳/关键进程,结束后桌面或任务栏可能短暂闪烁、重启。`n" } + if (-not $script:State.Admin) { + $msg += "`n⚠ 当前不是管理员权限:结束其他用户的进程会失败(错误「拒绝访问」)。`n" + + " 请关闭本程序,用 GpuMemTool.exe 重新启动并在 UAC 窗口点「是」。`n" + } + $msg += "`n确定继续吗?" + + $icon = 'Warning' + if ($preview.Warned.Count -gt 0) { $icon = 'Error' } + if ((Show-Msg $msg $icon 'YesNo') -ne 'Yes') { return } + if ($preview.Warned.Count -gt 0) { + if ((Show-Msg "再次确认:其中包含系统外壳/关键进程(如 dwm、explorer、svchost)。`n`n真的要继续吗?" 'Error' 'YesNo') -ne 'Yes') { return } + } + + $res = Stop-GpuProcess -Targets $targets -Force:$Force -PumpGui + if ($res.Messages.Count -gt 0) { $lblStatus.Text = [string]$res.Messages[$res.Messages.Count - 1] } + $script:State.FreedMiB += $res.FreedMiB + $lblFreed.Text = ('本次已释放: {0:N1} MB' -f $script:State.FreedMiB) + + # ---- 结果反馈:成功/失败都必须让用户看得见 ---- + $okCount = @($res.Killed).Count + $failCount = @($res.Failed).Count + $report = "已结束 $okCount 个进程,释放约 $([math]::Round($res.FreedMiB, 1)) MB 显存。" + if ($failCount -gt 0) { + $report += "`n`n失败 $failCount 个:" + foreach ($m in @($res.Messages)) { + if ([string]$m -like '*失败*' -or [string]$m -like '*跳过*') { $report += "`n · $m" } + } + # 区分两种最常见的失败原因,给出可操作的建议 + $denied = $false + $stuck = $false + foreach ($m in @($res.Messages)) { + if ([string]$m -match '拒绝访问|Access is denied|无法终止|Cannot stop') { $denied = $true } + if ([string]$m -match '进程仍未退出') { $stuck = $true } + } + if ($stuck) { + $report += "`n`n进程仍未退出。这类进程通常已经卡死,或受驱动/反作弊保护,`n" + + "用户态程序(包括任务管理器)都无法结束它。`n`n建议:`n" + + " 1) 按 Win + Ctrl + Shift + B 重启显卡驱动(屏幕会闪一下,常能释放卡住的显存);`n" + + " 2) 若仍无效,重启电脑即可彻底回收这部分显存。" + } elseif ($denied) { + if (-not $script:State.Admin) { + $report += "`n`n原因:权限不足(错误「拒绝访问」)。`n" + + "请关闭本程序,双击 GpuMemTool.exe 重新启动,并在 UAC 窗口点「是」。" + } else { + $report += "`n`n原因:该进程受系统或反作弊/安全软件保护(例如带 BattlEye、EAC 的游戏,`n" + + "或带有驱动级保护的软件),Windows 不允许外部结束它——即使是管理员。`n" + + "建议:在该程序内正常退出;或用任务管理器再试一次(通常同样会被拒绝)。" + } + } + } + [void](Show-Msg $report $(if ($failCount -gt 0) { 'Warning' } else { 'Information' })) + if (-not $script:State.Busy) { Start-Collect } +} + +# =========================================================================== +# 事件绑定 +# =========================================================================== +$btnRefresh.Add_Click({ + if (-not $script:State.Busy) { Start-Collect } + else { $lblStatus.Text = '正在采集中,请稍候...' } +}) +# 结束进程的入口统一包一层 try/catch:程序是 -WindowStyle Hidden 启动的, +# 未捕获的异常会写进看不见的控制台,表现为「按钮点了没反应」,这里改成弹窗报错。 +$btnKill.Add_Click({ try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' } }) +$btnKillForce.Add_Click({ try { Invoke-Kill -Force } catch { Show-HandlerError $_ '强制结束进程' } }) +$miKill.Add_Click({ try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' } }) +$miForce.Add_Click({ try { Invoke-Kill -Force } catch { Show-HandlerError $_ '强制结束进程' } }) + +$miCopy.Add_Click({ + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { return } + $t = ($sel | ForEach-Object { $_.Cells['PID'].Value }) -join "`r`n" + [System.Windows.Forms.Clipboard]::SetText($t) + $lblStatus.Text = "已复制 $($sel.Count) 个 PID 到剪贴板" +}) + +$miPath.Add_Click({ + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { return } + $file = [string]$sel[0].Cells['Path'].Value + if ([string]::IsNullOrWhiteSpace($file)) { + [void](Show-Msg '该进程没有可用的文件路径(可能是系统进程,或需要管理员权限才能读取)。') + return + } + if (Test-Path -LiteralPath $file) { Start-Process explorer.exe -ArgumentList ('/select,"' + $file + '"') } + else { [void](Show-Msg "文件不存在:`n$file" 'Warning') } +}) + +$miDetail.Add_Click({ + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { return } + $r = $sel[0] + $pidv = [int]$r.Cells['PID'].Value + $txt = New-Object System.Collections.ArrayList + foreach ($c in $colDefs) { + if ($c.N -eq 'Desc' -or $c.N -eq 'Path') { continue } + [void]$txt.Add(("{0,-12}: {1}" -f $c.H, $r.Cells[$c.N].Value)) + } + [void]$txt.Add(("命令行 : {0}" -f $r.Cells['Desc'].Value)) + [void]$txt.Add(("路径 : {0}" -f $r.Cells['Path'].Value)) + [void]$txt.Add('') + [void]$txt.Add(("tasklist 输出 (PID $pidv):")) + [void]$txt.Add('-' * 70) + $out = & "$env:SystemRoot\System32\tasklist.exe" /FI "PID eq $pidv" /V /FO LIST 2>&1 + foreach ($line in @($out)) { [void]$txt.Add([string]$line) } + + $f2 = New-Object System.Windows.Forms.Form + $f2.Text = "进程详细信息 - PID $pidv" + $f2.ClientSize = New-Object System.Drawing.Size(740, 520) + $f2.StartPosition = 'CenterParent' + $f2.BackColor = $C_BG; $f2.ForeColor = $C_FG + $tb = New-Object System.Windows.Forms.TextBox + $tb.Multiline = $true; $tb.ReadOnly = $true; $tb.ScrollBars = 'Both' + $tb.Dock = 'Fill'; $tb.Font = $fontMono + $tb.BackColor = $C_PANEL; $tb.ForeColor = $C_FG + $tb.Text = ($txt.ToArray() -join "`r`n") + $f2.Controls.Add($tb) + [void]$f2.ShowDialog($form) + $f2.Dispose() +}) + +$grid.Add_CellDoubleClick({ + param($sender, $e) + if ($e.RowIndex -ge 0) { $miDetail.PerformClick() } +}) + +$grid.Add_KeyDown({ + param($sender, $e) + if ($e.KeyCode -eq 'Delete') { try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' }; $e.Handled = $true } +}) + +$form.Add_KeyDown({ + param($sender, $e) + if ($e.KeyCode -eq 'F5') { if (-not $script:State.Busy) { Start-Collect }; $e.Handled = $true } +}) + +$chkOnlyGpu.Add_CheckedChanged({ + if (-not $script:State.Busy) { Start-Collect } +}) + +$cmbInterval.Add_SelectedIndexChanged({ + $map = @(1, 2, 3, 5, 10) + $script:Config.Interval = $map[$cmbInterval.SelectedIndex] +}) + +$btnExport.Add_Click({ + $hist = $script:State.Hist + if ($hist.Count -eq 0) { + [void](Show-Msg '还没有监测数据可导出。') + return + } + $dlg = New-Object System.Windows.Forms.SaveFileDialog + $dlg.Filter = 'CSV 文件 (*.csv)|*.csv' + $dlg.FileName = 'GPU监测数据_' + (Get-Date).ToString('yyyyMMdd_HHmmss') + '.csv' + if ($dlg.ShowDialog() -ne 'OK') { return } + $lines = New-Object System.Collections.ArrayList + [void]$lines.Add('时间,显存已用(MB),显存总量(MB),显存占用(%),GPU利用率(%)') + $tot = $script:State.TotalMiB + foreach ($sample in $hist) { + $pct = '' + if ($tot -gt 0) { $pct = ('{0:N2}' -f ($sample.mem / $tot * 100)) } + [void]$lines.Add(('{0},{1},{2},{3},{4}' -f $sample.t, $sample.mem, $tot, $pct, $sample.util)) + } + $lines | Set-Content -LiteralPath $dlg.FileName -Encoding UTF8 + [void](Show-Msg "已导出到:`n$($dlg.FileName)") +}) + +$btnElevate.Add_Click({ + try { + $scriptPath = $PSCommandPath + if ([string]::IsNullOrWhiteSpace($scriptPath)) { $scriptPath = $MyInvocation.MyCommand.Path } + $arg = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $scriptPath + '" -Elevated' + Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $arg -Verb RunAs + $form.Close() + } catch { + [void](Show-Msg "提权失败:$($_.Exception.Message)`n`n请关闭后右键选择「以管理员身份运行」。" 'Error') + } +}) + +# 定时器:驱动异步采集与界面刷新 +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 200 +$timer.Add_Tick({ + if ($script:State.Handle) { [void](Complete-Collect) } + if ($chkAuto.Checked -and -not $script:State.Busy) { + if ((Get-Date) -ge $script:State.NextRun) { + Start-Collect + $script:State.NextRun = (Get-Date).AddSeconds([int]$script:Config.Interval) + } + } +}) + +# =========================================================================== +# 启动 +# =========================================================================== +# 全局异常兜底:任何界面回调里的未捕获异常都弹窗提示, +# 否则会被 -WindowStyle Hidden 的隐藏控制台吞掉,用户只看到「按钮没反应」。 +try { + [System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException) +} catch { } +try { + [System.Windows.Forms.Application]::add_ThreadException( + [System.Windows.Forms.ThreadExceptionEventHandler]{ + param($sender, $evt) + try { + $txt = "界面操作发生未处理的错误:`r`n`r`n" + $evt.Exception.Message + + "`r`n`r`n" + $evt.Exception.StackTrace + [void][System.Windows.Forms.MessageBox]::Show($form, $txt, "$($script:AppName) - 错误", 'OK', 'Error') + } catch { } + }) +} catch { } + +$form.Add_Shown({ + if ($script:State.Admin) { + $lblAdmin.Text = '✔ 管理员权限' + $lblAdmin.ForeColor = $C_GREEN + $btnElevate.Visible = $false + } else { + $lblAdmin.Text = '⚠ 提权被取消:部分进程无法结束' + $lblAdmin.ForeColor = $C_RED + $btnElevate.Visible = $true + } + if (-not $script:State.NvSmi) { + $lblStatus.Text = '⚠ 未找到 nvidia-smi,将只使用 Windows 性能计数器数据。' + } else { + $lblStatus.Text = '正在初始化 GPU 性能计数器,首次采集约需 5~15 秒...' + } + $timer.Start() + Start-Collect + $script:State.NextRun = (Get-Date).AddSeconds([int]$script:Config.Interval) +}) + +$form.Add_FormClosing({ + try { $timer.Stop() } catch { } + try { + if ($script:State.Handle) { $script:State.Ps.Stop() } + if ($script:State.Ps) { $script:State.Ps.Dispose() } + if ($script:State.Rs) { $script:State.Rs.Close(); $script:State.Rs.Dispose() } + } catch { } +}) + +[void]$form.ShowDialog() +$form.Dispose() diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb0ba07 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# GpuMemTool — Windows 显存监控与释放工具 + +基于 `nvidia-smi` + Windows 性能计数器,列出每个进程占用多少显存,可一键结束进程释放显存,并带显存 / GPU 占用率实时曲线监测。零依赖:系统自带 Windows PowerShell 5.1 + WinForms 即可运行。 + +本仓库包含两个版本: + +| 版本 | 文件 | 说明 | +| --- | --- | --- | +| v1.1 | `GpuMemTool.ps1` / `GpuMemTool.exe` | 稳定版,单文件 exe(约 180 KB),双击即用 | +| v2.0 | `new1022/GpuMemTool.ps1` | 性能优化版:采集单轮 2433ms → 约 420ms(GPU 空闲)/ 970ms(GPU 忙),并修复采集超时导致的背靠背采样问题 | + +> v2.0 的详细改动见脚本头部注释。 + +--- +## v1.1 版本说明(根目录文件) + +一个面向 Windows 的轻量显存(VRAM)监测与释放工具:**列出每个进程占用了多少显存,并可直接结束进程回收显存**,同时提供显存 / GPU 占用率的实时曲线监测。 + +基于 `nvidia-smi` + Windows 性能计数器,**零依赖** —— 只用系统自带的 Windows PowerShell 5.1 + WinForms,不需要安装 Python、.NET SDK 或任何第三方库。 + +**交付形态:单个 `GpuMemTool.exe`(约 180 KB),双击即用,启动时自动请求管理员权限。** +脚本已内嵌在 exe 内部,不需要附带任何其他文件。 + +> 本文件属于显存工具,与同目录的奶蛙桌宠项目(`README.md` / `src/` / `dist/`)无关。 + +## 文件说明 + +| 文件 | 作用 | +| --- | --- | +| **`GpuMemTool.exe`** | **交付主程序:单文件、免安装、双击运行**(内嵌脚本 + 图标 + UAC 提权逻辑) | +| `GpuMemTool.ps1` | 源码(可读可改;运行效果与 exe 完全一致,也会自己请求 UAC) | +| `RunAsAdmin.bat` | 备选入口:双击提权运行 `GpuMemTool.ps1` | +| `GpuMemTool-README.md` | 本说明 | + +> 只需要 `GpuMemTool.exe` 一个文件就能用,其余文件可以删除/保留随意。 + +## 使用方法 + +1. **双击 `GpuMemTool.exe`** → 弹出 UAC 窗口(标题为 `GpuMemTool.exe`)→ 点「是」→ 主界面出现。 + - 点「否」也不会报错:程序会继续以普通权限运行,界面右上角显示红色提示, + 可随时点「以管理员身份重启」再试(只是部分进程无法结束)。 +2. 首次启动约 5~15 秒完成性能计数器初始化(界面有提示),随后进入实时刷新。 + +### exe 的运行细节(透明说明) + +- 启动时会把内嵌脚本释放到 `%LOCALAPPDATA%\GpuMemTool\GpuMemTool.ps1`(每次校验内容, + 一致则不重写),然后用系统自带的 Windows PowerShell 运行它; +- 启动诊断日志:`%LOCALAPPDATA%\GpuMemTool\launcher.log`(仅在启动异常时才有用); +- exe 是免安装的绿色程序,不写注册表、不建服务;删除 exe 即可卸载 + (如需彻底清理,再删掉上面那个 `%LOCALAPPDATA%\GpuMemTool` 目录即可); +- exe 未做代码签名,从本机运行不会有 SmartScreen 提示;若通过网络/U盘拷贝过来可能被 + 标记为「来自 Internet」,此时右键 → 属性 → 勾选「解除锁定」即可。 + +## 界面说明 + +- **顶部工具栏**:刷新间隔(1/2/3/5/10 秒)、自动刷新、「只看显存 ≥ 50MB」过滤、 + 立即刷新(F5)、结束选中进程(Delete)、强制结束、导出监测数据;右上角显示占用最高的进程。 +- **6 个指标卡片**:显存占用(已用/总量 + 进度条,≥70% 变黄、≥90% 变红)、GPU 核心利用率、 + 显存带宽利用率、GPU 温度、功耗、**进程显存合计**(并显示「未归属(驱动/系统)」的部分)。 +- **进程列表**:PID、进程名、显存 (MB)、显存占比、GPU 占用 %、数据来源、窗口标题/命令行、 + 可执行文件路径。**点击任意表头即可排序**(默认按显存从大到小)。 +- **实时曲线**:蓝色为显存占用(左轴 MB),紫色为 GPU 利用率(右轴 %),红色虚线为 90% 警戒线。 +- **状态栏**:最近更新时间、进程数、采集异常提示、本次已释放的显存总量。 + +### 右键菜单 / 快捷键 + +| 操作 | 说明 | +| --- | --- | +| 结束进程 / `Delete` | 结束选中进程(默认**先尝试正常关闭窗口**,3 秒内没退出再终止) | +| 强制结束 | 直接 `Stop-Process -Force` | +| 复制 PID | 把选中进程的 PID 复制到剪贴板 | +| 打开文件所在位置 | 在资源管理器中定位该进程的可执行文件 | +| 查看详细信息 / 双击行 | 显示命令行 + `tasklist /V` 的完整信息 | +| `F5` | 立即刷新 | + +支持 Ctrl / Shift 多选,可一次结束多个进程。 + +## 进程保护机制(安全设计) + +结束进程前会依次检查,避免误杀: + +1. **禁止结束**(`BlockNames`):`System`、`Idle`、`Registry`、`Memory Compression`、`csrss`、 + `wininit`、`services`、`lsass`、`smss`、`winlogon`、`fontdrvhost`、`MsMpEng` 等 —— + 结束它们会导致系统蓝屏或不可恢复。 +2. **保护自身**:本工具自己的 PID,以及直接父进程(控制台宿主 `powershell.exe` / `cmd.exe` / + Windows Terminal,结束它会连带关掉本窗口)。 +3. **二次确认**(`WarnNames`):`dwm`、`explorer`、`svchost`、`SearchHost`、 + `StartMenuExperienceHost` 等系统外壳进程 —— 允许结束,但要连续确认两次,并提示桌面可能闪烁。 +4. **PID 复用保护**:真正结束前会重新读取该 PID 的进程名,若与列表里的名字不一致 + (说明 PID 已被系统复用给别的进程),则跳过,避免误杀。 +5. 确认框会列出每个待结束进程的名字、PID、占用显存,以及预计可释放的总量。 + +## 结束进程的真实行为(重要) + +`Stop-Process` 有个坑:**它可能"假成功"** —— 调用没报错,但进程被驱动/反作弊挡住而不退出 +(典型是卡死的游戏进程:只剩 1 个线程、工作集接近 0,却仍占着 1.6 GB 显存)。旧版本信了它的返回值, +于是显示"已结束"、进程却纹丝不动,看起来就是"按钮不生效"。 + +现在的处理流程是**三步 + 结果校验**: + +1. 先尝试正常关闭窗口,3 秒内没退出就 `Stop-Process`; +2. **轮询确认进程是否真的消失**(不看返回值,只看事实); +3. 没消失就自动回退到 **WMI `Win32_Process.Terminate`** —— 这条路径由 SYSTEM 权限的 WMI 服务执行, + 能结束一部分 `Stop-Process` 杀不掉的进程(实测:一个卡死的 1.6 GB 游戏进程就是这样被清掉的); +4. 仍然杀不掉时**如实报告**,并给出建议:按 `Win + Ctrl + Shift + B` 重启显卡驱动, + 或重启系统。 + +所以现在无论成功还是失败,都会弹出一个结果对话框告诉你**到底发生了什么**,不会再"点了没反应"。 + +### 权限说明 + +- 结束**自己启动的**普通进程:普通权限通常就够; +- 结束**以管理员身份运行**的进程(很多游戏、部分软件):必须让本工具以管理员身份运行, + 否则会得到 `Access is denied`(错误码 5); +- 程序启动时自己会请求 UAC;若你点了「否」,界面右上角会显示红色提示, + 确认框里也会提前警告"结束其他用户的进程会失败"。 + +## 命令行 / 自动化用法 + +exe 与 ps1 都支持同样的参数(命令行模式**不会**弹 UAC): + +```powershell +# 无界面自检:采集一次并打印 GPU 指标 + 逐进程显存表格(便于排错) +GpuMemTool.exe -SelfTest + +# 结束指定进程(仍受保护名单约束;系统外壳进程需显式加 -Force 才会结束) +GpuMemTool.exe -KillPid 1234,5678 -Force + +# 用源码运行等价写法 +powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -SelfTest +``` + +> 在 cmd/PowerShell 里直接敲 `GpuMemTool.exe -SelfTest` 就能看到输出(exe 会自动接回当前控制台)。 + +## 技术说明(为什么不能只用 nvidia-smi) + +Windows(WDDM 模式)下,`nvidia-smi --query-compute-apps=pid,used_gpu_memory` 的 +`used_gpu_memory` **通常返回 `[N/A]`** —— 只有 TCC 模式或纯 CUDA 上下文才有效。实测本机 +RTX 5060 Laptop 上所有进程该项都是 `[N/A]`,所以**单靠 nvidia-smi 无法回答「哪个进程占用了多少显存」**。 + +本工具采用双数据源合并: + +| 数据 | 来源 | +| --- | --- | +| 显存总量/已用/空闲、GPU 利用率、显存带宽利用率、温度、功耗 | `nvidia-smi --query-gpu=...` | +| 逐进程显存占用 | Windows 性能计数器 `Win32_PerfFormattedData_GPUPerformanceCounters_GPUProcessMemory` 的 **Local Usage** | +| 逐进程 GPU 占用 % | 同计数器集的 `GPUEngine` 的 `UtilizationPercentage`(按 PID 汇总) | +| CUDA 进程显存(若有) | `nvidia-smi --query-compute-apps`,与计数器值取较大者;来源列标注 `CUDA` / `WDDM` / `CUDA+WDDM` | + +实测校验:逐进程合计 ≈ nvidia-smi 报告的已用量(例:合计 2377 MB vs 已用 2491 MB, +差值约 114 MB 属驱动/系统保留);界面把差值单独显示为「未归属(驱动/系统)」。 + +**采集性能**:首次采集需初始化性能计数器,约 5~15 秒(界面有提示);之后每次约 0.1~0.8 秒。 +采集在独立 Runspace 中异步执行,界面不会卡顿。 + +## 常见问题 + +- **刚打开时曲线是空的?** 正常,前几秒在初始化计数器,界面会提示;第一轮数据到达即出曲线。 +- **某个进程的显存很小或为 0?** 它可能只是短暂使用 GPU,或占用的是共享显存(Shared)。 + 本工具显示的是专用显存(Local / Dedicated)。 +- **结束进程后显存没立刻下降?** 显存由驱动异步回收,通常 1~2 秒内刷新;也可能该进程还有子进程在占用。 +- **列表里没有某个进程?** 只有「显存 ≥ 1MB 或 GPU 占用 ≥ 0.5%」的进程会被列出; + 取消勾选「只看显存 ≥ 50MB」可看到全部。 +- **多 GPU 机器**:GPU 级指标取第一块 GPU;Windows 性能计数器无法把进程映射到具体某块显卡, + 因此**进程级数据是多块 GPU 的合计**。 +- **提示找不到 nvidia-smi?** 程序仍可用(仅性能计数器数据),但 GPU 级指标不可用。 + 请确认已装 NVIDIA 驱动,或 `nvidia-smi.exe` 位于 `C:\Windows\System32`。 + +## 注意事项 + +- 结束进程会**立即丢失该进程未保存的数据**,请确认后再操作。 +- 结束 `dwm.exe` / `explorer.exe` 会导致桌面、任务栏短暂闪烁或重启(Windows 会自动恢复), + 正在运行的全屏程序可能受影响。 +- 本工具不会自动结束任何进程,只有你点了按钮并确认后才会执行。 diff --git a/RunAsAdmin.bat b/RunAsAdmin.bat new file mode 100644 index 0000000..a181c13 --- /dev/null +++ b/RunAsAdmin.bat @@ -0,0 +1,36 @@ +@echo off +rem =========================================================================== +rem GPU Mem Tool - launcher (requests administrator privileges automatically) +rem GPU 显存释放工具 - 启动器(自动请求管理员权限) +rem +rem Double-click this file. Click "Yes" on the UAC prompt. +rem 双击本文件即可;弹出 UAC 提权窗口时点「是」。 +rem +rem Keep this file ASCII-only so it works under any console codepage. +rem The script path is quoted via [char]34 so paths containing spaces work. +rem =========================================================================== +setlocal +title GPU Mem Tool Launcher +set "PS1=%~dp0GpuMemTool.ps1" + +if not exist "%PS1%" ( + echo [ERROR] GpuMemTool.ps1 not found next to this launcher: + echo %~dp0 + echo. + pause + exit /b 1 +) + +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^ + "$ps1 = '%PS1%';" ^ + "$q = [char]34;" ^ + "$argline = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ' + $q + $ps1 + $q;" ^ + "$me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();" ^ + "if ($me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {" ^ + " Start-Process -FilePath 'powershell.exe' -ArgumentList $argline" ^ + "} else {" ^ + " try { Start-Process -FilePath 'powershell.exe' -ArgumentList $argline -Verb RunAs -ErrorAction Stop }" ^ + " catch { Write-Host ''; Write-Host '[!] Elevation cancelled or failed:' $_.Exception.Message -ForegroundColor Yellow; Start-Sleep -Seconds 3 }" ^ + "}" + +exit /b 0 diff --git a/new1022/GpuMemTool.ps1 b/new1022/GpuMemTool.ps1 new file mode 100644 index 0000000..1015517 --- /dev/null +++ b/new1022/GpuMemTool.ps1 @@ -0,0 +1,1849 @@ +#Requires -Version 5.1 +<# +============================================================================= + GPU 显存释放工具 (GpuMemTool) v2.0 + ----------------------------------------------------------------------- + 功能: + 1. 基于 nvidia-smi 读取 GPU 级指标:显存总量/已用/空闲、GPU 利用率、 + 显存带宽利用率、温度、功耗; + 2. 逐进程显示「哪个进程占用了多少显存」,可按任意列排序; + 3. 一键结束(释放)选定进程,可选「正常结束」/「强制结束」,带保护名单; + 4. 显存 / GPU 占用率实时曲线监测 + 监测数据导出 CSV; + 5. 启动时直接请求管理员权限(UAC)——非管理员运行本脚本会自动提权重启, + 用户取消提权时仍可继续以普通权限使用。 + + 技术说明(重要): + Windows(WDDM) 下 `nvidia-smi --query-compute-apps` 的 used_gpu_memory + 通常返回 [N/A](仅 TCC 模式/CUDA 上下文有效),因此本工具在 nvidia-smi + 之外,额外使用 Windows 性能计数器 + Win32_PerfFormattedData_GPUPerformanceCounters_GPUProcessMemory(Local Usage) + 逐进程统计显存占用,并与 nvidia-smi 的 CUDA 数据取较大值、标注来源。 + 实测总量基本吻合,差值即驱动/系统保留部分(界面上单独显示)。 + 注意:首次采集需初始化性能计数器,约需 3~15 秒,之后每次约 0.3~1.0 秒。 + + v2.0 性能与修复(相对 v1.1): + · 采集单轮 2433ms → 约 420ms(GPU 空闲)/ 970ms(GPU 忙),快约 5 倍 + - nvidia-smi 两次调用改为并行(.NET Process,不用 Job) + - GPUEngine(556 个实例、单次 1.1s)改为按需查询:GPU 忙才查,空闲按 0 处理 + - 进程元数据 Win32_Process(715ms) → Get-Process(17ms) + - 命令行改为「查看详细信息」时按需读取(原来每轮全量读取,是最大开销) + · 修复:采集耗时超过刷新间隔时变成背靠背采样、CPU 长期跑满 + · 修复:每轮 ClearSelection 导致列表选中被清空/跳动 + · 修复:procs 为空时可能插入 PID 0 的幽灵行 + · 修复:曲线历史 ArrayList.RemoveAt(0) 的 O(n) 搬移(改环形缓冲) + · 修复:进程元数据缓存无限增长(现在会清理已退出进程) + · 新增:状态栏显示单轮采集耗时,便于直观确认性能 + + 用法: + powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 + (直接运行即会自动弹出 UAC 请求管理员权限;也可用同目录的 RunAsAdmin.bat) + powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -SelfTest + (-SelfTest 无界面自检:采集一次并打印表格,便于排错,不会提权) + powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -KillPid 1234,5678 -Force + (-KillPid 命令行结束指定进程,仍受保护名单约束,便于脚本化清理显存,不会提权) +============================================================================= +#> +[CmdletBinding()] +param( + [switch]$SelfTest, # 无 GUI 自检 + [int]$IntervalSeconds = 2, # 默认刷新间隔(秒) + [int[]]$KillPid, # 命令行模式:结束指定 PID + [switch]$Force, # 命令行模式:强制结束 + [switch]$Elevated # 内部标记:已经过一次提权,避免 UAC 循环 +) + +$ErrorActionPreference = 'Continue' +$script:AppName = 'GPU 显存释放工具' +$script:AppVersion = 'v2.0' +$script:Config = @{ Interval = $IntervalSeconds } + +# 命令行模式的输出编码修复(v2.0): +# PowerShell 5.1 在输出被重定向到文件/管道时按系统代码页(中文是 GBK)写, +# 用 UTF-8 读取会变成乱码。检测到重定向就切成 UTF-8;控制台交互显示不受影响。 +if ($SelfTest -or $KillPid) { + try { + if ([Console]::IsOutputRedirected) { + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) + } + } catch { } +} + +# =========================================================================== +# 启动时直接请求管理员权限(UAC) +# · 非管理员启动 -> 立刻以 RunAs 重新启动自己,然后退出当前实例 +# · 用户点了「否」或已带 -Elevated 标记 -> 不重复弹窗,继续以普通权限运行 +# (窗口右上角会显示红色提示,并可点「以管理员身份重启」再试) +# · -SelfTest / -KillPid 等命令行模式不自动提权,避免脚本/自动化被 UAC 卡住 +# =========================================================================== +if (-not $Elevated) { + $isAdminNow = $false + try { + $wi = [Security.Principal.WindowsIdentity]::GetCurrent() + $wp = New-Object Security.Principal.WindowsPrincipal($wi) + $isAdminNow = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { } + + if (-not $isAdminNow -and -not $SelfTest -and -not $KillPid) { + $selfPath = $PSCommandPath + if ([string]::IsNullOrWhiteSpace($selfPath)) { + try { $selfPath = $MyInvocation.MyCommand.Path } catch { } + } + if (-not [string]::IsNullOrWhiteSpace($selfPath) -and (Test-Path -LiteralPath $selfPath)) { + $q = [char]34 + $argline = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ' + $q + $selfPath + $q + + ' -IntervalSeconds ' + $IntervalSeconds + ' -Elevated' + $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + try { + Start-Process -FilePath $psExe -ArgumentList $argline -Verb RunAs -ErrorAction Stop + exit 0 # 已交给提权后的新实例,当前实例退出 + } catch { + # 用户取消 UAC,或提权失败:继续以普通权限运行,界面会给出提示 + } + } + } +} + +# --------------------------------------------------------------------------- +# 进程保护名单 +# BlockNames : 结束会导致系统崩溃/蓝屏,禁止结束 +# WarnNames : 系统外壳或关键进程,允许结束但需二次确认 +# --------------------------------------------------------------------------- +$script:BlockNames = @( + 'system', 'idle', 'registry', 'memory compression', 'secure system', + 'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon', + 'fontdrvhost', 'msmpeng', 'nissrv', 'sppsvc', 'trustedinstaller' +) +$script:WarnNames = @( + 'dwm', 'explorer', 'svchost', 'audiodg', 'sihost', 'runtimebroker', + 'searchhost', 'startmenuexperiencehost', 'shellexperiencehost', + 'textinputhost', 'applicationframehost', 'widgetservice', 'widgets' +) + +# =========================================================================== +# 通用函数 +# =========================================================================== +function Find-NvSmiPath { + $cands = New-Object System.Collections.ArrayList + $cmd = Get-Command 'nvidia-smi.exe' -ErrorAction SilentlyContinue + if ($cmd -and $cmd.Source) { [void]$cands.Add($cmd.Source) } + [void]$cands.Add((Join-Path $env:SystemRoot 'System32\nvidia-smi.exe')) + [void]$cands.Add((Join-Path ${env:ProgramFiles} 'NVIDIA Corporation\NVSMI\nvidia-smi.exe')) + [void]$cands.Add('C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe') + foreach ($p in $cands) { + if ($p -and (Test-Path -LiteralPath $p)) { return $p } + } + return $null +} + +function Test-IsAdmin { + try { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $pr = New-Object Security.Principal.WindowsPrincipal($id) + return $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { return $false } +} + +function Get-Argb { + param([int]$A, [int]$R, [int]$G, [int]$B) + return [System.Drawing.Color]::FromArgb($A, $R, $G, $B) +} + +# --------------------------------------------------------------------------- +# 保护名单判定:本工具自身 + 直接父进程(通常是控制台宿主 powershell/cmd/ +# Windows Terminal——结束它会连带关掉本窗口)。不再向上追溯更多层, +# 否则从资源管理器启动时会误把 explorer.exe 一并保护,导致无法释放它的显存。 +# --------------------------------------------------------------------------- +function Get-ProtectedPidSet { + $set = @{} + $set[[int]$PID] = $true + $self = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction SilentlyContinue + if ($self) { + $pp = [int]$self.ParentProcessId + if ($pp -gt 0) { $set[$pp] = $true } + } + return $set +} + +# --------------------------------------------------------------------------- +# 判断进程是否真的退出了 +# Stop-Process 有可能「假成功」:调用没报错,但进程被驱动/反作弊挡住而不退出 +# (典型:卡死的游戏进程只剩 1 个线程、工作集接近 0,却仍占着显存)。 +# 所以结束之后必须轮询确认,不能相信返回值。 +# --------------------------------------------------------------------------- +function Test-ProcessExited { + param([int]$ProcessId, [int]$TimeoutMs = 2000) + $sw = [System.Diagnostics.Stopwatch]::StartNew() + while ($sw.ElapsedMilliseconds -lt $TimeoutMs) { + if (-not (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { return $true } + Start-Sleep -Milliseconds 200 + } + return $false +} + +# --------------------------------------------------------------------------- +# 结束进程核心逻辑(GUI 与命令行共用) +# $Targets : 形如 @{ Pid = ..; Name = ..; Vram = .. } 的对象数组 +# 返回 : Ok / Warned / Blocked / Killed / Failed / FreedMiB / Messages +# --------------------------------------------------------------------------- +function Stop-GpuProcess { + param( + [array]$Targets, + [switch]$Force, + [switch]$PumpGui, # GUI 模式:等待期间处理消息循环,避免界面假死 + [switch]$DryRun # 只做分类判定,不真正结束进程 + ) + + $protect = Get-ProtectedPidSet + $blocked = @(); $warned = @(); $ok = @() + foreach ($t in @($Targets)) { + $base = ([string]$t.Name -replace '\.exe$', '').ToLower() + if ($script:BlockNames -contains $base) { $blocked += $t; continue } + if ($protect.ContainsKey([int]$t.Pid)) { $blocked += $t; continue } + if ($script:WarnNames -contains $base) { $warned += $t; continue } + $ok += $t + } + + if ($DryRun) { + return [pscustomobject]@{ + Ok = $ok; Warned = $warned; Blocked = $blocked + Killed = @(); Failed = @(); FreedMiB = 0.0; Messages = @() + } + } + + $messages = New-Object System.Collections.ArrayList + $killed = @(); $failed = @(); $freed = 0.0 + + foreach ($t in @($ok + $warned)) { + $vramTxt = '{0:N1}' -f [double]$t.Vram + $proc = Get-Process -Id ([int]$t.Pid) -ErrorAction SilentlyContinue + if (-not $proc) { + [void]$messages.Add("跳过 $($t.Name) (PID $($t.Pid)):进程已不存在") + continue + } + $expect = ([string]$t.Name -replace '\.exe$', '') + if (-not [string]::IsNullOrWhiteSpace($expect) -and $proc.ProcessName -ne $expect) { + [void]$messages.Add("跳过 $($t.Name) (PID $($t.Pid)):PID 已被复用(当前为 $($proc.ProcessName).exe),为避免误杀已中止") + $failed += $t + continue + } + $targetPid = [int]$t.Pid + $lastErr = '' + $how = '' + + # ---- 第一步:正常关闭窗口 / 直接结束 ---- + try { + if ($Force) { + Stop-Process -Id $targetPid -Force -ErrorAction Stop + $how = '强制结束' + } else { + $closed = $false + try { $closed = $proc.CloseMainWindow() } catch { $closed = $false } + if ($closed) { + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if ($PumpGui) { [System.Windows.Forms.Application]::DoEvents() } + Start-Sleep -Milliseconds 120 + if (-not (Get-Process -Id $targetPid -ErrorAction SilentlyContinue)) { break } + } + } + if (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) { + Stop-Process -Id $targetPid -ErrorAction Stop + $how = '结束' + } else { + $how = '正常关闭' + } + } + } catch { + $lastErr = $_.Exception.Message + } + + # ---- 第二步:确认是否真的退出了;没退就换 WMI(以 SYSTEM 身份执行)再杀一次 ---- + $gone = Test-ProcessExited -ProcessId $targetPid + if (-not $gone) { + try { + $ci = Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction Stop + if ($ci) { + $wr = Invoke-CimMethod -InputObject $ci -MethodName Terminate -ErrorAction Stop + if ($null -ne $wr -and $wr.ReturnValue -ne 0) { + if ([string]::IsNullOrWhiteSpace($lastErr)) { $lastErr = "WMI Terminate 返回码 $($wr.ReturnValue)" } + } + } + } catch { + if ([string]::IsNullOrWhiteSpace($lastErr)) { $lastErr = $_.Exception.Message } + } + $gone = Test-ProcessExited -ProcessId $targetPid + if ($gone) { $how = '强制结束(WMI)' } + } + + # ---- 第三步:如实上报 ---- + if ($gone) { + [void]$messages.Add("已$how $($t.Name) (PID $targetPid),释放约 $vramTxt MB") + $killed += $t; $freed += [double]$t.Vram + } else { + $reason = $lastErr + if ([string]::IsNullOrWhiteSpace($reason)) { $reason = '已发送结束指令但进程未退出' } + [void]$messages.Add("结束 $($t.Name) (PID $targetPid) 失败:$reason(进程仍未退出,通常是驱动/反作弊保护或进程已挂起卡死)") + $failed += $t + } + } + + return [pscustomobject]@{ + Ok = $ok + Warned = $warned + Blocked = $blocked + Killed = $killed + Failed = $failed + FreedMiB = [math]::Round($freed, 1) + Messages = @($messages.ToArray()) + } +} + +# =========================================================================== +# 数据采集(在常驻后台 Runspace 中执行,返回 JSON 字符串) +# +# v2.0 性能改造(实测:RTX 5060 Laptop / 275 个进程) +# 原实现单轮 2433ms,比 2 秒刷新间隔还长,导致「背靠背采集」、CPU 长期跑满。 +# +# 1. nvidia-smi 两次调用改为 .NET Process 真并行 +# 2. GPUEngine(556 个实例、单次约 1.1s)不再每轮必查: +# GPU 空闲时直接按 0 处理,忙时才查,且最多 10 秒兜底刷新一次 +# 3. 进程元数据从 Win32_Process(715ms)换成 Get-Process(约 17ms 热态) +# 4. 命令行不再每轮全量读取,改为「查看详细信息」里按需读取 +# 5. 所有缓存放在 runspace 的 script 作用域里,跨轮次复用 +# +# 注意:变量命名要避免大小写撞车(PowerShell 变量名不区分大小写!)。 +# 这里缓存变量叫 $gmt,条目叫 $mc / $metaRec,不要再用 $c / $C / $m 这类单字母。 +# =========================================================================== +$script:CollectScript = { + param([string]$NvSmiPath) + + $ErrorActionPreference = 'SilentlyContinue' + $swAll = [System.Diagnostics.Stopwatch]::StartNew() + + # -------------------- runspace 级缓存(跨采集轮次持久)-------------------- + if ($null -eq $script:gmt) { + $script:gmt = @{ + meta = @{} # pid -> @{ name; path; desc } + util = @{} # pid -> 上次 GPU 占用 % + utilAt = [datetime]::MinValue # 上次查询 GPUEngine 的时间 + utilCost = 0.0 # 上次查询 GPUEngine 的耗时(秒),用于自适应退避 + cudaOk = $false # compute-apps 是否给出过真实显存数值 + cudaAt = [datetime]::MinValue # 上次尝试 compute-apps 的时间 + } + } + $gmt = $script:gmt + $now = Get-Date + + function Convert-Num([string]$s) { + if ($null -eq $s) { return $null } + $t = $s.Trim() + if ($t -eq '' -or $t -like '*N/A*' -or $t -eq '[Not Supported]') { return $null } + $v = 0.0 + if ([double]::TryParse($t, [ref]$v)) { return $v } + return $null + } + + $res = [ordered]@{ + ts = $now.ToString('yyyy-MM-dd HH:mm:ss') + ms = 0 + smiMs = 0 + memMs = 0 + engMs = 0 + procMs = 0 + smiOk = $false + smiErr = '' + cntOk = $false + cntErr = '' + gpus = @() + procs = @() + smiUsedMiB = 0 + smiTotalMiB = 0 + sumProcMiB = 0 + gpuBusy = $false + utilSkipped = $false + utilAgeSec = 0 + utilSource = 'none' + newMeta = 0 + } + + # ======================= 1) nvidia-smi:两次调用并行 ======================= + # nvidia-smi 的参数必须作为「一条参数行」传入;直接给一个字符串变量会被 + # 当成单个参数(退出码 2)。 + $SmiArgLine = '--query-gpu=index,name,memory.total,memory.used,memory.free,utilization.gpu,utilization.memory,temperature.gpu,power.draw,power.limit --format=csv,noheader,nounits' + $CudaArgLine = '--query-compute-apps=pid,process_name,used_gpu_memory --format=csv,noheader,nounits' + + function Start-SmiProcess([string]$argline) { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $NvSmiPath + $psi.Arguments = $argline + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + return [System.Diagnostics.Process]::Start($psi) + } + + $swSmi = [System.Diagnostics.Stopwatch]::StartNew() + $rawGpu = ''; $rawCuda = '' + if ($NvSmiPath -and (Test-Path -LiteralPath $NvSmiPath)) { + try { + # compute-apps 在 WDDM 下恒为 N/A:拿到过真实数值才每轮查,否则 30 秒查一次 + $doCuda = $true + if (-not $gmt.cudaOk -and (($now - $gmt.cudaAt).TotalSeconds -lt 30)) { $doCuda = $false } + + $pGpu = Start-SmiProcess $SmiArgLine + $pCuda = $null + if ($doCuda) { $pCuda = Start-SmiProcess $CudaArgLine } + + # 先读第一个进程的输出(此时第二个已经在并行跑) + $rawGpu = $pGpu.StandardOutput.ReadToEnd() + $errGpu = $pGpu.StandardError.ReadToEnd() + $pGpu.WaitForExit() + $codeGpu = $pGpu.ExitCode + $pGpu.Dispose() + if ($null -ne $pCuda) { + $rawCuda = $pCuda.StandardOutput.ReadToEnd() + $null = $pCuda.StandardError.ReadToEnd() + $pCuda.WaitForExit() + $null = $pCuda.ExitCode + $pCuda.Dispose() + $gmt.cudaAt = $now + } + + if ($codeGpu -eq 0 -and -not [string]::IsNullOrWhiteSpace($rawGpu)) { + $res.smiOk = $true + foreach ($gpuLine in ($rawGpu -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($gpuLine)) { continue } + $f = @(($gpuLine -split ',') | ForEach-Object { $_.Trim() }) + if ($f.Count -lt 10) { continue } + $res.gpus += , ([pscustomobject]@{ + index = [int](Convert-Num $f[0]) + name = $f[1] + memTotal = (Convert-Num $f[2]) + memUsed = (Convert-Num $f[3]) + memFree = (Convert-Num $f[4]) + utilGpu = (Convert-Num $f[5]) + utilMem = (Convert-Num $f[6]) + temp = (Convert-Num $f[7]) + power = (Convert-Num $f[8]) + powerMax = (Convert-Num $f[9]) + }) + } + } else { + $msg = "nvidia-smi 退出码 $codeGpu" + if (-not [string]::IsNullOrWhiteSpace($errGpu)) { $msg += ':' + $errGpu.Trim() } + $res.smiErr = $msg + } + } catch { + $res.smiErr = $_.Exception.Message + } + } else { + $res.smiErr = 'nvidia-smi 未找到' + } + $res.smiMs = [int]$swSmi.ElapsedMilliseconds + + # 解析 CUDA 计算进程(WDDM 下 used_gpu_memory 基本恒为 N/A,只有路径有用) + $cudaMap = @{} + if (-not [string]::IsNullOrWhiteSpace($rawCuda)) { + foreach ($cudaLine in ($rawCuda -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($cudaLine)) { continue } + $f = @(($cudaLine -split ',') | ForEach-Object { $_.Trim() }) + if ($f.Count -lt 3) { continue } + $pidv = 0 + if (-not [int]::TryParse($f[0], [ref]$pidv)) { continue } + $miB = Convert-Num $f[2] + if ($null -ne $miB) { $gmt.cudaOk = $true } + $cudaMap[$pidv] = [pscustomobject]@{ pid = $pidv; path = $f[1]; cudaMiB = $miB } + } + } + + # GPU 是否忙:决定这一轮要不要查 GPUEngine(它是最贵的一步) + $ug = 0.0; $um = 0.0 + if ($res.gpus.Count -gt 0) { + $gpu0 = $res.gpus[0] + if ($null -ne $gpu0.utilGpu) { $ug = [double]$gpu0.utilGpu } + if ($null -ne $gpu0.utilMem) { $um = [double]$gpu0.utilMem } + } + $res.gpuBusy = ($ug -ge 1) -or ($um -ge 1) + + # ============ 2) Windows 计数器:逐进程显存(每轮都查,核心数据)============ + $swMem = [System.Diagnostics.Stopwatch]::StartNew() + $memByPid = @{} + try { + $mem = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUProcessMemory -ErrorAction Stop + $res.cntOk = $true + foreach ($memRec in @($mem)) { + if ($memRec.Name -notmatch '^pid_(\d+)_luid_') { continue } + $pidv = [int]$Matches[1] + $bytes = 0.0 + [void][double]::TryParse([string]$memRec.LocalUsage, [ref]$bytes) + if ($bytes -lt 0) { $bytes = 0 } + if ($memByPid.ContainsKey($pidv)) { $memByPid[$pidv] += $bytes } else { $memByPid[$pidv] = $bytes } + } + } catch { + $res.cntErr = $_.Exception.Message + } + $res.memMs = [int]$swMem.ElapsedMilliseconds + + # ============ 3) 逐进程 GPU 占用%:按需查询(自适应节流)============ + # GPUEngine 是这个采集里最贵的一步(556 个实例,单次 1.3~1.5s,偶发 CIM 抖动会到数秒)。 + # 策略:GPU 空闲直接按 0 处理;忙时也只用「最小间隔」限流,避免每轮都付这个代价。 + # 上一次查询快 -> 间隔 4s;上一次查询慢(>1.5s) -> 自动退避到 8s + $sinceUtil = ($now - $gmt.utilAt).TotalSeconds + $minGap = 4.0 + if ($gmt.utilCost -gt 1.5) { $minGap = 8.0 } + $needUtil = $true + if ($gmt.util.Count -gt 0) { + if (-not $res.gpuBusy -and $sinceUtil -lt 10) { $needUtil = $false } # 空闲:10 秒兜底刷新 + if ($sinceUtil -lt $minGap) { $needUtil = $false } # 忙时:限流 + } + + $utilByPid = @{} + if ($needUtil) { + $swEng = [System.Diagnostics.Stopwatch]::StartNew() + try { + $eng = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine -ErrorAction SilentlyContinue + $acc = @{} + foreach ($engRec in @($eng)) { + if ($engRec.Name -notmatch '^pid_(\d+)_luid_') { continue } + $pidv = [int]$Matches[1] + $u = 0.0 + [void][double]::TryParse([string]$engRec.UtilizationPercentage, [ref]$u) + if ($u -lt 0) { $u = 0 } + if ($acc.ContainsKey($pidv)) { $acc[$pidv] += $u } else { $acc[$pidv] = $u } + } + $gmt.util = $acc + $gmt.utilAt = $now + $gmt.utilCost = $swEng.Elapsed.TotalSeconds + $utilByPid = $acc + } catch { + if ([string]::IsNullOrWhiteSpace($res.cntErr)) { $res.cntErr = $_.Exception.Message } + $utilByPid = $gmt.util + } + $res.engMs = [int]$swEng.ElapsedMilliseconds + } else { + $res.utilSkipped = $true + $res.utilAgeSec = [int]$sinceUtil + if (-not $res.gpuBusy) { $utilByPid = @{} } # GPU 空闲:所有进程 GPU% 视为 0 + else { $utilByPid = $gmt.util } # GPU 忙但限流中:沿用上次结果 + } + + $res.utilSource = 'cim' + + + # ============ 4) 进程元信息 ============ + # 关键性能点(v2.0):Get-Process 整体枚举只要 17ms,但每访问一次 + # $p.Path / $p.MainWindowTitle 都要额外系统调用(271 个进程累计约 2 秒!)。 + # 所以只对「有显存/GPU 占用且缓存里没有」的少数进程去取这些属性,其余全部命中缓存。 + $swProc = [System.Diagnostics.Stopwatch]::StartNew() + $procMap = @{} + foreach ($procObj in @(Get-Process -ErrorAction SilentlyContinue)) { + $procMap[[int]$procObj.Id] = $procObj + } + $metaPids = @{} + foreach ($key in $memByPid.Keys) { $metaPids[$key] = $true } + foreach ($key in $utilByPid.Keys) { $metaPids[$key] = $true } + foreach ($key in $cudaMap.Keys) { $metaPids[$key] = $true } + $newMeta = 0 + foreach ($pidv in $metaPids.Keys) { + if ($gmt.meta.ContainsKey($pidv)) { continue } # 已缓存:跳过(稳态下几乎全部命中) + $procObj = $procMap[$pidv] + if ($null -eq $procObj) { continue } + $mc = @{ name = ''; path = ''; desc = '' } + $mc.name = $procObj.ProcessName + '.exe' + try { $mc.path = [string]$procObj.Path } catch { } + try { $mc.desc = [string]$procObj.MainWindowTitle } catch { } + $gmt.meta[$pidv] = $mc + $newMeta++ + } + # 定期清理已退出进程的缓存,避免长期运行后无限增长 + if ($gmt.meta.Count -gt 64) { + foreach ($key in @($gmt.meta.Keys)) { if (-not $procMap.ContainsKey($key)) { $gmt.meta.Remove($key) } } + foreach ($key in @($gmt.util.Keys)) { if (-not $procMap.ContainsKey($key)) { $gmt.util.Remove($key) } } + } + $res.procMs = [int]$swProc.ElapsedMilliseconds + $res.newMeta = $newMeta + + # ======================= 5) 合并成逐进程行 ======================= + $allPids = @{} + foreach ($key in $memByPid.Keys) { $allPids[$key] = $true } + foreach ($key in $utilByPid.Keys) { $allPids[$key] = $true } + foreach ($key in $cudaMap.Keys) { $allPids[$key] = $true } + + $rows = New-Object System.Collections.ArrayList + $sumProc = 0.0 + foreach ($pidv in $allPids.Keys) { + $cudaMiB = $null + if ($cudaMap.ContainsKey($pidv)) { $cudaMiB = $cudaMap[$pidv].cudaMiB } + $cntMiB = $null + if ($memByPid.ContainsKey($pidv)) { $cntMiB = [math]::Round($memByPid[$pidv] / 1MB, 1) } + $utilPct = 0.0 + if ($utilByPid.ContainsKey($pidv)) { $utilPct = [math]::Round($utilByPid[$pidv], 1) } + + $src = '' + $vram = 0.0 + if ($null -ne $cudaMiB -and $null -ne $cntMiB) { + $vram = [math]::Max($cudaMiB, $cntMiB); $src = 'CUDA+WDDM' + } elseif ($null -ne $cudaMiB) { + $vram = $cudaMiB; $src = 'CUDA' + } elseif ($null -ne $cntMiB) { + $vram = $cntMiB; $src = 'WDDM' + } + + # 显存 <1MB 且 GPU 占用 <0.5% 的进程不列出 + if ($vram -lt 1 -and $utilPct -lt 0.5) { continue } + $sumProc += $vram + + $rowName = ''; $rowPath = ''; $rowDesc = '' + $metaRec = $gmt.meta[$pidv] + if ($null -ne $metaRec) { + $rowName = [string]$metaRec.name + $rowPath = [string]$metaRec.path + $rowDesc = [string]$metaRec.desc + } + if ([string]::IsNullOrWhiteSpace($rowPath) -and $cudaMap.ContainsKey($pidv)) { + $rowPath = [string]$cudaMap[$pidv].path + } + if ([string]::IsNullOrWhiteSpace($rowName)) { $rowName = '(PID ' + $pidv + ',已退出?)' } + + [void]$rows.Add([pscustomobject]@{ + pid = $pidv + name = $rowName + vramMiB = [math]::Round($vram, 1) + util = [math]::Min($utilPct, 100.0) + src = $src + desc = $rowDesc + path = $rowPath + }) + } + + $res.procs = @($rows.ToArray() | Sort-Object -Property vramMiB -Descending) + $res.sumProcMiB = [math]::Round($sumProc, 1) + $tot = 0.0; $used = 0.0 + foreach ($gpuRec in $res.gpus) { + if ($null -ne $gpuRec.memTotal) { $tot += $gpuRec.memTotal } + if ($null -ne $gpuRec.memUsed) { $used += $gpuRec.memUsed } + } + $res.smiTotalMiB = [math]::Round($tot, 1) + $res.smiUsedMiB = [math]::Round($used, 1) + $res.ms = [int]$swAll.ElapsedMilliseconds + return ($res | ConvertTo-Json -Depth 6 -Compress) +} + +function Invoke-Collect { + param([string]$NvSmiPath) + $json = & $script:CollectScript $NvSmiPath + if ([string]::IsNullOrWhiteSpace($json)) { throw '采集脚本未返回数据' } + return ($json | ConvertFrom-Json) +} + + + + +# =========================================================================== +# 自检模式(无界面) +# =========================================================================== +if ($SelfTest) { + $smi = Find-NvSmiPath + Write-Host ("== {0} {1} 自检 ==" -f $script:AppName, $script:AppVersion) -ForegroundColor Cyan + Write-Host ("nvidia-smi : {0}" -f $(if ($smi) { $smi } else { '未找到' })) + Write-Host ("管理员 : {0}" -f (Test-IsAdmin)) + $sw = [Diagnostics.Stopwatch]::StartNew() + $d = Invoke-Collect -NvSmiPath $smi + Write-Host ("采集耗时 : {0} ms" -f $sw.ElapsedMilliseconds) + Write-Host ("nvidia-smi : smiOk={0} {1}" -f $d.smiOk, $d.smiErr) + Write-Host ("性能计数器 : cntOk={0} {1}" -f $d.cntOk, $d.cntErr) + Write-Host '' + foreach ($g in @($d.gpus)) { + Write-Host ("GPU {0}: {1}" -f $g.index, $g.name) -ForegroundColor Yellow + $pmaxTxt = 'N/A' + if ($null -ne $g.powerMax) { $pmaxTxt = ('{0:N0} W' -f $g.powerMax) } + $pwrTxt = 'N/A' + if ($null -ne $g.power) { $pwrTxt = ('{0:N1} W' -f $g.power) } + Write-Host (" 显存 {0} / {1} MiB (空闲 {2}) GPU利用率 {3}% 带宽利用率 {4}% 温度 {5}C 功耗 {6} / 上限 {7}" -f ` + $g.memUsed, $g.memTotal, $g.memFree, $g.utilGpu, $g.utilMem, $g.temp, $pwrTxt, $pmaxTxt) + } + if ($d.smiOk) { + Write-Host ("`n进程级合计 {0} MiB ;nvidia-smi 报告已用 {1} MiB ;差值 {2} MiB(驱动/系统保留)" -f ` + $d.sumProcMiB, $d.smiUsedMiB, [math]::Round($d.smiUsedMiB - $d.sumProcMiB, 1)) -ForegroundColor Green + } else { + Write-Host ("`n进程级合计 {0} MiB (nvidia-smi 不可用,无法与驱动报告的总量对比)" -f $d.sumProcMiB) -ForegroundColor Green + } + Write-Host ("`n{0,-8} {1,-34} {2,10} {3,8} {4,-11} {5}" -f 'PID', '进程名', '显存(MB)', 'GPU%', '来源', '说明') + Write-Host ('-' * 120) + foreach ($p in @($d.procs)) { + $desc = [string]$p.desc; if ($desc.Length -gt 44) { $desc = $desc.Substring(0, 44) + '...' } + Write-Host ("{0,-8} {1,-34} {2,10:N1} {3,8:N1} {4,-11} {5}" -f $p.pid, $p.name, $p.vramMiB, $p.util, $p.src, $desc) + } + Write-Host ("`n共 {0} 个进程占用 GPU 资源。" -f @($d.procs).Count) + exit 0 +} + +# =========================================================================== +# 命令行结束进程模式(无界面) +# powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -KillPid 1234,5678 -Force +# =========================================================================== +if ($KillPid -and @($KillPid).Count -gt 0) { + Write-Host ("== {0} {1} 结束进程 ==" -f $script:AppName, $script:AppVersion) -ForegroundColor Cyan + if (-not (Test-IsAdmin)) { Write-Host '[!] 当前不是管理员,其他用户的进程可能无法结束。' -ForegroundColor Yellow } + + $smi = Find-NvSmiPath + $d = $null + try { $d = Invoke-Collect -NvSmiPath $smi } catch { Write-Host ("[!] 采集失败:{0}" -f $_.Exception.Message) -ForegroundColor Yellow } + + $targets = @() + foreach ($id in @($KillPid)) { + $id = [int]$id + $name = ''; $vram = 0.0 + if ($d) { + $info = @($d.procs) | Where-Object { [int]$_.pid -eq $id } | Select-Object -First 1 + if ($info) { $name = [string]$info.name; $vram = [double]$info.vramMiB } + } + if ([string]::IsNullOrWhiteSpace($name)) { + $proc = Get-Process -Id $id -ErrorAction SilentlyContinue + if ($proc) { $name = $proc.ProcessName + '.exe' } + } + if ([string]::IsNullOrWhiteSpace($name)) { $name = '(未知进程)' } + $targets += [pscustomobject]@{ Pid = $id; Name = $name; Vram = $vram } + } + + # 命令行模式下没有确认对话框:系统外壳/关键进程默认跳过,必须显式加 -Force + $pre = Stop-GpuProcess -Targets $targets -DryRun + if ($pre.Warned.Count -gt 0 -and -not $Force) { + foreach ($t in $pre.Warned) { + Write-Host ("[跳过] {0} (PID {1}):系统外壳/关键进程,需显式加 -Force 才会结束" -f $t.Name, $t.Pid) -ForegroundColor Yellow + } + $targets = @($pre.Ok) + } + + $res = Stop-GpuProcess -Targets $targets -Force:$Force + foreach ($t in $res.Blocked) { + Write-Host ("[阻止] {0} (PID {1}):系统关键进程,或本工具自身/父进程" -f $t.Name, $t.Pid) -ForegroundColor Red + } + foreach ($m in $res.Messages) { Write-Host ("[执行] " + $m) -ForegroundColor Green } + foreach ($t in $res.Failed) { Write-Host ("[失败] {0} (PID {1})" -f $t.Name, $t.Pid) -ForegroundColor Red } + Write-Host ("`n共结束 {0} 个进程,预计释放 {1:N1} MB 显存。" -f @($res.Killed).Count, $res.FreedMiB) -ForegroundColor Cyan + exit 0 +} + +# =========================================================================== +# GUI +# =========================================================================== +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() +[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) + +# 双缓冲面板(消除重绘闪烁);编译失败则回退到普通 Panel +$panelClass = 'GpuTool.BufferedPanel' +if (-not ($panelClass -as [type])) { + try { + Add-Type -TypeDefinition @' +using System; +using System.Windows.Forms; +namespace GpuTool { + public class BufferedPanel : Panel { + public BufferedPanel() { this.DoubleBuffered = true; this.ResizeRedraw = true; } + } +} +'@ -ReferencedAssemblies 'System.Windows.Forms', 'System.Drawing' -ErrorAction Stop + } catch { + $panelClass = 'System.Windows.Forms.Panel' + } +} + +# --------------------------- 配色 --------------------------- +$C_BG = Get-Argb 255 24 26 32 +$C_PANEL = Get-Argb 255 32 35 43 +$C_CARD = Get-Argb 255 38 42 52 +$C_LINE = Get-Argb 255 56 61 74 +$C_FG = Get-Argb 255 228 231 238 +$C_FG_DIM = Get-Argb 255 150 157 172 +$C_ACCENT = Get-Argb 255 76 160 255 +$C_GREEN = Get-Argb 255 70 200 120 +$C_AMBER = Get-Argb 255 240 180 60 +$C_RED = Get-Argb 255 235 80 80 +$C_PURPLE = Get-Argb 255 170 120 255 + +$fontUI = New-Object System.Drawing.Font('Microsoft YaHei UI', 9) +$fontSmall = New-Object System.Drawing.Font('Microsoft YaHei UI', 8) +$fontAxis = New-Object System.Drawing.Font('Microsoft YaHei UI', 7.5) +$fontBold = New-Object System.Drawing.Font('Microsoft YaHei UI', 10, [System.Drawing.FontStyle]::Bold) +$fontBig = New-Object System.Drawing.Font('Microsoft YaHei UI', 14, [System.Drawing.FontStyle]::Bold) +$fontTitle = New-Object System.Drawing.Font('Microsoft YaHei UI', 12, [System.Drawing.FontStyle]::Bold) +$fontMono = New-Object System.Drawing.Font('Consolas', 9) + +function New-Label { + param($Text, $X, $Y, $W, $H, $Font = $fontUI, $Color = $C_FG, $Align = 'MiddleLeft') + $l = New-Object System.Windows.Forms.Label + $l.Text = $Text + $l.Location = New-Object System.Drawing.Point($X, $Y) + $l.Size = New-Object System.Drawing.Size($W, $H) + $l.Font = $Font; $l.ForeColor = $Color + $l.BackColor = [System.Drawing.Color]::Transparent + $l.TextAlign = [System.Enum]::Parse([System.Drawing.ContentAlignment], $Align) + return $l +} + +function New-Button { + param($Text, $X, $Y, $W, $H, $Back = $C_PANEL, $Fore = $C_FG, $Font = $fontUI) + $b = New-Object System.Windows.Forms.Button + $b.Text = $Text + $b.Location = New-Object System.Drawing.Point($X, $Y) + $b.Size = New-Object System.Drawing.Size($W, $H) + $b.FlatStyle = 'Flat' + $b.FlatAppearance.BorderColor = $C_LINE + $b.BackColor = $Back; $b.ForeColor = $Fore; $b.Font = $Font + $b.UseVisualStyleBackColor = $false + return $b +} + +# --------------------------- 主窗体 --------------------------- +# 尺寸与位置都由「屏幕可用区域」显式算出并夹取: +# StartPosition=CenterScreen 在某些 DPI/多屏环境下会把窗口放到屏幕外, +# 导致底部状态栏被截断,因此这里改为 Manual + 手动居中。 +$wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea +$ncW = 16 # 左右边框近似值 +$ncH = 39 # 标题栏近似值 +$clientW = [int][math]::Min(1220, [math]::Max(980, $wa.Width - $ncW - 24)) +$clientH = [int][math]::Min(900, [math]::Max(600, $wa.Height - $ncH - 24)) +$outerW = $clientW + $ncW +$outerH = $clientH + $ncH +$posX = [int]($wa.X + [math]::Max(0, ($wa.Width - $outerW) / 2)) +$posY = [int]($wa.Y + [math]::Max(0, ($wa.Height - $outerH) / 2)) + +$form = New-Object System.Windows.Forms.Form +$form.Text = "$($script:AppName) $($script:AppVersion)" +$form.ClientSize = New-Object System.Drawing.Size($clientW, $clientH) +$form.MinimumSize = New-Object System.Drawing.Size([int][math]::Min(1020, $clientW), [int][math]::Min(840, $outerH)) +$form.StartPosition = 'Manual' +$form.Location = New-Object System.Drawing.Point($posX, $posY) +$form.BackColor = $C_BG +$form.ForeColor = $C_FG +$form.Font = $fontUI +$form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None +$form.KeyPreview = $true + +# ---------- 顶部标题栏 ---------- +$header = New-Object System.Windows.Forms.Panel +$header.Location = New-Object System.Drawing.Point(0, 0) +$header.Size = New-Object System.Drawing.Size($clientW, 46) +$header.BackColor = $C_PANEL +$header.Anchor = 'Top,Left,Right' +$form.Controls.Add($header) + +$header.Controls.Add((New-Label $script:AppName 12 8 300 30 $fontTitle $C_FG)) + +$lblGpuName = New-Label '' 316 8 430 30 $fontUI $C_FG_DIM +$header.Controls.Add($lblGpuName) + +$lblAdmin = New-Label '' 750 8 250 30 $fontBold $C_RED 'MiddleRight' +$lblAdmin.Anchor = 'Top,Right' +$header.Controls.Add($lblAdmin) + +$btnElevate = New-Button '以管理员身份重启' 1010 8 195 30 (Get-Argb 255 120 60 40) $C_FG +$btnElevate.Anchor = 'Top,Right' +$header.Controls.Add($btnElevate) + +# ---------- 工具栏 ---------- +$toolbar = New-Object System.Windows.Forms.Panel +$toolbar.Location = New-Object System.Drawing.Point(0, 46) +$toolbar.Size = New-Object System.Drawing.Size($clientW, 40) +$toolbar.BackColor = $C_BG +$toolbar.Anchor = 'Top,Left,Right' +$form.Controls.Add($toolbar) + +$toolbar.Controls.Add((New-Label '刷新间隔' 12 6 60 28 $fontUI $C_FG_DIM)) + +$cmbInterval = New-Object System.Windows.Forms.ComboBox +$cmbInterval.DropDownStyle = 'DropDownList' +$cmbInterval.Location = New-Object System.Drawing.Point(74, 8) +$cmbInterval.Size = New-Object System.Drawing.Size(76, 26) +$cmbInterval.FlatStyle = 'Flat' +$cmbInterval.BackColor = $C_CARD; $cmbInterval.ForeColor = $C_FG +[void]$cmbInterval.Items.AddRange(@('1 秒', '2 秒', '3 秒', '5 秒', '10 秒')) +$cmbInterval.SelectedIndex = $(if ($script:Config.Interval -ge 10) { 4 } elseif ($script:Config.Interval -ge 5) { 3 } elseif ($script:Config.Interval -ge 3) { 2 } elseif ($script:Config.Interval -ge 2) { 1 } else { 0 }) +$toolbar.Controls.Add($cmbInterval) + +$chkAuto = New-Object System.Windows.Forms.CheckBox +$chkAuto.Text = '自动刷新'; $chkAuto.Checked = $true +$chkAuto.Location = New-Object System.Drawing.Point(160, 10) +$chkAuto.Size = New-Object System.Drawing.Size(84, 24) +$chkAuto.ForeColor = $C_FG +$chkAuto.BackColor = [System.Drawing.Color]::Transparent +$toolbar.Controls.Add($chkAuto) + +$chkOnlyGpu = New-Object System.Windows.Forms.CheckBox +$chkOnlyGpu.Text = '只看显存 ≥ 50MB' +$chkOnlyGpu.Checked = $false +$chkOnlyGpu.Location = New-Object System.Drawing.Point(248, 10) +$chkOnlyGpu.Size = New-Object System.Drawing.Size(150, 24) +$chkOnlyGpu.ForeColor = $C_FG +$chkOnlyGpu.BackColor = [System.Drawing.Color]::Transparent +$toolbar.Controls.Add($chkOnlyGpu) + +$btnRefresh = New-Button '立即刷新 (F5)' 406 6 118 28 $C_ACCENT ([System.Drawing.Color]::White) +$toolbar.Controls.Add($btnRefresh) + +$btnKill = New-Button '结束选中进程 (Del)' 532 6 158 28 (Get-Argb 255 150 45 45) $C_FG +$toolbar.Controls.Add($btnKill) + +$btnKillForce = New-Button '强制结束' 698 6 86 28 (Get-Argb 255 110 30 30) $C_FG +$toolbar.Controls.Add($btnKillForce) + +$btnExport = New-Button '导出监测数据' 792 6 108 28 $C_PANEL $C_FG +$toolbar.Controls.Add($btnExport) + +$lblSummary = New-Label '' 906 6 306 28 $fontUI $C_FG_DIM 'MiddleRight' +$lblSummary.Anchor = 'Top,Right' +$toolbar.Controls.Add($lblSummary) + +# ---------- 指标卡片区 ---------- +$stats = New-Object $panelClass +$stats.Location = New-Object System.Drawing.Point(0, 86) +$stats.Size = New-Object System.Drawing.Size($clientW, 190) +$stats.BackColor = $C_BG +$stats.Anchor = 'Top,Left,Right' +$form.Controls.Add($stats) + +# ---------- 进程表格 ---------- +$grid = New-Object System.Windows.Forms.DataGridView +$grid.Location = New-Object System.Drawing.Point(10, 286) +$grid.Size = New-Object System.Drawing.Size(($clientW - 20), 330) +$grid.Anchor = 'Top,Left,Right' +$grid.BackgroundColor = $C_PANEL +$grid.BorderStyle = 'None' +$grid.GridColor = $C_LINE +$grid.EnableHeadersVisualStyles = $false +$grid.ColumnHeadersDefaultCellStyle.BackColor = $C_CARD +$grid.ColumnHeadersDefaultCellStyle.ForeColor = $C_FG +$grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = $C_CARD +$grid.ColumnHeadersDefaultCellStyle.Font = $fontBold +$grid.ColumnHeadersHeight = 30 +$grid.ColumnHeadersHeightSizeMode = 'DisableResizing' +$grid.DefaultCellStyle.BackColor = $C_PANEL +$grid.DefaultCellStyle.ForeColor = $C_FG +$grid.DefaultCellStyle.SelectionBackColor = (Get-Argb 255 40 80 140) +$grid.DefaultCellStyle.SelectionForeColor = [System.Drawing.Color]::White +$grid.AlternatingRowsDefaultCellStyle.BackColor = Get-Argb 255 28 31 38 +$grid.RowHeadersVisible = $false +$grid.AllowUserToAddRows = $false +$grid.AllowUserToDeleteRows = $false +$grid.AllowUserToResizeRows = $false +$grid.ReadOnly = $true +$grid.SelectionMode = 'FullRowSelect' +$grid.MultiSelect = $true +$grid.RowTemplate.Height = 24 +$grid.AutoGenerateColumns = $false +$grid.AutoSizeColumnsMode = 'None' +$form.Controls.Add($grid) + +try { + $pi = [System.Windows.Forms.DataGridView].GetProperty('DoubleBuffered', [System.Reflection.BindingFlags]'Instance,NonPublic') + $pi.SetValue($grid, $true, $null) +} catch { } + +$dt = New-Object System.Data.DataTable 'Procs' +[void]$dt.Columns.Add('PID', [int]) +[void]$dt.Columns.Add('Name', [string]) +[void]$dt.Columns.Add('VramMiB', [double]) +[void]$dt.Columns.Add('VramPct', [double]) +[void]$dt.Columns.Add('Util', [double]) +[void]$dt.Columns.Add('Src', [string]) +[void]$dt.Columns.Add('Desc', [string]) +[void]$dt.Columns.Add('Path', [string]) +$dt.PrimaryKey = @($dt.Columns['PID']) + +$colDefs = @( + @{ N = 'PID'; H = 'PID'; W = 68; F = ''; A = 'MiddleCenter' }, + @{ N = 'Name'; H = '进程名'; W = 190; F = ''; A = 'MiddleLeft' }, + @{ N = 'VramMiB'; H = '显存 (MB)'; W = 100; F = 'N1'; A = 'MiddleRight' }, + @{ N = 'VramPct'; H = '显存占比'; W = 84; F = 'N1'; A = 'MiddleRight' }, + @{ N = 'Util'; H = 'GPU 占用 %'; W = 94; F = 'N1'; A = 'MiddleRight' }, + @{ N = 'Src'; H = '数据来源'; W = 96; F = ''; A = 'MiddleCenter' }, + @{ N = 'Desc'; H = '窗口标题'; W = 320; F = ''; A = 'MiddleLeft' }, + @{ N = 'Path'; H = '可执行文件路径'; W = 240; F = ''; A = 'MiddleLeft' } +) +foreach ($c in $colDefs) { + $col = New-Object System.Windows.Forms.DataGridViewTextBoxColumn + $col.Name = $c.N + $col.HeaderText = $c.H + $col.DataPropertyName = $c.N + $col.Width = $c.W + $col.SortMode = 'Automatic' + $col.DefaultCellStyle.Alignment = [System.Enum]::Parse([System.Windows.Forms.DataGridViewContentAlignment], $c.A) + if ($c.F) { $col.DefaultCellStyle.Format = $c.F } + [void]$grid.Columns.Add($col) +} +$grid.DataSource = $dt +$dt.DefaultView.Sort = 'VramMiB DESC' + +# ---------- 图表区 ---------- +$chart = New-Object $panelClass +$chart.Location = New-Object System.Drawing.Point(10, 626) +$chart.Size = New-Object System.Drawing.Size(($clientW - 20), 250) +$chart.Anchor = 'Top,Left,Right,Bottom' +$chart.BackColor = $C_PANEL +$form.Controls.Add($chart) + +# ---------- 状态栏 ---------- +$status = New-Object System.Windows.Forms.StatusStrip +$status.BackColor = $C_PANEL +$status.ForeColor = $C_FG_DIM +$status.SizingGrip = $false +$lblStatus = New-Object System.Windows.Forms.ToolStripStatusLabel +$lblStatus.Text = '正在初始化...' +$lblStatus.Spring = $true +$lblStatus.TextAlign = 'MiddleLeft' +[void]$status.Items.Add($lblStatus) +$lblFreed = New-Object System.Windows.Forms.ToolStripStatusLabel +$lblFreed.Text = '本次已释放: 0 MB' +[void]$status.Items.Add($lblFreed) +$form.Controls.Add($status) + +# ---------- 右键菜单 ---------- +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$menu.BackColor = $C_CARD; $menu.ForeColor = $C_FG +$miKill = $menu.Items.Add('结束进程') +$miForce = $menu.Items.Add('强制结束进程') +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +$miCopy = $menu.Items.Add('复制 PID') +$miPath = $menu.Items.Add('打开文件所在位置') +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +$miDetail = $menu.Items.Add('查看详细信息') +$grid.ContextMenuStrip = $menu + +# =========================================================================== +# 运行状态 +# =========================================================================== +$script:State = @{ + Gpu = $null + GpuName = '' + TotalMiB = 0.0 + SumProcMiB = 0.0 + SmiUsedMiB = 0.0 + Rows = @() + NvSmi = Find-NvSmiPath + LastTs = '' + Admin = Test-IsAdmin + FreedMiB = 0.0 + Busy = $false + Handle = $null + Ps = $null + Rs = $null + Ready = $false + NextRun = [datetime]::MinValue + Err = '' + + # ---- 采集性能统计(v2.0 新增:显示在状态栏,方便直观看到优化效果)---- + CollectMs = 0 + CollectMsAvg = 0.0 + CollectCount = 0 + UtilSkipped = $false + GpuBusy = $false + + # ---- 曲线历史:环形缓冲 ---- + # 原版用 ArrayList + RemoveAt(0),每轮搬移 900 个元素(O(n));环形缓冲写入是 O(1) + HistCap = 900 + HistBuf = (New-Object 'object[]' 900) + HistHead = 0 + HistCount = 0 +} + +# 曲线历史:追加一个采样点(O(1)) +function Add-History { + param([double]$MemMiB, [double]$UtilPct) + $s = $script:State + $cap = $s.HistCap + $idx = ($s.HistHead + $s.HistCount) % $cap + $s.HistBuf[$idx] = [pscustomobject]@{ + t = (Get-Date).ToString('HH:mm:ss') + mem = $MemMiB + util = $UtilPct + } + if ($s.HistCount -lt $cap) { $s.HistCount++ } + else { $s.HistHead = ($s.HistHead + 1) % $cap } +} + +# 曲线历史:按逻辑下标取第 Index 个采样点(0 = 最旧) +function Get-HistoryAt { + param([int]$Index) + $s = $script:State + if ($Index -lt 0 -or $Index -ge $s.HistCount) { return $null } + return $s.HistBuf[($s.HistHead + $Index) % $s.HistCap] +} + +# UI 更新缓存:PID -> 上次写入 DataRow 的值 +# 原来每个字段都要读一次 DataRow 再转字符串比较(7 次/行/轮),改用内存数组比较后快很多 +$script:UiCache = @{} + +# =========================================================================== +# 绘制:圆角矩形 +# =========================================================================== +function New-RoundRect { + param([single]$X, [single]$Y, [single]$W, [single]$H, [single]$R) + $p = New-Object System.Drawing.Drawing2D.GraphicsPath + $d = $R * 2 + $p.AddArc($X, $Y, $d, $d, 180, 90) + $p.AddArc($X + $W - $d, $Y, $d, $d, 270, 90) + $p.AddArc($X + $W - $d, $Y + $H - $d, $d, $d, 0, 90) + $p.AddArc($X, $Y + $H - $d, $d, $d, 90, 90) + $p.CloseFigure() + return $p +} + +# =========================================================================== +# 绘制:指标卡片(3 列 × 2 行) +# =========================================================================== +$stats.Add_Paint({ + param($sender, $e) + $g = $e.Graphics + $g.SmoothingMode = 'AntiAlias' + $g.Clear($C_BG) + + $W = $sender.Width + $padX = 6; $padY = 4 + $cols = 3; $rows = 2 + $cw = [int](($W - $padX * ($cols + 1)) / $cols) + $ch = [int](($sender.Height - $padY * ($rows + 1)) / $rows) + + $gpu = $script:State.Gpu + $totMiB = $script:State.TotalMiB + $usedMiB = 0.0; $utilGpu = 0.0; $utilMem = 0.0 + $temp = $null; $power = $null; $pmax = $null + if ($gpu) { + if ($null -ne $gpu.memUsed) { $usedMiB = [double]$gpu.memUsed } + if ($null -ne $gpu.utilGpu) { $utilGpu = [double]$gpu.utilGpu } + if ($null -ne $gpu.utilMem) { $utilMem = [double]$gpu.utilMem } + $temp = $gpu.temp; $power = $gpu.power; $pmax = $gpu.powerMax + } + $memPct = 0.0 + if ($totMiB -gt 0) { $memPct = [math]::Min(100.0, $usedMiB / $totMiB * 100.0) } + + $unattributed = [math]::Max(0.0, $script:State.SmiUsedMiB - $script:State.SumProcMiB) + $sumPct = 0.0 + if ($script:State.SmiUsedMiB -gt 0) { $sumPct = [math]::Min(100.0, $script:State.SumProcMiB / $script:State.SmiUsedMiB * 100.0) } + $tempBar = 0.0 + if ($null -ne $temp) { $tempBar = [math]::Min(100.0, [double]$temp) } + $powBar = 0.0 + if ($null -ne $power -and $null -ne $pmax -and [double]$pmax -gt 0) { $powBar = [math]::Min(100.0, [double]$power / [double]$pmax * 100.0) } + + $cards = @( + @{ T = '显存占用 (nvidia-smi)'; V = ('{0:N0} / {1:N0} MB' -f $usedMiB, $totMiB); S = ('{0:N1}% 空闲 {1:N0} MB' -f $memPct, [math]::Max(0, $totMiB - $usedMiB)); Bar = $memPct; K = 'mem' }, + @{ T = 'GPU 核心利用率'; V = ('{0:N0} %' -f $utilGpu); S = 'nvidia-smi utilization.gpu'; Bar = $utilGpu; K = 'util' }, + @{ T = '显存带宽利用率'; V = ('{0:N0} %' -f $utilMem); S = 'nvidia-smi utilization.memory'; Bar = $utilMem; K = 'mem' }, + @{ T = 'GPU 温度'; V = $(if ($null -ne $temp) { '{0:N0} °C' -f $temp } else { 'N/A' }); S = '核心温度'; Bar = $tempBar; K = 'temp' }, + @{ T = '功耗'; V = $(if ($null -ne $power) { '{0:N1} W' -f $power } else { 'N/A' }); S = $(if ($null -ne $pmax) { '上限 {0:N0} W' -f $pmax } else { '上限 N/A' }); Bar = $powBar; K = 'pow' }, + @{ T = '进程显存合计'; V = ('{0:N0} MB' -f $script:State.SumProcMiB); S = ('未归属(驱动/系统) {0:N0} MB' -f $unattributed); Bar = $sumPct; K = 'mem' } + ) + + for ($i = 0; $i -lt $cards.Count; $i++) { + $c = $cards[$i] + $r = [int][math]::Floor($i / $cols) + $col = $i % $cols + $x = $padX + $col * ($cw + $padX) + $y = $padY + $r * ($ch + $padY) + + $path = New-RoundRect $x $y $cw $ch 6 + $br = New-Object System.Drawing.SolidBrush($C_CARD) + $g.FillPath($br, $path) + $br.Dispose() + $pen = New-Object System.Drawing.Pen($C_LINE, 1) + $g.DrawPath($pen, $path) + $pen.Dispose() + $path.Dispose() + + $pctv = [double]$c.Bar + if ($pctv -lt 0) { $pctv = 0 } + if ($pctv -gt 100) { $pctv = 100 } + $vcol = $C_FG + switch ($c.K) { + 'mem' { if ($pctv -ge 90) { $vcol = $C_RED } elseif ($pctv -ge 70) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } } + 'util' { if ($pctv -ge 90) { $vcol = $C_RED } elseif ($pctv -ge 60) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } } + 'temp' { if ($null -eq $temp) { $vcol = $C_FG_DIM } elseif ($temp -ge 85) { $vcol = $C_RED } elseif ($temp -ge 70) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } } + 'pow' { if ($null -eq $power) { $vcol = $C_FG_DIM } else { $vcol = $C_ACCENT } } + } + + $b1 = New-Object System.Drawing.SolidBrush($C_FG_DIM) + $g.DrawString($c.T, $fontUI, $b1, ($x + 12), ($y + 8)) + $b2 = New-Object System.Drawing.SolidBrush($vcol) + $g.DrawString($c.V, $fontBig, $b2, ($x + 10), ($y + 26)) + + $barX = $x + 12; $barY = $y + $ch - 32; $barW = $cw - 24; $barH = 8 + $bg = New-Object System.Drawing.SolidBrush((Get-Argb 255 20 22 28)) + $g.FillRectangle($bg, $barX, $barY, $barW, $barH) + $bg.Dispose() + $fw = [int]($barW * $pctv / 100.0) + if ($fw -gt 0) { + $fbr = New-Object System.Drawing.SolidBrush($vcol) + $g.FillRectangle($fbr, $barX, $barY, $fw, $barH) + $fbr.Dispose() + } + $b3 = New-Object System.Drawing.SolidBrush($C_FG_DIM) + $g.DrawString($c.S, $fontSmall, $b3, ($x + 12), ($barY + 11)) + $b1.Dispose(); $b2.Dispose(); $b3.Dispose() + } +}) + +# =========================================================================== +# 绘制:实时曲线 +# v2.0:改用环形缓冲读取(Get-HistoryAt),并给采样数设上限,绘制成本稳定 +# =========================================================================== +$chart.Add_Paint({ + param($sender, $e) + $g = $e.Graphics + $g.SmoothingMode = 'AntiAlias' + $g.Clear($C_PANEL) + + $W = $sender.Width; $H = $sender.Height + $padL = 66; $padR = 56; $padT = 26; $padB = 22 + $plotW = $W - $padL - $padR + $plotH = $H - $padT - $padB + if ($plotW -lt 60 -or $plotH -lt 30) { return } + + $n = $script:State.HistCount + + $penGrid = New-Object System.Drawing.Pen((Get-Argb 255 52 57 68), 1) + $brAxis = New-Object System.Drawing.SolidBrush($C_FG_DIM) + $brTitle = New-Object System.Drawing.SolidBrush($C_FG) + $penMem = New-Object System.Drawing.Pen($C_ACCENT, 2) + $penUtil = New-Object System.Drawing.Pen($C_PURPLE, 2) + $brAccent = New-Object System.Drawing.SolidBrush($C_ACCENT) + $brPurple = New-Object System.Drawing.SolidBrush($C_PURPLE) + + for ($i = 0; $i -le 4; $i++) { + $y = $padT + $plotH * $i / 4.0 + $g.DrawLine($penGrid, $padL, $y, ($padL + $plotW), $y) + $s = [string]::Format('{0:N0}', ($script:State.TotalMiB * (4 - $i) / 4.0)) + $sz = $g.MeasureString($s, $fontAxis) + $g.DrawString($s, $fontAxis, $brAxis, ($padL - $sz.Width - 5), ($y - $sz.Height / 2)) + $g.DrawString(('{0:N0}%' -f (100.0 * (4 - $i) / 4.0)), $fontAxis, $brAxis, ($padL + $plotW + 5), ($y - $sz.Height / 2)) + } + + $g.DrawString('显存 / GPU 占用实时监测', $fontBold, $brTitle, $padL, 4) + + $lgX = $padL + 214 + $g.DrawLine($penMem, $lgX, 13, ($lgX + 22), 13) + $g.DrawString('显存占用 (MB)', $fontSmall, $brAxis, ($lgX + 26), 6) + $g.DrawLine($penUtil, ($lgX + 130), 13, ($lgX + 152), 13) + $g.DrawString('GPU 利用率 (%)', $fontSmall, $brAxis, ($lgX + 156), 6) + + # 采集耗时(右上角,直观体现性能) + if ($script:State.CollectMs -gt 0) { + $perf = '采集 {0} ms' -f $script:State.CollectMs + $szP = $g.MeasureString($perf, $fontAxis) + $g.DrawString($perf, $fontAxis, $brAxis, ($padL + $plotW - $szP.Width), ($padT + 18)) + } + + if ($n -lt 2) { + $msg = '正在采集数据...' + if ($script:State.Err) { $msg = '采集异常: ' + $script:State.Err } + elseif ($n -eq 0) { $msg = '正在初始化 GPU 性能计数器,首次采集约需 3~15 秒...' } + $sz = $g.MeasureString($msg, $fontBold) + $g.DrawString($msg, $fontBold, $brAxis, ($padL + ($plotW - $sz.Width) / 2), ($padT + $plotH / 2 - 10)) + $penGrid.Dispose(); $brAxis.Dispose(); $brTitle.Dispose(); $penMem.Dispose(); $penUtil.Dispose(); $brAccent.Dispose(); $brPurple.Dispose() + return + } + + $maxSamples = [int][math]::Max(2, [math]::Floor($plotW / 2)) + $start = [int][math]::Max(0, $n - $maxSamples) + $cnt = $n - $start + $total = $script:State.TotalMiB + if ($total -le 0) { $total = 1 } + + $y90 = $padT + $plotH * 0.1 + $penWarn = New-Object System.Drawing.Pen((Get-Argb 200 235 80 80), 1) + $penWarn.DashStyle = 'Dash' + $g.DrawLine($penWarn, $padL, $y90, ($padL + $plotW), $y90) + $brWarn = New-Object System.Drawing.SolidBrush((Get-Argb 200 235 80 80)) + $g.DrawString('90%', $fontAxis, $brWarn, ($padL + 3), ($y90 - 14)) + + $ptsMem = New-Object System.Collections.ArrayList + $ptsUtil = New-Object System.Collections.ArrayList + for ($i = 0; $i -lt $cnt; $i++) { + # 注意:PowerShell 变量名不区分大小写,循环变量绝不能叫 $h, + # 否则会覆盖上面的面板高度 $H,导致后面的 ($H - 17) 报错。 + $sample = Get-HistoryAt ($start + $i) + if ($null -eq $sample) { continue } + $x = $padL + $plotW * ($i / [double]($cnt - 1)) + $mv = [double]$sample.mem + if ($mv -gt $total) { $mv = $total } + [void]$ptsMem.Add((New-Object System.Drawing.PointF($x, ($padT + $plotH * (1 - $mv / $total))))) + $uv = [double]$sample.util + if ($uv -lt 0) { $uv = 0 } + if ($uv -gt 100) { $uv = 100 } + [void]$ptsUtil.Add((New-Object System.Drawing.PointF($x, ($padT + $plotH * (1 - $uv / 100.0))))) + } + + if ($ptsMem.Count -ge 2) { + $poly = New-Object System.Drawing.Drawing2D.GraphicsPath + $poly.AddLines($ptsMem.ToArray()) + $last = $ptsMem[$ptsMem.Count - 1] + $first = $ptsMem[0] + $baseY = $padT + $plotH + $poly.AddLine($last.X, $last.Y, $last.X, $baseY) + $poly.AddLine($last.X, $baseY, $first.X, $baseY) + $poly.CloseFigure() + $fill = New-Object System.Drawing.SolidBrush((Get-Argb 60 76 160 255)) + $g.FillPath($fill, $poly) + $fill.Dispose(); $poly.Dispose() + + $g.DrawLines($penMem, $ptsMem.ToArray()) + $g.DrawLines($penUtil, $ptsUtil.ToArray()) + } + + # 注意:在方法调用的参数里直接写 $hist[$n - 1] 会被解析成 ($hist[$n]) - 1, + # 因此所有下标运算先行算好,再传给 DrawString。 + $idxLast = $n - 1 + $lastSample = Get-HistoryAt $idxLast + $firstSample = Get-HistoryAt $start + if ($null -ne $lastSample -and $null -ne $firstSample) { + $capMem = '{0:N0} MB' -f [double]$lastSample.mem + $capUtil = '{0:N0} %' -f [double]$lastSample.util + $capAxis = '{0} ~ {1} ({2} 个采样点)' -f $firstSample.t, $lastSample.t, $n + $g.DrawString($capMem, $fontBold, $brAccent, ($padL + $plotW - 72), ($padT + 2)) + $g.DrawString($capUtil, $fontBold, $brPurple, ($padL + $plotW + 3), ($padT + 2)) + $g.DrawString($capAxis, $fontAxis, $brAxis, $padL, ($H - 17)) + } + + $penGrid.Dispose(); $brAxis.Dispose(); $brTitle.Dispose(); $penMem.Dispose(); $penUtil.Dispose() + $penWarn.Dispose(); $brWarn.Dispose(); $brAccent.Dispose(); $brPurple.Dispose() +}) + +# =========================================================================== +# 后台采集(Runspace 异步,避免界面卡顿) +# =========================================================================== +function Initialize-Collector { + if ($script:State.Ready) { return } + $rs = [runspacefactory]::CreateRunspace() + $rs.ApartmentState = 'MTA' + $rs.ThreadOptions = 'ReuseThread' + $rs.Open() + $ps = [powershell]::Create() + $ps.Runspace = $rs + $script:State.Rs = $rs + $script:State.Ps = $ps + $script:State.Ready = $true +} + +function Start-Collect { + Initialize-Collector + if ($script:State.Handle) { return } + $ps = $script:State.Ps + [void]$ps.AddScript($script:CollectScript) + [void]$ps.AddArgument($script:State.NvSmi) + $script:State.Handle = $ps.BeginInvoke() + $script:State.Busy = $true +} + +function Complete-Collect { + if (-not $script:State.Handle) { return $false } + $ps = $script:State.Ps + if (-not $script:State.Handle.IsCompleted) { return $false } + + $out = $null + try { $out = $ps.EndInvoke($script:State.Handle) } catch { $script:State.Err = $_.Exception.Message } + $script:State.Handle = $null + $script:State.Busy = $false + + $errs = @() + try { $errs = @($ps.Streams.Error) } catch { } + try { $ps.Commands.Clear() } catch { } + try { $ps.Streams.Error.Clear(); $ps.Streams.Warning.Clear() } catch { } + + # 关键修复(v2.0):下一轮从「本轮结束」开始计时,保证轮与轮之间有空闲间隔。 + # 原版从「本轮开始」计时,一旦采集耗时超过刷新间隔就会变成背靠背采集、CPU 长期跑满; + # 放在这里也能在采集持续失败时避免热循环。 + $script:State.NextRun = (Get-Date).AddSeconds([int]$script:Config.Interval) + + if ($null -eq $out) { + if ($errs.Count -gt 0) { $script:State.Err = $errs[0].ToString() } + return $false + } + $text = ($out | Where-Object { $_ -is [string] } | Select-Object -Last 1) + if ([string]::IsNullOrWhiteSpace($text)) { + if ($errs.Count -gt 0) { $script:State.Err = $errs[0].ToString() } + return $false + } + try { + $data = $text | ConvertFrom-Json + } catch { + $script:State.Err = '解析采集结果失败: ' + $_.Exception.Message + return $false + } + Apply-Data $data + return $true +} + +function Apply-Data { + param($data) + $script:State.Err = '' + + # ---------------- GPU 级指标 ---------------- + $gpus = @($data.gpus) + $g = $null + if ($gpus.Count -gt 0) { $g = $gpus[0] } + $script:State.Gpu = $g + $script:State.TotalMiB = 0.0 + if ($g -and $null -ne $g.memTotal) { $script:State.TotalMiB = [double]$g.memTotal } + $script:State.SumProcMiB = 0.0 + $script:State.SmiUsedMiB = 0.0 + if ($null -ne $data.sumProcMiB) { $script:State.SumProcMiB = [double]$data.sumProcMiB } + if ($null -ne $data.smiUsedMiB) { $script:State.SmiUsedMiB = [double]$data.smiUsedMiB } + $script:State.LastTs = [string]$data.ts + $tot = $script:State.TotalMiB + + # ---------------- 采集性能统计(状态栏展示) ---------------- + if ($null -ne $data.ms) { + $script:State.CollectMs = [int]$data.ms + $script:State.CollectCount++ + if ($script:State.CollectCount -le 1) { + $script:State.CollectMsAvg = [double]$data.ms + } else { + $script:State.CollectMsAvg = $script:State.CollectMsAvg * 0.7 + [double]$data.ms * 0.3 + } + } + $script:State.UtilSkipped = [bool]$data.utilSkipped + $script:State.GpuBusy = [bool]$data.gpuBusy + + # ---------------- 进程行(空值保护:procs 为 null 时不再冒出 PID 0 幽灵行)---------------- + if ($null -eq $data.procs) { $rows = @() } else { $rows = @($data.procs) } + if ($chkOnlyGpu.Checked) { + $rows = @($rows | Where-Object { $null -ne $_ -and [double]$_.vramMiB -ge 50 }) + } + $script:State.Rows = $rows + + # 记录当前选中项(只在行增删时才需要恢复,避免每轮都清空/重设选中导致列表"跳") + $selPids = @{} + foreach ($r in @($grid.SelectedRows)) { + try { $selPids[[int]$r.Cells['PID'].Value] = $true } catch { } + } + + $seen = @{} + $added = 0; $removed = 0; $wrote = 0 + $grid.SuspendLayout() + try { + foreach ($p in $rows) { + $pidv = [int]$p.pid + $seen[$pidv] = $true + $row = $dt.Rows.Find($pidv) + if ($null -eq $row) { + $row = $dt.NewRow() + $row['PID'] = $pidv + $row['Name'] = ''; $row['Src'] = ''; $row['Desc'] = ''; $row['Path'] = '' + $row['VramMiB'] = 0.0; $row['VramPct'] = 0.0; $row['Util'] = 0.0 + $dt.Rows.Add($row) + $added++ + } + + $pct = 0.0 + if ($tot -gt 0) { $pct = [math]::Round([double]$p.vramMiB / $tot * 100, 2) } + $nameS = [string]$p.name + $vramD = [double]$p.vramMiB + $utilD = [double]$p.util + $srcS = [string]$p.src + $descS = [string]$p.desc + $pathS = [string]$p.path + + # 与内存缓存比对,只有变化才写 DataRow(原来每条都要读 7 次 DataRow 再转字符串比较) + $c = $script:UiCache[$pidv] + if ($null -eq $c -or + $c[0] -ne $nameS -or $c[1] -ne $vramD -or $c[2] -ne $pct -or + $c[3] -ne $utilD -or $c[4] -ne $srcS -or $c[5] -ne $descS -or $c[6] -ne $pathS) { + $row['Name'] = $nameS + $row['VramMiB'] = $vramD + $row['VramPct'] = $pct + $row['Util'] = $utilD + $row['Src'] = $srcS + $row['Desc'] = $descS + $row['Path'] = $pathS + $script:UiCache[$pidv] = @($nameS, $vramD, $pct, $utilD, $srcS, $descS, $pathS) + $wrote++ + } + } + + # 删除已退出的进程行(先收集 PID,避免对已删除行再取值) + $gone = New-Object System.Collections.ArrayList + foreach ($r in @($dt.Rows)) { + $id = [int]$r['PID'] + if (-not $seen[$id]) { [void]$gone.Add($id) } + } + foreach ($id in $gone) { + $row = $dt.Rows.Find($id) + if ($null -ne $row) { $dt.Rows.Remove($row) } + $script:UiCache.Remove($id) + $removed++ + } + } finally { + $grid.ResumeLayout($false) + } + + # 只有行数变化时才恢复选中,否则交给 DataGridView 自己保持 + if (($added -gt 0 -or $removed -gt 0) -and $selPids.Count -gt 0) { + $grid.ClearSelection() + foreach ($r in @($grid.Rows)) { + try { if ($selPids[[int]$r.Cells['PID'].Value]) { $r.Selected = $true } } catch { } + } + } + + # ---------------- 曲线历史(环形缓冲,O(1)) ---------------- + $memNow = 0.0; $utilNow = 0.0 + if ($g) { + if ($null -ne $g.memUsed) { $memNow = [double]$g.memUsed } + if ($null -ne $g.utilGpu) { $utilNow = [double]$g.utilGpu } + } + Add-History -MemMiB $memNow -UtilPct $utilNow + + $stats.Invalidate() + $chart.Invalidate() + + # ---------------- 状态栏 / 摘要 ---------------- + $parts = @() + if ($script:State.LastTs) { $parts += ('更新于 ' + $script:State.LastTs) } + $parts += ('进程 ' + $rows.Count + ' 个') + $parts += ('采集 ' + $script:State.CollectMs + ' ms(均 {0:N0})' -f $script:State.CollectMsAvg) + if ($script:State.UtilSkipped) { $parts += 'GPU% 缓存中' } + if (-not $script:State.NvSmi) { $parts += '⚠ 未找到 nvidia-smi(仅性能计数器数据)' } + elseif (-not $data.smiOk) { $parts += ('⚠ nvidia-smi 异常: ' + $data.smiErr) } + if (-not $data.cntOk) { $parts += ('⚠ GPU 计数器异常: ' + $data.cntErr) } + if ($tot -gt 0) { $parts += ('显存 ' + ('{0:N1}' -f ($script:State.SmiUsedMiB / $tot * 100)) + '%') } + $lblStatus.Text = ($parts -join ' | ') + + if ($rows.Count -gt 0) { + $top = $rows[0] + $lblSummary.Text = ('占用最高: {0} (PID {1}) {2:N0} MB' -f $top.name, $top.pid, $top.vramMiB) + } else { + $lblSummary.Text = '当前没有进程占用显存' + } + + if ($g) { + $t = 'N/A' + if ($null -ne $g.temp) { $t = ('{0:N0} °C' -f $g.temp) } + $lblGpuName.Text = ('{0} · 显存 {1:N0} MB · 温度 {2}' -f $g.name, $g.memTotal, $t) + } elseif (-not $script:State.NvSmi) { + $lblGpuName.Text = '未检测到 nvidia-smi,GPU 级指标不可用' + } +} + +# =========================================================================== +# 结束进程 +# =========================================================================== +function Show-Msg { + param([string]$Text, [string]$Icon = 'Information', [string]$Buttons = 'OK') + $btns = [System.Enum]::Parse([System.Windows.Forms.MessageBoxButtons], $Buttons) + $ico = [System.Enum]::Parse([System.Windows.Forms.MessageBoxIcon], $Icon) + # 必须显式指定 owner=$form:否则对话框在某些窗口层级下会跑到主窗口后面, + # 表现为「点了按钮没反应」,而且此时线程被模态阻塞,两个按钮都会失效。 + if ($form -and $form.IsHandleCreated) { + return [System.Windows.Forms.MessageBox]::Show($form, $Text, $script:AppName, $btns, $ico) + } + return [System.Windows.Forms.MessageBox]::Show($Text, $script:AppName, $btns, $ico) +} + +function Show-HandlerError { + param($ErrorRecord, [string]$Where = '操作') + $m = '' + try { $m = [string]$ErrorRecord.Exception.Message } catch { $m = [string]$ErrorRecord } + $st = '' + try { $st = [string]$ErrorRecord.ScriptStackTrace } catch { } + [void](Show-Msg "$Where 执行失败:`n`n$m`n`n$st" 'Error') +} + +function Invoke-Kill { + param([switch]$Force) + + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { + [void](Show-Msg '请先在列表中选择要结束的进程(可按住 Ctrl / Shift 多选)。') + return + } + + $targets = @() + foreach ($r in $sel) { + $targets += [pscustomobject]@{ + Pid = [int]$r.Cells['PID'].Value + Name = [string]$r.Cells['Name'].Value + Vram = [double]$r.Cells['VramMiB'].Value + } + } + + # 先做一遍干跑,得到分类结果用于确认对话框 + $preview = Stop-GpuProcess -Targets $targets -Force:$Force -DryRun + if ($preview.Blocked.Count -gt 0) { + $msg = "以下进程属于系统关键进程或本工具自身,已阻止结束:`n`n" + foreach ($t in $preview.Blocked) { $msg += (" · {0} (PID {1})`n" -f $t.Name, $t.Pid) } + [void](Show-Msg $msg 'Warning') + } + $list = @($preview.Ok + $preview.Warned) + if ($list.Count -eq 0) { return } + + $sum = 0.0 + $msg = "即将结束以下 $($list.Count) 个进程:`n`n" + foreach ($t in $list) { + $msg += (" · {0} (PID {1}) 占用 {2:N1} MB`n" -f $t.Name, $t.Pid, $t.Vram) + $sum += $t.Vram + } + $msg += "`n预计可释放约 $([math]::Round($sum, 1)) MB 显存。`n" + if ($preview.Warned.Count -gt 0) { $msg += "`n⚠ 包含系统外壳/关键进程,结束后桌面或任务栏可能短暂闪烁、重启。`n" } + if (-not $script:State.Admin) { + $msg += "`n⚠ 当前不是管理员权限:结束其他用户的进程会失败(错误「拒绝访问」)。`n" + + " 请关闭本程序,用 GpuMemTool.exe 重新启动并在 UAC 窗口点「是」。`n" + } + $msg += "`n确定继续吗?" + + $icon = 'Warning' + if ($preview.Warned.Count -gt 0) { $icon = 'Error' } + if ((Show-Msg $msg $icon 'YesNo') -ne 'Yes') { return } + if ($preview.Warned.Count -gt 0) { + if ((Show-Msg "再次确认:其中包含系统外壳/关键进程(如 dwm、explorer、svchost)。`n`n真的要继续吗?" 'Error' 'YesNo') -ne 'Yes') { return } + } + + $res = Stop-GpuProcess -Targets $targets -Force:$Force -PumpGui + if ($res.Messages.Count -gt 0) { $lblStatus.Text = [string]$res.Messages[$res.Messages.Count - 1] } + $script:State.FreedMiB += $res.FreedMiB + $lblFreed.Text = ('本次已释放: {0:N1} MB' -f $script:State.FreedMiB) + + # ---- 结果反馈:成功/失败都必须让用户看得见 ---- + $okCount = @($res.Killed).Count + $failCount = @($res.Failed).Count + $report = "已结束 $okCount 个进程,释放约 $([math]::Round($res.FreedMiB, 1)) MB 显存。" + if ($failCount -gt 0) { + $report += "`n`n失败 $failCount 个:" + foreach ($m in @($res.Messages)) { + if ([string]$m -like '*失败*' -or [string]$m -like '*跳过*') { $report += "`n · $m" } + } + # 区分两种最常见的失败原因,给出可操作的建议 + $denied = $false + $stuck = $false + foreach ($m in @($res.Messages)) { + if ([string]$m -match '拒绝访问|Access is denied|无法终止|Cannot stop') { $denied = $true } + if ([string]$m -match '进程仍未退出') { $stuck = $true } + } + if ($stuck) { + $report += "`n`n进程仍未退出。这类进程通常已经卡死,或受驱动/反作弊保护,`n" + + "用户态程序(包括任务管理器)都无法结束它。`n`n建议:`n" + + " 1) 按 Win + Ctrl + Shift + B 重启显卡驱动(屏幕会闪一下,常能释放卡住的显存);`n" + + " 2) 若仍无效,重启电脑即可彻底回收这部分显存。" + } elseif ($denied) { + if (-not $script:State.Admin) { + $report += "`n`n原因:权限不足(错误「拒绝访问」)。`n" + + "请关闭本程序,双击 GpuMemTool.exe 重新启动,并在 UAC 窗口点「是」。" + } else { + $report += "`n`n原因:该进程受系统或反作弊/安全软件保护(例如带 BattlEye、EAC 的游戏,`n" + + "或带有驱动级保护的软件),Windows 不允许外部结束它——即使是管理员。`n" + + "建议:在该程序内正常退出;或用任务管理器再试一次(通常同样会被拒绝)。" + } + } + } + [void](Show-Msg $report $(if ($failCount -gt 0) { 'Warning' } else { 'Information' })) + if (-not $script:State.Busy) { Start-Collect } +} + +# =========================================================================== +# 事件绑定 +# =========================================================================== +$btnRefresh.Add_Click({ + if (-not $script:State.Busy) { Start-Collect } + else { $lblStatus.Text = '正在采集中,请稍候...' } +}) +# 结束进程的入口统一包一层 try/catch:程序是 -WindowStyle Hidden 启动的, +# 未捕获的异常会写进看不见的控制台,表现为「按钮点了没反应」,这里改成弹窗报错。 +$btnKill.Add_Click({ try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' } }) +$btnKillForce.Add_Click({ try { Invoke-Kill -Force } catch { Show-HandlerError $_ '强制结束进程' } }) +$miKill.Add_Click({ try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' } }) +$miForce.Add_Click({ try { Invoke-Kill -Force } catch { Show-HandlerError $_ '强制结束进程' } }) + +$miCopy.Add_Click({ + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { return } + $t = ($sel | ForEach-Object { $_.Cells['PID'].Value }) -join "`r`n" + [System.Windows.Forms.Clipboard]::SetText($t) + $lblStatus.Text = "已复制 $($sel.Count) 个 PID 到剪贴板" +}) + +$miPath.Add_Click({ + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { return } + $file = [string]$sel[0].Cells['Path'].Value + if ([string]::IsNullOrWhiteSpace($file)) { + [void](Show-Msg '该进程没有可用的文件路径(可能是系统进程,或需要管理员权限才能读取)。') + return + } + if (Test-Path -LiteralPath $file) { Start-Process explorer.exe -ArgumentList ('/select,"' + $file + '"') } + else { [void](Show-Msg "文件不存在:`n$file" 'Warning') } +}) + +$miDetail.Add_Click({ + $sel = @($grid.SelectedRows) + if ($sel.Count -eq 0) { return } + $r = $sel[0] + $pidv = [int]$r.Cells['PID'].Value + $txt = New-Object System.Collections.ArrayList + foreach ($c in $colDefs) { + if ($c.N -eq 'Desc' -or $c.N -eq 'Path') { continue } + [void]$txt.Add(("{0,-12}: {1}" -f $c.H, $r.Cells[$c.N].Value)) + } + [void]$txt.Add(("窗口标题 : {0}" -f $r.Cells['Desc'].Value)) + [void]$txt.Add(("路径 : {0}" -f $r.Cells['Path'].Value)) + + # v2.0:命令行不再每轮全量采集(那是原来 715ms 的大头),改为打开本窗口时按需读取一次 + [void]$txt.Add('') + [void]$txt.Add('正在读取命令行...') + try { + $ci = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$pidv" -ErrorAction Stop + if ($ci -and -not [string]::IsNullOrWhiteSpace([string]$ci.CommandLine)) { + $txt[$txt.Count - 1] = "命令行 : $([string]$ci.CommandLine)" + } else { + $txt[$txt.Count - 1] = '命令行 : (读取不到,可能需要管理员权限)' + } + } catch { + $txt[$txt.Count - 1] = "命令行 : (读取失败:$($_.Exception.Message))" + } + + [void]$txt.Add('') + [void]$txt.Add(("tasklist 输出 (PID $pidv):")) + [void]$txt.Add('-' * 70) + $out = & "$env:SystemRoot\System32\tasklist.exe" /FI "PID eq $pidv" /V /FO LIST 2>&1 + foreach ($line in @($out)) { [void]$txt.Add([string]$line) } + + $f2 = New-Object System.Windows.Forms.Form + $f2.Text = "进程详细信息 - PID $pidv" + $f2.ClientSize = New-Object System.Drawing.Size(740, 520) + $f2.StartPosition = 'CenterParent' + $f2.BackColor = $C_BG; $f2.ForeColor = $C_FG + $tb = New-Object System.Windows.Forms.TextBox + $tb.Multiline = $true; $tb.ReadOnly = $true; $tb.ScrollBars = 'Both' + $tb.Dock = 'Fill'; $tb.Font = $fontMono + $tb.BackColor = $C_PANEL; $tb.ForeColor = $C_FG + $tb.Text = ($txt.ToArray() -join "`r`n") + $f2.Controls.Add($tb) + [void]$f2.ShowDialog($form) + $f2.Dispose() +}) + +$grid.Add_CellDoubleClick({ + param($sender, $e) + if ($e.RowIndex -ge 0) { $miDetail.PerformClick() } +}) + +$grid.Add_KeyDown({ + param($sender, $e) + if ($e.KeyCode -eq 'Delete') { try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' }; $e.Handled = $true } +}) + +$form.Add_KeyDown({ + param($sender, $e) + if ($e.KeyCode -eq 'F5') { if (-not $script:State.Busy) { Start-Collect }; $e.Handled = $true } +}) + +$chkOnlyGpu.Add_CheckedChanged({ + if (-not $script:State.Busy) { Start-Collect } +}) + +$cmbInterval.Add_SelectedIndexChanged({ + $map = @(1, 2, 3, 5, 10) + $script:Config.Interval = $map[$cmbInterval.SelectedIndex] +}) + +$btnExport.Add_Click({ + $hist = $script:State.Hist + if ($hist.Count -eq 0) { + [void](Show-Msg '还没有监测数据可导出。') + return + } + $dlg = New-Object System.Windows.Forms.SaveFileDialog + $dlg.Filter = 'CSV 文件 (*.csv)|*.csv' + $dlg.FileName = 'GPU监测数据_' + (Get-Date).ToString('yyyyMMdd_HHmmss') + '.csv' + if ($dlg.ShowDialog() -ne 'OK') { return } + $lines = New-Object System.Collections.ArrayList + [void]$lines.Add('时间,显存已用(MB),显存总量(MB),显存占用(%),GPU利用率(%)') + $tot = $script:State.TotalMiB + foreach ($sample in $hist) { + $pct = '' + if ($tot -gt 0) { $pct = ('{0:N2}' -f ($sample.mem / $tot * 100)) } + [void]$lines.Add(('{0},{1},{2},{3},{4}' -f $sample.t, $sample.mem, $tot, $pct, $sample.util)) + } + $lines | Set-Content -LiteralPath $dlg.FileName -Encoding UTF8 + [void](Show-Msg "已导出到:`n$($dlg.FileName)") +}) + +$btnElevate.Add_Click({ + try { + $scriptPath = $PSCommandPath + if ([string]::IsNullOrWhiteSpace($scriptPath)) { $scriptPath = $MyInvocation.MyCommand.Path } + $arg = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $scriptPath + '" -Elevated' + Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $arg -Verb RunAs + $form.Close() + } catch { + [void](Show-Msg "提权失败:$($_.Exception.Message)`n`n请关闭后右键选择「以管理员身份运行」。" 'Error') + } +}) + +# 定时器:驱动异步采集与界面刷新 +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 200 +$timer.Add_Tick({ + # 1) 有结果就收(后台采集是异步的,这里永远不阻塞 UI) + if ($script:State.Handle) { [void](Complete-Collect) } + # 2) 到点再启动下一轮。 + # NextRun 已由 Complete-Collect 在「采集结束」时设定,保证轮与轮之间有空闲间隔; + # 这里不能再按「开始时间」计时,否则采集慢于间隔时会背靠背采样、CPU 跑满。 + if ($chkAuto.Checked -and -not $script:State.Busy) { + if ((Get-Date) -ge $script:State.NextRun) { + Start-Collect + } + } +}) + +# =========================================================================== +# 启动 +# =========================================================================== +# 全局异常兜底:任何界面回调里的未捕获异常都弹窗提示, +# 否则会被 -WindowStyle Hidden 的隐藏控制台吞掉,用户只看到「按钮没反应」。 +try { + [System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException) +} catch { } +try { + [System.Windows.Forms.Application]::add_ThreadException( + [System.Windows.Forms.ThreadExceptionEventHandler]{ + param($sender, $evt) + try { + $txt = "界面操作发生未处理的错误:`r`n`r`n" + $evt.Exception.Message + + "`r`n`r`n" + $evt.Exception.StackTrace + [void][System.Windows.Forms.MessageBox]::Show($form, $txt, "$($script:AppName) - 错误", 'OK', 'Error') + } catch { } + }) +} catch { } + +$form.Add_Shown({ + if ($script:State.Admin) { + $lblAdmin.Text = '✔ 管理员权限' + $lblAdmin.ForeColor = $C_GREEN + $btnElevate.Visible = $false + } else { + $lblAdmin.Text = '⚠ 提权被取消:部分进程无法结束' + $lblAdmin.ForeColor = $C_RED + $btnElevate.Visible = $true + } + if (-not $script:State.NvSmi) { + $lblStatus.Text = '⚠ 未找到 nvidia-smi,将只使用 Windows 性能计数器数据。' + } else { + $lblStatus.Text = '正在初始化 GPU 性能计数器,首次采集约需 3~15 秒...' + } + $timer.Start() + Start-Collect + # NextRun 由 Complete-Collect 在采集结束时设置,这里置为「立刻可跑」即可 + $script:State.NextRun = [datetime]::MinValue +}) + +$form.Add_FormClosing({ + try { $timer.Stop() } catch { } + try { + if ($script:State.Handle) { $script:State.Ps.Stop() } + if ($script:State.Ps) { $script:State.Ps.Dispose() } + if ($script:State.Rs) { $script:State.Rs.Close(); $script:State.Rs.Dispose() } + } catch { } +}) + +[void]$form.ShowDialog() +$form.Dispose() \ No newline at end of file