1849 lines
81 KiB
PowerShell
1849 lines
81 KiB
PowerShell
#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() |