#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()