Files

1583 lines
69 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#Requires -Version 5.1
<#
=============================================================================
GPU 显存释放工具 (GpuMemTool) v1.1
-----------------------------------------------------------------------
功能:
1. 基于 nvidia-smi 读取 GPU 级指标:显存总量/已用/空闲、GPU 利用率、
显存带宽利用率、温度、功耗;
2. 逐进程显示「哪个进程占用了多少显存」,可按任意列排序;
3. 一键结束(释放)选定进程,可选「正常结束」/「强制结束」,带保护名单;
4. 显存 / GPU 占用率实时曲线监测 + 监测数据导出 CSV;
5. 启动时直接请求管理员权限(UAC)——非管理员运行本脚本会自动提权重启,
用户取消提权时仍可继续以普通权限使用。
技术说明(重要):
Windows(WDDM) 下 `nvidia-smi --query-compute-apps` 的 used_gpu_memory
通常返回 [N/A](仅 TCC 模式/CUDA 上下文有效),因此本工具在 nvidia-smi
之外,额外使用 Windows 性能计数器
Win32_PerfFormattedData_GPUPerformanceCounters_GPUProcessMemory(Local Usage)
逐进程统计显存占用,并与 nvidia-smi 的 CUDA 数据取较大值、标注来源。
实测总量基本吻合,差值即驱动/系统保留部分(界面上单独显示)。
注意:首次采集需初始化性能计数器,约需 5~15 秒,之后每次约 0.1~0.8 秒。
用法:
powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1
(直接运行即会自动弹出 UAC 请求管理员权限;也可用同目录的 RunAsAdmin.bat)
powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -SelfTest
(-SelfTest 无界面自检:采集一次并打印表格,便于排错,不会提权)
powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -KillPid 1234,5678 -Force
(-KillPid 命令行结束指定进程,仍受保护名单约束,便于脚本化清理显存,不会提权)
=============================================================================
#>
[CmdletBinding()]
param(
[switch]$SelfTest, # 无 GUI 自检
[int]$IntervalSeconds = 2, # 默认刷新间隔(秒)
[int[]]$KillPid, # 命令行模式:结束指定 PID
[switch]$Force, # 命令行模式:强制结束
[switch]$Elevated # 内部标记:已经过一次提权,避免 UAC 循环
)
$ErrorActionPreference = 'Continue'
$script:AppName = 'GPU 显存释放工具'
$script:AppVersion = 'v1.1'
$script:Config = @{ Interval = $IntervalSeconds }
# ===========================================================================
# 启动时直接请求管理员权限(UAC)
# · 非管理员启动 -> 立刻以 RunAs 重新启动自己,然后退出当前实例
# · 用户点了「否」或已带 -Elevated 标记 -> 不重复弹窗,继续以普通权限运行
# (窗口右上角会显示红色提示,并可点「以管理员身份重启」再试)
# · -SelfTest / -KillPid 等命令行模式不自动提权,避免脚本/自动化被 UAC 卡住
# ===========================================================================
if (-not $Elevated) {
$isAdminNow = $false
try {
$wi = [Security.Principal.WindowsIdentity]::GetCurrent()
$wp = New-Object Security.Principal.WindowsPrincipal($wi)
$isAdminNow = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch { }
if (-not $isAdminNow -and -not $SelfTest -and -not $KillPid) {
$selfPath = $PSCommandPath
if ([string]::IsNullOrWhiteSpace($selfPath)) {
try { $selfPath = $MyInvocation.MyCommand.Path } catch { }
}
if (-not [string]::IsNullOrWhiteSpace($selfPath) -and (Test-Path -LiteralPath $selfPath)) {
$q = [char]34
$argline = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ' + $q + $selfPath + $q +
' -IntervalSeconds ' + $IntervalSeconds + ' -Elevated'
$psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
try {
Start-Process -FilePath $psExe -ArgumentList $argline -Verb RunAs -ErrorAction Stop
exit 0 # 已交给提权后的新实例,当前实例退出
} catch {
# 用户取消 UAC,或提权失败:继续以普通权限运行,界面会给出提示
}
}
}
}
# ---------------------------------------------------------------------------
# 进程保护名单
# BlockNames : 结束会导致系统崩溃/蓝屏,禁止结束
# WarnNames : 系统外壳或关键进程,允许结束但需二次确认
# ---------------------------------------------------------------------------
$script:BlockNames = @(
'system', 'idle', 'registry', 'memory compression', 'secure system',
'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon',
'fontdrvhost', 'msmpeng', 'nissrv', 'sppsvc', 'trustedinstaller'
)
$script:WarnNames = @(
'dwm', 'explorer', 'svchost', 'audiodg', 'sihost', 'runtimebroker',
'searchhost', 'startmenuexperiencehost', 'shellexperiencehost',
'textinputhost', 'applicationframehost', 'widgetservice', 'widgets'
)
# ===========================================================================
# 通用函数
# ===========================================================================
function Find-NvSmiPath {
$cands = New-Object System.Collections.ArrayList
$cmd = Get-Command 'nvidia-smi.exe' -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source) { [void]$cands.Add($cmd.Source) }
[void]$cands.Add((Join-Path $env:SystemRoot 'System32\nvidia-smi.exe'))
[void]$cands.Add((Join-Path ${env:ProgramFiles} 'NVIDIA Corporation\NVSMI\nvidia-smi.exe'))
[void]$cands.Add('C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe')
foreach ($p in $cands) {
if ($p -and (Test-Path -LiteralPath $p)) { return $p }
}
return $null
}
function Test-IsAdmin {
try {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$pr = New-Object Security.Principal.WindowsPrincipal($id)
return $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch { return $false }
}
function Get-Argb {
param([int]$A, [int]$R, [int]$G, [int]$B)
return [System.Drawing.Color]::FromArgb($A, $R, $G, $B)
}
# ---------------------------------------------------------------------------
# 保护名单判定:本工具自身 + 直接父进程(通常是控制台宿主 powershell/cmd/
# Windows Terminal——结束它会连带关掉本窗口)。不再向上追溯更多层,
# 否则从资源管理器启动时会误把 explorer.exe 一并保护,导致无法释放它的显存。
# ---------------------------------------------------------------------------
function Get-ProtectedPidSet {
$set = @{}
$set[[int]$PID] = $true
$self = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction SilentlyContinue
if ($self) {
$pp = [int]$self.ParentProcessId
if ($pp -gt 0) { $set[$pp] = $true }
}
return $set
}
# ---------------------------------------------------------------------------
# 判断进程是否真的退出了
# Stop-Process 有可能「假成功」:调用没报错,但进程被驱动/反作弊挡住而不退出
# (典型:卡死的游戏进程只剩 1 个线程、工作集接近 0,却仍占着显存)。
# 所以结束之后必须轮询确认,不能相信返回值。
# ---------------------------------------------------------------------------
function Test-ProcessExited {
param([int]$ProcessId, [int]$TimeoutMs = 2000)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while ($sw.ElapsedMilliseconds -lt $TimeoutMs) {
if (-not (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { return $true }
Start-Sleep -Milliseconds 200
}
return $false
}
# ---------------------------------------------------------------------------
# 结束进程核心逻辑(GUI 与命令行共用)
# $Targets : 形如 @{ Pid = ..; Name = ..; Vram = .. } 的对象数组
# 返回 : Ok / Warned / Blocked / Killed / Failed / FreedMiB / Messages
# ---------------------------------------------------------------------------
function Stop-GpuProcess {
param(
[array]$Targets,
[switch]$Force,
[switch]$PumpGui, # GUI 模式:等待期间处理消息循环,避免界面假死
[switch]$DryRun # 只做分类判定,不真正结束进程
)
$protect = Get-ProtectedPidSet
$blocked = @(); $warned = @(); $ok = @()
foreach ($t in @($Targets)) {
$base = ([string]$t.Name -replace '\.exe$', '').ToLower()
if ($script:BlockNames -contains $base) { $blocked += $t; continue }
if ($protect.ContainsKey([int]$t.Pid)) { $blocked += $t; continue }
if ($script:WarnNames -contains $base) { $warned += $t; continue }
$ok += $t
}
if ($DryRun) {
return [pscustomobject]@{
Ok = $ok; Warned = $warned; Blocked = $blocked
Killed = @(); Failed = @(); FreedMiB = 0.0; Messages = @()
}
}
$messages = New-Object System.Collections.ArrayList
$killed = @(); $failed = @(); $freed = 0.0
foreach ($t in @($ok + $warned)) {
$vramTxt = '{0:N1}' -f [double]$t.Vram
$proc = Get-Process -Id ([int]$t.Pid) -ErrorAction SilentlyContinue
if (-not $proc) {
[void]$messages.Add("跳过 $($t.Name) (PID $($t.Pid)):进程已不存在")
continue
}
$expect = ([string]$t.Name -replace '\.exe$', '')
if (-not [string]::IsNullOrWhiteSpace($expect) -and $proc.ProcessName -ne $expect) {
[void]$messages.Add("跳过 $($t.Name) (PID $($t.Pid)):PID 已被复用(当前为 $($proc.ProcessName).exe),为避免误杀已中止")
$failed += $t
continue
}
$targetPid = [int]$t.Pid
$lastErr = ''
$how = ''
# ---- 第一步:正常关闭窗口 / 直接结束 ----
try {
if ($Force) {
Stop-Process -Id $targetPid -Force -ErrorAction Stop
$how = '强制结束'
} else {
$closed = $false
try { $closed = $proc.CloseMainWindow() } catch { $closed = $false }
if ($closed) {
$deadline = (Get-Date).AddSeconds(3)
while ((Get-Date) -lt $deadline) {
if ($PumpGui) { [System.Windows.Forms.Application]::DoEvents() }
Start-Sleep -Milliseconds 120
if (-not (Get-Process -Id $targetPid -ErrorAction SilentlyContinue)) { break }
}
}
if (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {
Stop-Process -Id $targetPid -ErrorAction Stop
$how = '结束'
} else {
$how = '正常关闭'
}
}
} catch {
$lastErr = $_.Exception.Message
}
# ---- 第二步:确认是否真的退出了;没退就换 WMI(以 SYSTEM 身份执行)再杀一次 ----
$gone = Test-ProcessExited -ProcessId $targetPid
if (-not $gone) {
try {
$ci = Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction Stop
if ($ci) {
$wr = Invoke-CimMethod -InputObject $ci -MethodName Terminate -ErrorAction Stop
if ($null -ne $wr -and $wr.ReturnValue -ne 0) {
if ([string]::IsNullOrWhiteSpace($lastErr)) { $lastErr = "WMI Terminate 返回码 $($wr.ReturnValue)" }
}
}
} catch {
if ([string]::IsNullOrWhiteSpace($lastErr)) { $lastErr = $_.Exception.Message }
}
$gone = Test-ProcessExited -ProcessId $targetPid
if ($gone) { $how = '强制结束(WMI)' }
}
# ---- 第三步:如实上报 ----
if ($gone) {
[void]$messages.Add("已$how $($t.Name) (PID $targetPid),释放约 $vramTxt MB")
$killed += $t; $freed += [double]$t.Vram
} else {
$reason = $lastErr
if ([string]::IsNullOrWhiteSpace($reason)) { $reason = '已发送结束指令但进程未退出' }
[void]$messages.Add("结束 $($t.Name) (PID $targetPid) 失败:$reason(进程仍未退出,通常是驱动/反作弊保护或进程已挂起卡死)")
$failed += $t
}
}
return [pscustomobject]@{
Ok = $ok
Warned = $warned
Blocked = $blocked
Killed = $killed
Failed = $failed
FreedMiB = [math]::Round($freed, 1)
Messages = @($messages.ToArray())
}
}
# ===========================================================================
# 数据采集脚本块(在独立 Runspace 中执行,返回 JSON 字符串)
# ===========================================================================
$script:CollectScript = {
param([string]$NvSmiPath)
$ErrorActionPreference = 'SilentlyContinue'
# 注意:必须以「字符串数组」形式传参;PowerShell 不会对字符串变量做分词,
# 否则整串会被当作单个参数传给 nvidia-smi(表现为退出码 2)。
$SmiArgs = @(
'--query-gpu=index,name,memory.total,memory.used,memory.free,utilization.gpu,utilization.memory,temperature.gpu,power.draw,power.limit'
'--format=csv,noheader,nounits'
)
$ComputeArgs = @(
'--query-compute-apps=pid,process_name,used_gpu_memory'
'--format=csv,noheader,nounits'
)
function Convert-Num([string]$s) {
if ($null -eq $s) { return $null }
$t = $s.Trim()
if ($t -eq '' -or $t -like '*N/A*' -or $t -eq '[Not Supported]') { return $null }
$v = 0.0
if ([double]::TryParse($t, [ref]$v)) { return $v }
return $null
}
$res = [ordered]@{
ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
smiOk = $false
smiErr = ''
cntOk = $false
cntErr = ''
gpus = @()
procs = @()
smiUsedMiB = 0
sumProcMiB = 0
luidCount = 0
}
# ---------------- 1) nvidia-smi:GPU 级指标 ----------------
if ($NvSmiPath -and (Test-Path -LiteralPath $NvSmiPath)) {
try {
$raw = & $NvSmiPath $SmiArgs 2>&1
$code = $LASTEXITCODE
$rawLines = @($raw | Where-Object { $_ -is [string] })
$rawErrs = @($raw | Where-Object { $_ -isnot [string] })
if ($code -eq 0 -and $rawLines.Count -gt 0) {
$res.smiOk = $true
foreach ($line in $rawLines) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$f = @(($line -split ',') | ForEach-Object { $_.Trim() })
if ($f.Count -lt 10) { continue }
$res.gpus += , ([pscustomobject]@{
index = [int](Convert-Num $f[0])
name = $f[1]
memTotal = (Convert-Num $f[2])
memUsed = (Convert-Num $f[3])
memFree = (Convert-Num $f[4])
utilGpu = (Convert-Num $f[5])
utilMem = (Convert-Num $f[6])
temp = (Convert-Num $f[7])
power = (Convert-Num $f[8])
powerMax = (Convert-Num $f[9])
})
}
} else {
$msg = "nvidia-smi 退出码 $code"
if ($rawErrs.Count -gt 0) { $msg += ':' + ([string]$rawErrs[0]).Trim() }
$res.smiErr = $msg
}
# ------------- 2) nvidia-smi:CUDA 计算进程显存 -------------
$craw = & $NvSmiPath $ComputeArgs 2>&1
$ccode = $LASTEXITCODE
$crawLines = @($craw | Where-Object { $_ -is [string] })
if ($ccode -eq 0 -and $crawLines.Count -gt 0) {
foreach ($line in $crawLines) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$f = @(($line -split ',') | ForEach-Object { $_.Trim() })
if ($f.Count -lt 3) { continue }
$pidv = 0
if (-not [int]::TryParse($f[0], [ref]$pidv)) { continue }
$res.procs += , ([pscustomobject]@{
pid = $pidv
path = $f[1]
cudaMiB = (Convert-Num $f[2])
})
}
}
} catch {
$res.smiErr = $_.Exception.Message
}
} else {
$res.smiErr = 'nvidia-smi 未找到'
}
# ------------- 3) Windows 计数器:逐进程显存 / GPU 占用 -------------
$memByPid = @{}
$utilByPid = @{}
try {
$mem = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUProcessMemory -ErrorAction Stop
$res.cntOk = $true
$luids = @{}
foreach ($m in @($mem)) {
if ($m.Name -notmatch '^pid_(\d+)_luid_(0x[0-9A-Fa-f]+)_(0x[0-9A-Fa-f]+)_phys_(\d+)$') { continue }
$pidv = [int]$Matches[1]
$luids[$Matches[2] + '_' + $Matches[3]] = $true
$bytes = 0.0
[void][double]::TryParse([string]$m.LocalUsage, [ref]$bytes)
if ($bytes -lt 0) { $bytes = 0 }
if ($memByPid.ContainsKey($pidv)) { $memByPid[$pidv] += $bytes } else { $memByPid[$pidv] = $bytes }
}
$res.luidCount = $luids.Count
$eng = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine -ErrorAction SilentlyContinue
foreach ($e in @($eng)) {
if ($e.Name -notmatch '^pid_(\d+)_luid_') { continue }
$pidv = [int]$Matches[1]
$u = 0.0
[void][double]::TryParse([string]$e.UtilizationPercentage, [ref]$u)
if ($u -lt 0) { $u = 0 }
if ($utilByPid.ContainsKey($pidv)) { $utilByPid[$pidv] += $u } else { $utilByPid[$pidv] = $u }
}
} catch {
$res.cntErr = $_.Exception.Message
}
# ---------------- 4) 进程元信息 ----------------
$procMeta = @{}
foreach ($p in @(Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue)) {
$procMeta[[int]$p.ProcessId] = $p
}
$psMeta = @{}
foreach ($p in @(Get-Process -ErrorAction SilentlyContinue)) {
$psMeta[[int]$p.Id] = $p
}
# ---------------- 5) 合并 ----------------
$allPids = @{}
foreach ($k in $memByPid.Keys) { $allPids[$k] = $true }
foreach ($k in $utilByPid.Keys) { $allPids[$k] = $true }
$cudaMap = @{}
foreach ($c in $res.procs) { $cudaMap[[int]$c.pid] = $c }
foreach ($k in $cudaMap.Keys) { $allPids[$k] = $true }
$rows = New-Object System.Collections.ArrayList
$sumProc = 0.0
foreach ($pidv in $allPids.Keys) {
$cudaMiB = $null
if ($cudaMap.ContainsKey($pidv)) { $cudaMiB = $cudaMap[$pidv].cudaMiB }
$cntMiB = $null
if ($memByPid.ContainsKey($pidv)) { $cntMiB = [math]::Round($memByPid[$pidv] / 1MB, 1) }
$util = 0.0
if ($utilByPid.ContainsKey($pidv)) { $util = [math]::Round($utilByPid[$pidv], 1) }
$src = ''
$vram = 0.0
if ($null -ne $cudaMiB -and $null -ne $cntMiB) {
$vram = [math]::Max($cudaMiB, $cntMiB); $src = 'CUDA+WDDM'
} elseif ($null -ne $cudaMiB) {
$vram = $cudaMiB; $src = 'CUDA'
} elseif ($null -ne $cntMiB) {
$vram = $cntMiB; $src = 'WDDM'
}
if ($vram -lt 1 -and $util -lt 0.5) { continue }
$sumProc += $vram
$name = ''; $path = ''; $cmd = ''; $title = ''
if ($procMeta.ContainsKey($pidv)) {
$pm = $procMeta[$pidv]
$name = [string]$pm.Name
$path = [string]$pm.ExecutablePath
$cmd = [string]$pm.CommandLine
}
if ($psMeta.ContainsKey($pidv)) {
$ps = $psMeta[$pidv]
if ([string]::IsNullOrWhiteSpace($name)) { $name = $ps.ProcessName + '.exe' }
if ([string]::IsNullOrWhiteSpace($path)) { try { $path = [string]$ps.Path } catch { } }
try { $title = [string]$ps.MainWindowTitle } catch { }
}
if ([string]::IsNullOrWhiteSpace($name)) { $name = '(已退出? PID ' + $pidv + ')' }
if ([string]::IsNullOrWhiteSpace($path) -and $cudaMap.ContainsKey($pidv)) { $path = $cudaMap[$pidv].path }
$desc = $title
if ([string]::IsNullOrWhiteSpace($desc) -and -not [string]::IsNullOrWhiteSpace($cmd)) {
$desc = $cmd
if ($desc.Length -gt 200) { $desc = $desc.Substring(0, 200) + '...' }
}
[void]$rows.Add([pscustomobject]@{
pid = $pidv
name = $name
vramMiB = [math]::Round($vram, 1)
util = [math]::Min($util, 100.0)
src = $src
desc = $desc
path = $path
})
}
$res.procs = @($rows.ToArray() | Sort-Object -Property vramMiB -Descending)
$res.sumProcMiB = [math]::Round($sumProc, 1)
$tot = 0.0; $used = 0.0
foreach ($g in $res.gpus) {
if ($null -ne $g.memTotal) { $tot += $g.memTotal }
if ($null -ne $g.memUsed) { $used += $g.memUsed }
}
$res.smiTotalMiB = [math]::Round($tot, 1)
$res.smiUsedMiB = [math]::Round($used, 1)
return ($res | ConvertTo-Json -Depth 6 -Compress)
}
function Invoke-Collect {
param([string]$NvSmiPath)
$json = & $script:CollectScript $NvSmiPath
if ([string]::IsNullOrWhiteSpace($json)) { throw '采集脚本未返回数据' }
return ($json | ConvertFrom-Json)
}
# ===========================================================================
# 自检模式(无界面)
# ===========================================================================
if ($SelfTest) {
$smi = Find-NvSmiPath
Write-Host ("== {0} {1} 自检 ==" -f $script:AppName, $script:AppVersion) -ForegroundColor Cyan
Write-Host ("nvidia-smi : {0}" -f $(if ($smi) { $smi } else { '未找到' }))
Write-Host ("管理员 : {0}" -f (Test-IsAdmin))
$sw = [Diagnostics.Stopwatch]::StartNew()
$d = Invoke-Collect -NvSmiPath $smi
Write-Host ("采集耗时 : {0} ms" -f $sw.ElapsedMilliseconds)
Write-Host ("nvidia-smi : smiOk={0} {1}" -f $d.smiOk, $d.smiErr)
Write-Host ("性能计数器 : cntOk={0} {1}" -f $d.cntOk, $d.cntErr)
Write-Host ''
foreach ($g in @($d.gpus)) {
Write-Host ("GPU {0}: {1}" -f $g.index, $g.name) -ForegroundColor Yellow
$pmaxTxt = 'N/A'
if ($null -ne $g.powerMax) { $pmaxTxt = ('{0:N0} W' -f $g.powerMax) }
$pwrTxt = 'N/A'
if ($null -ne $g.power) { $pwrTxt = ('{0:N1} W' -f $g.power) }
Write-Host (" 显存 {0} / {1} MiB (空闲 {2}) GPU利用率 {3}% 带宽利用率 {4}% 温度 {5}C 功耗 {6} / 上限 {7}" -f `
$g.memUsed, $g.memTotal, $g.memFree, $g.utilGpu, $g.utilMem, $g.temp, $pwrTxt, $pmaxTxt)
}
if ($d.smiOk) {
Write-Host ("`n进程级合计 {0} MiB ;nvidia-smi 报告已用 {1} MiB ;差值 {2} MiB(驱动/系统保留)" -f `
$d.sumProcMiB, $d.smiUsedMiB, [math]::Round($d.smiUsedMiB - $d.sumProcMiB, 1)) -ForegroundColor Green
} else {
Write-Host ("`n进程级合计 {0} MiB (nvidia-smi 不可用,无法与驱动报告的总量对比)" -f $d.sumProcMiB) -ForegroundColor Green
}
Write-Host ("`n{0,-8} {1,-34} {2,10} {3,8} {4,-11} {5}" -f 'PID', '进程名', '显存(MB)', 'GPU%', '来源', '说明')
Write-Host ('-' * 120)
foreach ($p in @($d.procs)) {
$desc = [string]$p.desc; if ($desc.Length -gt 44) { $desc = $desc.Substring(0, 44) + '...' }
Write-Host ("{0,-8} {1,-34} {2,10:N1} {3,8:N1} {4,-11} {5}" -f $p.pid, $p.name, $p.vramMiB, $p.util, $p.src, $desc)
}
Write-Host ("`n共 {0} 个进程占用 GPU 资源。" -f @($d.procs).Count)
exit 0
}
# ===========================================================================
# 命令行结束进程模式(无界面)
# powershell -NoProfile -ExecutionPolicy Bypass -File GpuMemTool.ps1 -KillPid 1234,5678 -Force
# ===========================================================================
if ($KillPid -and @($KillPid).Count -gt 0) {
Write-Host ("== {0} {1} 结束进程 ==" -f $script:AppName, $script:AppVersion) -ForegroundColor Cyan
if (-not (Test-IsAdmin)) { Write-Host '[!] 当前不是管理员,其他用户的进程可能无法结束。' -ForegroundColor Yellow }
$smi = Find-NvSmiPath
$d = $null
try { $d = Invoke-Collect -NvSmiPath $smi } catch { Write-Host ("[!] 采集失败:{0}" -f $_.Exception.Message) -ForegroundColor Yellow }
$targets = @()
foreach ($id in @($KillPid)) {
$id = [int]$id
$name = ''; $vram = 0.0
if ($d) {
$info = @($d.procs) | Where-Object { [int]$_.pid -eq $id } | Select-Object -First 1
if ($info) { $name = [string]$info.name; $vram = [double]$info.vramMiB }
}
if ([string]::IsNullOrWhiteSpace($name)) {
$proc = Get-Process -Id $id -ErrorAction SilentlyContinue
if ($proc) { $name = $proc.ProcessName + '.exe' }
}
if ([string]::IsNullOrWhiteSpace($name)) { $name = '(未知进程)' }
$targets += [pscustomobject]@{ Pid = $id; Name = $name; Vram = $vram }
}
# 命令行模式下没有确认对话框:系统外壳/关键进程默认跳过,必须显式加 -Force
$pre = Stop-GpuProcess -Targets $targets -DryRun
if ($pre.Warned.Count -gt 0 -and -not $Force) {
foreach ($t in $pre.Warned) {
Write-Host ("[跳过] {0} (PID {1}):系统外壳/关键进程,需显式加 -Force 才会结束" -f $t.Name, $t.Pid) -ForegroundColor Yellow
}
$targets = @($pre.Ok)
}
$res = Stop-GpuProcess -Targets $targets -Force:$Force
foreach ($t in $res.Blocked) {
Write-Host ("[阻止] {0} (PID {1}):系统关键进程,或本工具自身/父进程" -f $t.Name, $t.Pid) -ForegroundColor Red
}
foreach ($m in $res.Messages) { Write-Host ("[执行] " + $m) -ForegroundColor Green }
foreach ($t in $res.Failed) { Write-Host ("[失败] {0} (PID {1})" -f $t.Name, $t.Pid) -ForegroundColor Red }
Write-Host ("`n共结束 {0} 个进程,预计释放 {1:N1} MB 显存。" -f @($res.Killed).Count, $res.FreedMiB) -ForegroundColor Cyan
exit 0
}
# ===========================================================================
# GUI
# ===========================================================================
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()
[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false)
# 双缓冲面板(消除重绘闪烁);编译失败则回退到普通 Panel
$panelClass = 'GpuTool.BufferedPanel'
if (-not ($panelClass -as [type])) {
try {
Add-Type -TypeDefinition @'
using System;
using System.Windows.Forms;
namespace GpuTool {
public class BufferedPanel : Panel {
public BufferedPanel() { this.DoubleBuffered = true; this.ResizeRedraw = true; }
}
}
'@ -ReferencedAssemblies 'System.Windows.Forms', 'System.Drawing' -ErrorAction Stop
} catch {
$panelClass = 'System.Windows.Forms.Panel'
}
}
# --------------------------- 配色 ---------------------------
$C_BG = Get-Argb 255 24 26 32
$C_PANEL = Get-Argb 255 32 35 43
$C_CARD = Get-Argb 255 38 42 52
$C_LINE = Get-Argb 255 56 61 74
$C_FG = Get-Argb 255 228 231 238
$C_FG_DIM = Get-Argb 255 150 157 172
$C_ACCENT = Get-Argb 255 76 160 255
$C_GREEN = Get-Argb 255 70 200 120
$C_AMBER = Get-Argb 255 240 180 60
$C_RED = Get-Argb 255 235 80 80
$C_PURPLE = Get-Argb 255 170 120 255
$fontUI = New-Object System.Drawing.Font('Microsoft YaHei UI', 9)
$fontSmall = New-Object System.Drawing.Font('Microsoft YaHei UI', 8)
$fontAxis = New-Object System.Drawing.Font('Microsoft YaHei UI', 7.5)
$fontBold = New-Object System.Drawing.Font('Microsoft YaHei UI', 10, [System.Drawing.FontStyle]::Bold)
$fontBig = New-Object System.Drawing.Font('Microsoft YaHei UI', 14, [System.Drawing.FontStyle]::Bold)
$fontTitle = New-Object System.Drawing.Font('Microsoft YaHei UI', 12, [System.Drawing.FontStyle]::Bold)
$fontMono = New-Object System.Drawing.Font('Consolas', 9)
function New-Label {
param($Text, $X, $Y, $W, $H, $Font = $fontUI, $Color = $C_FG, $Align = 'MiddleLeft')
$l = New-Object System.Windows.Forms.Label
$l.Text = $Text
$l.Location = New-Object System.Drawing.Point($X, $Y)
$l.Size = New-Object System.Drawing.Size($W, $H)
$l.Font = $Font; $l.ForeColor = $Color
$l.BackColor = [System.Drawing.Color]::Transparent
$l.TextAlign = [System.Enum]::Parse([System.Drawing.ContentAlignment], $Align)
return $l
}
function New-Button {
param($Text, $X, $Y, $W, $H, $Back = $C_PANEL, $Fore = $C_FG, $Font = $fontUI)
$b = New-Object System.Windows.Forms.Button
$b.Text = $Text
$b.Location = New-Object System.Drawing.Point($X, $Y)
$b.Size = New-Object System.Drawing.Size($W, $H)
$b.FlatStyle = 'Flat'
$b.FlatAppearance.BorderColor = $C_LINE
$b.BackColor = $Back; $b.ForeColor = $Fore; $b.Font = $Font
$b.UseVisualStyleBackColor = $false
return $b
}
# --------------------------- 主窗体 ---------------------------
# 尺寸与位置都由「屏幕可用区域」显式算出并夹取:
# StartPosition=CenterScreen 在某些 DPI/多屏环境下会把窗口放到屏幕外,
# 导致底部状态栏被截断,因此这里改为 Manual + 手动居中。
$wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
$ncW = 16 # 左右边框近似值
$ncH = 39 # 标题栏近似值
$clientW = [int][math]::Min(1220, [math]::Max(980, $wa.Width - $ncW - 24))
$clientH = [int][math]::Min(900, [math]::Max(600, $wa.Height - $ncH - 24))
$outerW = $clientW + $ncW
$outerH = $clientH + $ncH
$posX = [int]($wa.X + [math]::Max(0, ($wa.Width - $outerW) / 2))
$posY = [int]($wa.Y + [math]::Max(0, ($wa.Height - $outerH) / 2))
$form = New-Object System.Windows.Forms.Form
$form.Text = "$($script:AppName) $($script:AppVersion)"
$form.ClientSize = New-Object System.Drawing.Size($clientW, $clientH)
$form.MinimumSize = New-Object System.Drawing.Size([int][math]::Min(1020, $clientW), [int][math]::Min(840, $outerH))
$form.StartPosition = 'Manual'
$form.Location = New-Object System.Drawing.Point($posX, $posY)
$form.BackColor = $C_BG
$form.ForeColor = $C_FG
$form.Font = $fontUI
$form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None
$form.KeyPreview = $true
# ---------- 顶部标题栏 ----------
$header = New-Object System.Windows.Forms.Panel
$header.Location = New-Object System.Drawing.Point(0, 0)
$header.Size = New-Object System.Drawing.Size($clientW, 46)
$header.BackColor = $C_PANEL
$header.Anchor = 'Top,Left,Right'
$form.Controls.Add($header)
$header.Controls.Add((New-Label $script:AppName 12 8 300 30 $fontTitle $C_FG))
$lblGpuName = New-Label '' 316 8 430 30 $fontUI $C_FG_DIM
$header.Controls.Add($lblGpuName)
$lblAdmin = New-Label '' 750 8 250 30 $fontBold $C_RED 'MiddleRight'
$lblAdmin.Anchor = 'Top,Right'
$header.Controls.Add($lblAdmin)
$btnElevate = New-Button '以管理员身份重启' 1010 8 195 30 (Get-Argb 255 120 60 40) $C_FG
$btnElevate.Anchor = 'Top,Right'
$header.Controls.Add($btnElevate)
# ---------- 工具栏 ----------
$toolbar = New-Object System.Windows.Forms.Panel
$toolbar.Location = New-Object System.Drawing.Point(0, 46)
$toolbar.Size = New-Object System.Drawing.Size($clientW, 40)
$toolbar.BackColor = $C_BG
$toolbar.Anchor = 'Top,Left,Right'
$form.Controls.Add($toolbar)
$toolbar.Controls.Add((New-Label '刷新间隔' 12 6 60 28 $fontUI $C_FG_DIM))
$cmbInterval = New-Object System.Windows.Forms.ComboBox
$cmbInterval.DropDownStyle = 'DropDownList'
$cmbInterval.Location = New-Object System.Drawing.Point(74, 8)
$cmbInterval.Size = New-Object System.Drawing.Size(76, 26)
$cmbInterval.FlatStyle = 'Flat'
$cmbInterval.BackColor = $C_CARD; $cmbInterval.ForeColor = $C_FG
[void]$cmbInterval.Items.AddRange(@('1 秒', '2 秒', '3 秒', '5 秒', '10 秒'))
$cmbInterval.SelectedIndex = $(if ($script:Config.Interval -ge 10) { 4 } elseif ($script:Config.Interval -ge 5) { 3 } elseif ($script:Config.Interval -ge 3) { 2 } elseif ($script:Config.Interval -ge 2) { 1 } else { 0 })
$toolbar.Controls.Add($cmbInterval)
$chkAuto = New-Object System.Windows.Forms.CheckBox
$chkAuto.Text = '自动刷新'; $chkAuto.Checked = $true
$chkAuto.Location = New-Object System.Drawing.Point(160, 10)
$chkAuto.Size = New-Object System.Drawing.Size(84, 24)
$chkAuto.ForeColor = $C_FG
$chkAuto.BackColor = [System.Drawing.Color]::Transparent
$toolbar.Controls.Add($chkAuto)
$chkOnlyGpu = New-Object System.Windows.Forms.CheckBox
$chkOnlyGpu.Text = '只看显存 ≥ 50MB'
$chkOnlyGpu.Checked = $false
$chkOnlyGpu.Location = New-Object System.Drawing.Point(248, 10)
$chkOnlyGpu.Size = New-Object System.Drawing.Size(150, 24)
$chkOnlyGpu.ForeColor = $C_FG
$chkOnlyGpu.BackColor = [System.Drawing.Color]::Transparent
$toolbar.Controls.Add($chkOnlyGpu)
$btnRefresh = New-Button '立即刷新 (F5)' 406 6 118 28 $C_ACCENT ([System.Drawing.Color]::White)
$toolbar.Controls.Add($btnRefresh)
$btnKill = New-Button '结束选中进程 (Del)' 532 6 158 28 (Get-Argb 255 150 45 45) $C_FG
$toolbar.Controls.Add($btnKill)
$btnKillForce = New-Button '强制结束' 698 6 86 28 (Get-Argb 255 110 30 30) $C_FG
$toolbar.Controls.Add($btnKillForce)
$btnExport = New-Button '导出监测数据' 792 6 108 28 $C_PANEL $C_FG
$toolbar.Controls.Add($btnExport)
$lblSummary = New-Label '' 906 6 306 28 $fontUI $C_FG_DIM 'MiddleRight'
$lblSummary.Anchor = 'Top,Right'
$toolbar.Controls.Add($lblSummary)
# ---------- 指标卡片区 ----------
$stats = New-Object $panelClass
$stats.Location = New-Object System.Drawing.Point(0, 86)
$stats.Size = New-Object System.Drawing.Size($clientW, 190)
$stats.BackColor = $C_BG
$stats.Anchor = 'Top,Left,Right'
$form.Controls.Add($stats)
# ---------- 进程表格 ----------
$grid = New-Object System.Windows.Forms.DataGridView
$grid.Location = New-Object System.Drawing.Point(10, 286)
$grid.Size = New-Object System.Drawing.Size(($clientW - 20), 330)
$grid.Anchor = 'Top,Left,Right'
$grid.BackgroundColor = $C_PANEL
$grid.BorderStyle = 'None'
$grid.GridColor = $C_LINE
$grid.EnableHeadersVisualStyles = $false
$grid.ColumnHeadersDefaultCellStyle.BackColor = $C_CARD
$grid.ColumnHeadersDefaultCellStyle.ForeColor = $C_FG
$grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = $C_CARD
$grid.ColumnHeadersDefaultCellStyle.Font = $fontBold
$grid.ColumnHeadersHeight = 30
$grid.ColumnHeadersHeightSizeMode = 'DisableResizing'
$grid.DefaultCellStyle.BackColor = $C_PANEL
$grid.DefaultCellStyle.ForeColor = $C_FG
$grid.DefaultCellStyle.SelectionBackColor = (Get-Argb 255 40 80 140)
$grid.DefaultCellStyle.SelectionForeColor = [System.Drawing.Color]::White
$grid.AlternatingRowsDefaultCellStyle.BackColor = Get-Argb 255 28 31 38
$grid.RowHeadersVisible = $false
$grid.AllowUserToAddRows = $false
$grid.AllowUserToDeleteRows = $false
$grid.AllowUserToResizeRows = $false
$grid.ReadOnly = $true
$grid.SelectionMode = 'FullRowSelect'
$grid.MultiSelect = $true
$grid.RowTemplate.Height = 24
$grid.AutoGenerateColumns = $false
$grid.AutoSizeColumnsMode = 'None'
$form.Controls.Add($grid)
try {
$pi = [System.Windows.Forms.DataGridView].GetProperty('DoubleBuffered', [System.Reflection.BindingFlags]'Instance,NonPublic')
$pi.SetValue($grid, $true, $null)
} catch { }
$dt = New-Object System.Data.DataTable 'Procs'
[void]$dt.Columns.Add('PID', [int])
[void]$dt.Columns.Add('Name', [string])
[void]$dt.Columns.Add('VramMiB', [double])
[void]$dt.Columns.Add('VramPct', [double])
[void]$dt.Columns.Add('Util', [double])
[void]$dt.Columns.Add('Src', [string])
[void]$dt.Columns.Add('Desc', [string])
[void]$dt.Columns.Add('Path', [string])
$dt.PrimaryKey = @($dt.Columns['PID'])
$colDefs = @(
@{ N = 'PID'; H = 'PID'; W = 68; F = ''; A = 'MiddleCenter' },
@{ N = 'Name'; H = '进程名'; W = 190; F = ''; A = 'MiddleLeft' },
@{ N = 'VramMiB'; H = '显存 (MB)'; W = 100; F = 'N1'; A = 'MiddleRight' },
@{ N = 'VramPct'; H = '显存占比'; W = 84; F = 'N1'; A = 'MiddleRight' },
@{ N = 'Util'; H = 'GPU 占用 %'; W = 94; F = 'N1'; A = 'MiddleRight' },
@{ N = 'Src'; H = '数据来源'; W = 96; F = ''; A = 'MiddleCenter' },
@{ N = 'Desc'; H = '窗口标题 / 命令行'; W = 320; F = ''; A = 'MiddleLeft' },
@{ N = 'Path'; H = '可执行文件路径'; W = 240; F = ''; A = 'MiddleLeft' }
)
foreach ($c in $colDefs) {
$col = New-Object System.Windows.Forms.DataGridViewTextBoxColumn
$col.Name = $c.N
$col.HeaderText = $c.H
$col.DataPropertyName = $c.N
$col.Width = $c.W
$col.SortMode = 'Automatic'
$col.DefaultCellStyle.Alignment = [System.Enum]::Parse([System.Windows.Forms.DataGridViewContentAlignment], $c.A)
if ($c.F) { $col.DefaultCellStyle.Format = $c.F }
[void]$grid.Columns.Add($col)
}
$grid.DataSource = $dt
$dt.DefaultView.Sort = 'VramMiB DESC'
# ---------- 图表区 ----------
$chart = New-Object $panelClass
$chart.Location = New-Object System.Drawing.Point(10, 626)
$chart.Size = New-Object System.Drawing.Size(($clientW - 20), 250)
$chart.Anchor = 'Top,Left,Right,Bottom'
$chart.BackColor = $C_PANEL
$form.Controls.Add($chart)
# ---------- 状态栏 ----------
$status = New-Object System.Windows.Forms.StatusStrip
$status.BackColor = $C_PANEL
$status.ForeColor = $C_FG_DIM
$status.SizingGrip = $false
$lblStatus = New-Object System.Windows.Forms.ToolStripStatusLabel
$lblStatus.Text = '正在初始化...'
$lblStatus.Spring = $true
$lblStatus.TextAlign = 'MiddleLeft'
[void]$status.Items.Add($lblStatus)
$lblFreed = New-Object System.Windows.Forms.ToolStripStatusLabel
$lblFreed.Text = '本次已释放: 0 MB'
[void]$status.Items.Add($lblFreed)
$form.Controls.Add($status)
# ---------- 右键菜单 ----------
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$menu.BackColor = $C_CARD; $menu.ForeColor = $C_FG
$miKill = $menu.Items.Add('结束进程')
$miForce = $menu.Items.Add('强制结束进程')
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
$miCopy = $menu.Items.Add('复制 PID')
$miPath = $menu.Items.Add('打开文件所在位置')
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
$miDetail = $menu.Items.Add('查看详细信息')
$grid.ContextMenuStrip = $menu
# ===========================================================================
# 运行状态
# ===========================================================================
$script:State = @{
Gpu = $null
GpuName = ''
TotalMiB = 0.0
SumProcMiB = 0.0
SmiUsedMiB = 0.0
Rows = @()
Hist = New-Object System.Collections.ArrayList
NvSmi = Find-NvSmiPath
LastTs = ''
Admin = Test-IsAdmin
FreedMiB = 0.0
Busy = $false
Handle = $null
Ps = $null
Rs = $null
Ready = $false
NextRun = [datetime]::MinValue
Err = ''
}
# ===========================================================================
# 绘制:圆角矩形
# ===========================================================================
function New-RoundRect {
param([single]$X, [single]$Y, [single]$W, [single]$H, [single]$R)
$p = New-Object System.Drawing.Drawing2D.GraphicsPath
$d = $R * 2
$p.AddArc($X, $Y, $d, $d, 180, 90)
$p.AddArc($X + $W - $d, $Y, $d, $d, 270, 90)
$p.AddArc($X + $W - $d, $Y + $H - $d, $d, $d, 0, 90)
$p.AddArc($X, $Y + $H - $d, $d, $d, 90, 90)
$p.CloseFigure()
return $p
}
# ===========================================================================
# 绘制:指标卡片(3 列 × 2 行)
# ===========================================================================
$stats.Add_Paint({
param($sender, $e)
$g = $e.Graphics
$g.SmoothingMode = 'AntiAlias'
$g.Clear($C_BG)
$W = $sender.Width
$padX = 6; $padY = 4
$cols = 3; $rows = 2
$cw = [int](($W - $padX * ($cols + 1)) / $cols)
$ch = [int](($sender.Height - $padY * ($rows + 1)) / $rows)
$gpu = $script:State.Gpu
$totMiB = $script:State.TotalMiB
$usedMiB = 0.0; $utilGpu = 0.0; $utilMem = 0.0
$temp = $null; $power = $null; $pmax = $null
if ($gpu) {
if ($null -ne $gpu.memUsed) { $usedMiB = [double]$gpu.memUsed }
if ($null -ne $gpu.utilGpu) { $utilGpu = [double]$gpu.utilGpu }
if ($null -ne $gpu.utilMem) { $utilMem = [double]$gpu.utilMem }
$temp = $gpu.temp; $power = $gpu.power; $pmax = $gpu.powerMax
}
$memPct = 0.0
if ($totMiB -gt 0) { $memPct = [math]::Min(100.0, $usedMiB / $totMiB * 100.0) }
$unattributed = [math]::Max(0.0, $script:State.SmiUsedMiB - $script:State.SumProcMiB)
$sumPct = 0.0
if ($script:State.SmiUsedMiB -gt 0) { $sumPct = [math]::Min(100.0, $script:State.SumProcMiB / $script:State.SmiUsedMiB * 100.0) }
$tempBar = 0.0
if ($null -ne $temp) { $tempBar = [math]::Min(100.0, [double]$temp) }
$powBar = 0.0
if ($null -ne $power -and $null -ne $pmax -and [double]$pmax -gt 0) { $powBar = [math]::Min(100.0, [double]$power / [double]$pmax * 100.0) }
$cards = @(
@{ T = '显存占用 (nvidia-smi)'; V = ('{0:N0} / {1:N0} MB' -f $usedMiB, $totMiB); S = ('{0:N1}% 空闲 {1:N0} MB' -f $memPct, [math]::Max(0, $totMiB - $usedMiB)); Bar = $memPct; K = 'mem' },
@{ T = 'GPU 核心利用率'; V = ('{0:N0} %' -f $utilGpu); S = 'nvidia-smi utilization.gpu'; Bar = $utilGpu; K = 'util' },
@{ T = '显存带宽利用率'; V = ('{0:N0} %' -f $utilMem); S = 'nvidia-smi utilization.memory'; Bar = $utilMem; K = 'mem' },
@{ T = 'GPU 温度'; V = $(if ($null -ne $temp) { '{0:N0} °C' -f $temp } else { 'N/A' }); S = '核心温度'; Bar = $tempBar; K = 'temp' },
@{ T = '功耗'; V = $(if ($null -ne $power) { '{0:N1} W' -f $power } else { 'N/A' }); S = $(if ($null -ne $pmax) { '上限 {0:N0} W' -f $pmax } else { '上限 N/A' }); Bar = $powBar; K = 'pow' },
@{ T = '进程显存合计'; V = ('{0:N0} MB' -f $script:State.SumProcMiB); S = ('未归属(驱动/系统) {0:N0} MB' -f $unattributed); Bar = $sumPct; K = 'mem' }
)
for ($i = 0; $i -lt $cards.Count; $i++) {
$c = $cards[$i]
$r = [int][math]::Floor($i / $cols)
$col = $i % $cols
$x = $padX + $col * ($cw + $padX)
$y = $padY + $r * ($ch + $padY)
$path = New-RoundRect $x $y $cw $ch 6
$br = New-Object System.Drawing.SolidBrush($C_CARD)
$g.FillPath($br, $path)
$br.Dispose()
$pen = New-Object System.Drawing.Pen($C_LINE, 1)
$g.DrawPath($pen, $path)
$pen.Dispose()
$path.Dispose()
$pctv = [double]$c.Bar
if ($pctv -lt 0) { $pctv = 0 }
if ($pctv -gt 100) { $pctv = 100 }
$vcol = $C_FG
switch ($c.K) {
'mem' { if ($pctv -ge 90) { $vcol = $C_RED } elseif ($pctv -ge 70) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } }
'util' { if ($pctv -ge 90) { $vcol = $C_RED } elseif ($pctv -ge 60) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } }
'temp' { if ($null -eq $temp) { $vcol = $C_FG_DIM } elseif ($temp -ge 85) { $vcol = $C_RED } elseif ($temp -ge 70) { $vcol = $C_AMBER } else { $vcol = $C_GREEN } }
'pow' { if ($null -eq $power) { $vcol = $C_FG_DIM } else { $vcol = $C_ACCENT } }
}
$b1 = New-Object System.Drawing.SolidBrush($C_FG_DIM)
$g.DrawString($c.T, $fontUI, $b1, ($x + 12), ($y + 8))
$b2 = New-Object System.Drawing.SolidBrush($vcol)
$g.DrawString($c.V, $fontBig, $b2, ($x + 10), ($y + 26))
$barX = $x + 12; $barY = $y + $ch - 32; $barW = $cw - 24; $barH = 8
$bg = New-Object System.Drawing.SolidBrush((Get-Argb 255 20 22 28))
$g.FillRectangle($bg, $barX, $barY, $barW, $barH)
$bg.Dispose()
$fw = [int]($barW * $pctv / 100.0)
if ($fw -gt 0) {
$fbr = New-Object System.Drawing.SolidBrush($vcol)
$g.FillRectangle($fbr, $barX, $barY, $fw, $barH)
$fbr.Dispose()
}
$b3 = New-Object System.Drawing.SolidBrush($C_FG_DIM)
$g.DrawString($c.S, $fontSmall, $b3, ($x + 12), ($barY + 11))
$b1.Dispose(); $b2.Dispose(); $b3.Dispose()
}
})
# ===========================================================================
# 绘制:实时曲线
# ===========================================================================
$chart.Add_Paint({
param($sender, $e)
$g = $e.Graphics
$g.SmoothingMode = 'AntiAlias'
$g.Clear($C_PANEL)
$W = $sender.Width; $H = $sender.Height
$padL = 66; $padR = 56; $padT = 26; $padB = 22
$plotW = $W - $padL - $padR
$plotH = $H - $padT - $padB
if ($plotW -lt 60 -or $plotH -lt 30) { return }
$hist = $script:State.Hist
$n = $hist.Count
$penGrid = New-Object System.Drawing.Pen((Get-Argb 255 52 57 68), 1)
$brAxis = New-Object System.Drawing.SolidBrush($C_FG_DIM)
$brTitle = New-Object System.Drawing.SolidBrush($C_FG)
$penMem = New-Object System.Drawing.Pen($C_ACCENT, 2)
$penUtil = New-Object System.Drawing.Pen($C_PURPLE, 2)
$brAccent = New-Object System.Drawing.SolidBrush($C_ACCENT)
$brPurple = New-Object System.Drawing.SolidBrush($C_PURPLE)
for ($i = 0; $i -le 4; $i++) {
$y = $padT + $plotH * $i / 4.0
$g.DrawLine($penGrid, $padL, $y, ($padL + $plotW), $y)
$s = [string]::Format('{0:N0}', ($script:State.TotalMiB * (4 - $i) / 4.0))
$sz = $g.MeasureString($s, $fontAxis)
$g.DrawString($s, $fontAxis, $brAxis, ($padL - $sz.Width - 5), ($y - $sz.Height / 2))
$g.DrawString(('{0:N0}%' -f (100.0 * (4 - $i) / 4.0)), $fontAxis, $brAxis, ($padL + $plotW + 5), ($y - $sz.Height / 2))
}
$g.DrawString('显存 / GPU 占用实时监测', $fontBold, $brTitle, $padL, 4)
$lgX = $padL + 214
$g.DrawLine($penMem, $lgX, 13, ($lgX + 22), 13)
$g.DrawString('显存占用 (MB)', $fontSmall, $brAxis, ($lgX + 26), 6)
$g.DrawLine($penUtil, ($lgX + 130), 13, ($lgX + 152), 13)
$g.DrawString('GPU 利用率 (%)', $fontSmall, $brAxis, ($lgX + 156), 6)
if ($n -lt 2) {
$msg = '正在采集数据...'
if ($script:State.Err) { $msg = '采集异常: ' + $script:State.Err }
elseif ($n -eq 0) { $msg = '正在初始化 GPU 性能计数器,首次采集约需 5~15 秒...' }
$sz = $g.MeasureString($msg, $fontBold)
$g.DrawString($msg, $fontBold, $brAxis, ($padL + ($plotW - $sz.Width) / 2), ($padT + $plotH / 2 - 10))
$penGrid.Dispose(); $brAxis.Dispose(); $brTitle.Dispose(); $penMem.Dispose(); $penUtil.Dispose(); $brAccent.Dispose(); $brPurple.Dispose()
return
}
$maxSamples = [math]::Max(2, $plotW / 2)
$start = [int][math]::Max(0, $n - $maxSamples)
$cnt = $n - $start
$total = $script:State.TotalMiB
if ($total -le 0) { $total = 1 }
$y90 = $padT + $plotH * 0.1
$penWarn = New-Object System.Drawing.Pen((Get-Argb 200 235 80 80), 1)
$penWarn.DashStyle = 'Dash'
$g.DrawLine($penWarn, $padL, $y90, ($padL + $plotW), $y90)
$brWarn = New-Object System.Drawing.SolidBrush((Get-Argb 200 235 80 80))
$g.DrawString('90%', $fontAxis, $brWarn, ($padL + 3), ($y90 - 14))
$ptsMem = New-Object System.Collections.ArrayList
$ptsUtil = New-Object System.Collections.ArrayList
for ($i = 0; $i -lt $cnt; $i++) {
# 注意:PowerShell 变量名不区分大小写,循环变量绝不能叫 $h,
# 否则会覆盖上面的面板高度 $H,导致后面的 ($H - 17) 报错。
$sample = $hist[$start + $i]
$x = $padL + $plotW * ($i / [double]($cnt - 1))
$mv = [double]$sample.mem
if ($mv -gt $total) { $mv = $total }
[void]$ptsMem.Add((New-Object System.Drawing.PointF($x, ($padT + $plotH * (1 - $mv / $total)))))
$uv = [double]$sample.util
if ($uv -lt 0) { $uv = 0 }
if ($uv -gt 100) { $uv = 100 }
[void]$ptsUtil.Add((New-Object System.Drawing.PointF($x, ($padT + $plotH * (1 - $uv / 100.0)))))
}
if ($ptsMem.Count -ge 2) {
$poly = New-Object System.Drawing.Drawing2D.GraphicsPath
$poly.AddLines($ptsMem.ToArray())
$last = $ptsMem[$ptsMem.Count - 1]
$first = $ptsMem[0]
$baseY = $padT + $plotH
$poly.AddLine($last.X, $last.Y, $last.X, $baseY)
$poly.AddLine($last.X, $baseY, $first.X, $baseY)
$poly.CloseFigure()
$fill = New-Object System.Drawing.SolidBrush((Get-Argb 60 76 160 255))
$g.FillPath($fill, $poly)
$fill.Dispose(); $poly.Dispose()
$g.DrawLines($penMem, $ptsMem.ToArray())
$g.DrawLines($penUtil, $ptsUtil.ToArray())
}
# 注意:在方法调用的参数里直接写 $hist[$n - 1] 会被解析成 ($hist[$n]) - 1,
# 因此所有下标运算先行算好,再传给 DrawString。
$idxLast = $n - 1
$lastSample = $hist[$idxLast]
$firstSample = $hist[$start]
$capMem = '{0:N0} MB' -f [double]$lastSample.mem
$capUtil = '{0:N0} %' -f [double]$lastSample.util
$capAxis = '{0} ~ {1} ({2} 个采样点)' -f $firstSample.t, $lastSample.t, $n
$g.DrawString($capMem, $fontBold, $brAccent, ($padL + $plotW - 72), ($padT + 2))
$g.DrawString($capUtil, $fontBold, $brPurple, ($padL + $plotW + 3), ($padT + 2))
$g.DrawString($capAxis, $fontAxis, $brAxis, $padL, ($H - 17))
$penGrid.Dispose(); $brAxis.Dispose(); $brTitle.Dispose(); $penMem.Dispose(); $penUtil.Dispose()
$penWarn.Dispose(); $brWarn.Dispose(); $brAccent.Dispose(); $brPurple.Dispose()
})
# ===========================================================================
# 后台采集(Runspace 异步,避免界面卡顿)
# ===========================================================================
function Initialize-Collector {
if ($script:State.Ready) { return }
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'MTA'
$rs.ThreadOptions = 'ReuseThread'
$rs.Open()
$ps = [powershell]::Create()
$ps.Runspace = $rs
$script:State.Rs = $rs
$script:State.Ps = $ps
$script:State.Ready = $true
}
function Start-Collect {
Initialize-Collector
if ($script:State.Handle) { return }
$ps = $script:State.Ps
[void]$ps.AddScript($script:CollectScript)
[void]$ps.AddArgument($script:State.NvSmi)
$script:State.Handle = $ps.BeginInvoke()
$script:State.Busy = $true
}
function Complete-Collect {
if (-not $script:State.Handle) { return $false }
$ps = $script:State.Ps
if (-not $script:State.Handle.IsCompleted) { return $false }
$out = $null
try { $out = $ps.EndInvoke($script:State.Handle) } catch { $script:State.Err = $_.Exception.Message }
$script:State.Handle = $null
$script:State.Busy = $false
$errs = @()
try { $errs = @($ps.Streams.Error) } catch { }
try { $ps.Commands.Clear() } catch { }
try { $ps.Streams.Error.Clear(); $ps.Streams.Warning.Clear() } catch { }
if ($null -eq $out) {
if ($errs.Count -gt 0) { $script:State.Err = $errs[0].ToString() }
return $false
}
$text = ($out | Where-Object { $_ -is [string] } | Select-Object -Last 1)
if ([string]::IsNullOrWhiteSpace($text)) {
if ($errs.Count -gt 0) { $script:State.Err = $errs[0].ToString() }
return $false
}
try {
$data = $text | ConvertFrom-Json
} catch {
$script:State.Err = '解析采集结果失败: ' + $_.Exception.Message
return $false
}
Apply-Data $data
return $true
}
function Apply-Data {
param($data)
$script:State.Err = ''
$gpus = @($data.gpus)
$g = $null
if ($gpus.Count -gt 0) { $g = $gpus[0] }
$script:State.Gpu = $g
$script:State.TotalMiB = 0.0
if ($g -and $null -ne $g.memTotal) { $script:State.TotalMiB = [double]$g.memTotal }
$script:State.SumProcMiB = 0.0
$script:State.SmiUsedMiB = 0.0
if ($null -ne $data.sumProcMiB) { $script:State.SumProcMiB = [double]$data.sumProcMiB }
if ($null -ne $data.smiUsedMiB) { $script:State.SmiUsedMiB = [double]$data.smiUsedMiB }
$script:State.LastTs = [string]$data.ts
$rows = @($data.procs)
if ($chkOnlyGpu.Checked) { $rows = @($rows | Where-Object { $_.vramMiB -ge 50 }) }
$script:State.Rows = $rows
$selPids = @{}
foreach ($r in @($grid.SelectedRows)) {
try { $selPids[[int]$r.Cells['PID'].Value] = $true } catch { }
}
$seen = @{}
$changed = 0
foreach ($p in $rows) {
$pidv = [int]$p.pid
$seen[$pidv] = $true
$row = $dt.Rows.Find($pidv)
if ($null -eq $row) {
$row = $dt.NewRow()
$row['PID'] = $pidv
$row['Name'] = ''; $row['Src'] = ''; $row['Desc'] = ''; $row['Path'] = ''
$dt.Rows.Add($row)
$changed++
}
$pct = 0.0
if ($script:State.TotalMiB -gt 0) { $pct = [math]::Round($p.vramMiB / $script:State.TotalMiB * 100, 2) }
$vals = @{
'Name' = [string]$p.name
'VramMiB' = [double]$p.vramMiB
'VramPct' = $pct
'Util' = [double]$p.util
'Src' = [string]$p.src
'Desc' = [string]$p.desc
'Path' = [string]$p.path
}
foreach ($k in $vals.Keys) {
if ("$($row[$k])" -ne "$($vals[$k])") { $row[$k] = $vals[$k]; $changed++ }
}
}
$stale = @()
foreach ($r in $dt.Rows) { if (-not $seen[[int]$r['PID']]) { $stale += $r } }
foreach ($r in $stale) { $dt.Rows.Remove($r); $changed++ }
if ($selPids.Count -gt 0) {
$grid.ClearSelection()
foreach ($r in @($grid.Rows)) {
try { if ($selPids[[int]$r.Cells['PID'].Value]) { $r.Selected = $true } } catch { }
}
}
$memNow = 0.0; $utilNow = 0.0
if ($g) {
if ($null -ne $g.memUsed) { $memNow = [double]$g.memUsed }
if ($null -ne $g.utilGpu) { $utilNow = [double]$g.utilGpu }
}
[void]$script:State.Hist.Add([pscustomobject]@{ t = (Get-Date).ToString('HH:mm:ss'); mem = $memNow; util = $utilNow })
while ($script:State.Hist.Count -gt 900) { $script:State.Hist.RemoveAt(0) }
$stats.Invalidate()
$chart.Invalidate()
if ($changed -gt 0) { $grid.Invalidate() }
# 状态栏 / 摘要
$parts = @()
if ($script:State.LastTs) { $parts += ('更新于 ' + $script:State.LastTs) }
$parts += ('进程 ' + $rows.Count + ' 个')
if (-not $script:State.NvSmi) { $parts += '⚠ 未找到 nvidia-smi(仅性能计数器数据)' }
elseif (-not $data.smiOk) { $parts += ('⚠ nvidia-smi 异常: ' + $data.smiErr) }
if (-not $data.cntOk) { $parts += ('⚠ GPU 计数器异常: ' + $data.cntErr) }
if ($script:State.TotalMiB -gt 0) { $parts += ('显存 ' + ('{0:N1}' -f ($script:State.SmiUsedMiB / $script:State.TotalMiB * 100)) + '%') }
$lblStatus.Text = ($parts -join ' | ')
if ($rows.Count -gt 0) {
$top = $rows[0]
$lblSummary.Text = ('占用最高: {0} (PID {1}) {2:N0} MB' -f $top.name, $top.pid, $top.vramMiB)
} else {
$lblSummary.Text = '当前没有进程占用显存'
}
if ($g) {
$t = 'N/A'
if ($null -ne $g.temp) { $t = ('{0:N0} °C' -f $g.temp) }
$lblGpuName.Text = ('{0} · 显存 {1:N0} MB · 温度 {2}' -f $g.name, $g.memTotal, $t)
} elseif (-not $script:State.NvSmi) {
$lblGpuName.Text = '未检测到 nvidia-smi,GPU 级指标不可用'
}
}
# ===========================================================================
# 结束进程
# ===========================================================================
function Show-Msg {
param([string]$Text, [string]$Icon = 'Information', [string]$Buttons = 'OK')
$btns = [System.Enum]::Parse([System.Windows.Forms.MessageBoxButtons], $Buttons)
$ico = [System.Enum]::Parse([System.Windows.Forms.MessageBoxIcon], $Icon)
# 必须显式指定 owner=$form:否则对话框在某些窗口层级下会跑到主窗口后面,
# 表现为「点了按钮没反应」,而且此时线程被模态阻塞,两个按钮都会失效。
if ($form -and $form.IsHandleCreated) {
return [System.Windows.Forms.MessageBox]::Show($form, $Text, $script:AppName, $btns, $ico)
}
return [System.Windows.Forms.MessageBox]::Show($Text, $script:AppName, $btns, $ico)
}
function Show-HandlerError {
param($ErrorRecord, [string]$Where = '操作')
$m = ''
try { $m = [string]$ErrorRecord.Exception.Message } catch { $m = [string]$ErrorRecord }
$st = ''
try { $st = [string]$ErrorRecord.ScriptStackTrace } catch { }
[void](Show-Msg "$Where 执行失败:`n`n$m`n`n$st" 'Error')
}
function Invoke-Kill {
param([switch]$Force)
$sel = @($grid.SelectedRows)
if ($sel.Count -eq 0) {
[void](Show-Msg '请先在列表中选择要结束的进程(可按住 Ctrl / Shift 多选)。')
return
}
$targets = @()
foreach ($r in $sel) {
$targets += [pscustomobject]@{
Pid = [int]$r.Cells['PID'].Value
Name = [string]$r.Cells['Name'].Value
Vram = [double]$r.Cells['VramMiB'].Value
}
}
# 先做一遍干跑,得到分类结果用于确认对话框
$preview = Stop-GpuProcess -Targets $targets -Force:$Force -DryRun
if ($preview.Blocked.Count -gt 0) {
$msg = "以下进程属于系统关键进程或本工具自身,已阻止结束:`n`n"
foreach ($t in $preview.Blocked) { $msg += (" · {0} (PID {1})`n" -f $t.Name, $t.Pid) }
[void](Show-Msg $msg 'Warning')
}
$list = @($preview.Ok + $preview.Warned)
if ($list.Count -eq 0) { return }
$sum = 0.0
$msg = "即将结束以下 $($list.Count) 个进程:`n`n"
foreach ($t in $list) {
$msg += (" · {0} (PID {1}) 占用 {2:N1} MB`n" -f $t.Name, $t.Pid, $t.Vram)
$sum += $t.Vram
}
$msg += "`n预计可释放约 $([math]::Round($sum, 1)) MB 显存。`n"
if ($preview.Warned.Count -gt 0) { $msg += "`n⚠ 包含系统外壳/关键进程,结束后桌面或任务栏可能短暂闪烁、重启。`n" }
if (-not $script:State.Admin) {
$msg += "`n⚠ 当前不是管理员权限:结束其他用户的进程会失败(错误「拒绝访问」)。`n" +
" 请关闭本程序,用 GpuMemTool.exe 重新启动并在 UAC 窗口点「是」。`n"
}
$msg += "`n确定继续吗?"
$icon = 'Warning'
if ($preview.Warned.Count -gt 0) { $icon = 'Error' }
if ((Show-Msg $msg $icon 'YesNo') -ne 'Yes') { return }
if ($preview.Warned.Count -gt 0) {
if ((Show-Msg "再次确认:其中包含系统外壳/关键进程(如 dwm、explorer、svchost)。`n`n真的要继续吗?" 'Error' 'YesNo') -ne 'Yes') { return }
}
$res = Stop-GpuProcess -Targets $targets -Force:$Force -PumpGui
if ($res.Messages.Count -gt 0) { $lblStatus.Text = [string]$res.Messages[$res.Messages.Count - 1] }
$script:State.FreedMiB += $res.FreedMiB
$lblFreed.Text = ('本次已释放: {0:N1} MB' -f $script:State.FreedMiB)
# ---- 结果反馈:成功/失败都必须让用户看得见 ----
$okCount = @($res.Killed).Count
$failCount = @($res.Failed).Count
$report = "已结束 $okCount 个进程,释放约 $([math]::Round($res.FreedMiB, 1)) MB 显存。"
if ($failCount -gt 0) {
$report += "`n`n失败 $failCount 个:"
foreach ($m in @($res.Messages)) {
if ([string]$m -like '*失败*' -or [string]$m -like '*跳过*') { $report += "`n · $m" }
}
# 区分两种最常见的失败原因,给出可操作的建议
$denied = $false
$stuck = $false
foreach ($m in @($res.Messages)) {
if ([string]$m -match '拒绝访问|Access is denied|无法终止|Cannot stop') { $denied = $true }
if ([string]$m -match '进程仍未退出') { $stuck = $true }
}
if ($stuck) {
$report += "`n`n进程仍未退出。这类进程通常已经卡死,或受驱动/反作弊保护,`n" +
"用户态程序(包括任务管理器)都无法结束它。`n`n建议:`n" +
" 1) 按 Win + Ctrl + Shift + B 重启显卡驱动(屏幕会闪一下,常能释放卡住的显存);`n" +
" 2) 若仍无效,重启电脑即可彻底回收这部分显存。"
} elseif ($denied) {
if (-not $script:State.Admin) {
$report += "`n`n原因:权限不足(错误「拒绝访问」)。`n" +
"请关闭本程序,双击 GpuMemTool.exe 重新启动,并在 UAC 窗口点「是」。"
} else {
$report += "`n`n原因:该进程受系统或反作弊/安全软件保护(例如带 BattlEye、EAC 的游戏,`n" +
"或带有驱动级保护的软件),Windows 不允许外部结束它——即使是管理员。`n" +
"建议:在该程序内正常退出;或用任务管理器再试一次(通常同样会被拒绝)。"
}
}
}
[void](Show-Msg $report $(if ($failCount -gt 0) { 'Warning' } else { 'Information' }))
if (-not $script:State.Busy) { Start-Collect }
}
# ===========================================================================
# 事件绑定
# ===========================================================================
$btnRefresh.Add_Click({
if (-not $script:State.Busy) { Start-Collect }
else { $lblStatus.Text = '正在采集中,请稍候...' }
})
# 结束进程的入口统一包一层 try/catch:程序是 -WindowStyle Hidden 启动的,
# 未捕获的异常会写进看不见的控制台,表现为「按钮点了没反应」,这里改成弹窗报错。
$btnKill.Add_Click({ try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' } })
$btnKillForce.Add_Click({ try { Invoke-Kill -Force } catch { Show-HandlerError $_ '强制结束进程' } })
$miKill.Add_Click({ try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' } })
$miForce.Add_Click({ try { Invoke-Kill -Force } catch { Show-HandlerError $_ '强制结束进程' } })
$miCopy.Add_Click({
$sel = @($grid.SelectedRows)
if ($sel.Count -eq 0) { return }
$t = ($sel | ForEach-Object { $_.Cells['PID'].Value }) -join "`r`n"
[System.Windows.Forms.Clipboard]::SetText($t)
$lblStatus.Text = "已复制 $($sel.Count) 个 PID 到剪贴板"
})
$miPath.Add_Click({
$sel = @($grid.SelectedRows)
if ($sel.Count -eq 0) { return }
$file = [string]$sel[0].Cells['Path'].Value
if ([string]::IsNullOrWhiteSpace($file)) {
[void](Show-Msg '该进程没有可用的文件路径(可能是系统进程,或需要管理员权限才能读取)。')
return
}
if (Test-Path -LiteralPath $file) { Start-Process explorer.exe -ArgumentList ('/select,"' + $file + '"') }
else { [void](Show-Msg "文件不存在:`n$file" 'Warning') }
})
$miDetail.Add_Click({
$sel = @($grid.SelectedRows)
if ($sel.Count -eq 0) { return }
$r = $sel[0]
$pidv = [int]$r.Cells['PID'].Value
$txt = New-Object System.Collections.ArrayList
foreach ($c in $colDefs) {
if ($c.N -eq 'Desc' -or $c.N -eq 'Path') { continue }
[void]$txt.Add(("{0,-12}: {1}" -f $c.H, $r.Cells[$c.N].Value))
}
[void]$txt.Add(("命令行 : {0}" -f $r.Cells['Desc'].Value))
[void]$txt.Add(("路径 : {0}" -f $r.Cells['Path'].Value))
[void]$txt.Add('')
[void]$txt.Add(("tasklist 输出 (PID $pidv):"))
[void]$txt.Add('-' * 70)
$out = & "$env:SystemRoot\System32\tasklist.exe" /FI "PID eq $pidv" /V /FO LIST 2>&1
foreach ($line in @($out)) { [void]$txt.Add([string]$line) }
$f2 = New-Object System.Windows.Forms.Form
$f2.Text = "进程详细信息 - PID $pidv"
$f2.ClientSize = New-Object System.Drawing.Size(740, 520)
$f2.StartPosition = 'CenterParent'
$f2.BackColor = $C_BG; $f2.ForeColor = $C_FG
$tb = New-Object System.Windows.Forms.TextBox
$tb.Multiline = $true; $tb.ReadOnly = $true; $tb.ScrollBars = 'Both'
$tb.Dock = 'Fill'; $tb.Font = $fontMono
$tb.BackColor = $C_PANEL; $tb.ForeColor = $C_FG
$tb.Text = ($txt.ToArray() -join "`r`n")
$f2.Controls.Add($tb)
[void]$f2.ShowDialog($form)
$f2.Dispose()
})
$grid.Add_CellDoubleClick({
param($sender, $e)
if ($e.RowIndex -ge 0) { $miDetail.PerformClick() }
})
$grid.Add_KeyDown({
param($sender, $e)
if ($e.KeyCode -eq 'Delete') { try { Invoke-Kill } catch { Show-HandlerError $_ '结束进程' }; $e.Handled = $true }
})
$form.Add_KeyDown({
param($sender, $e)
if ($e.KeyCode -eq 'F5') { if (-not $script:State.Busy) { Start-Collect }; $e.Handled = $true }
})
$chkOnlyGpu.Add_CheckedChanged({
if (-not $script:State.Busy) { Start-Collect }
})
$cmbInterval.Add_SelectedIndexChanged({
$map = @(1, 2, 3, 5, 10)
$script:Config.Interval = $map[$cmbInterval.SelectedIndex]
})
$btnExport.Add_Click({
$hist = $script:State.Hist
if ($hist.Count -eq 0) {
[void](Show-Msg '还没有监测数据可导出。')
return
}
$dlg = New-Object System.Windows.Forms.SaveFileDialog
$dlg.Filter = 'CSV 文件 (*.csv)|*.csv'
$dlg.FileName = 'GPU监测数据_' + (Get-Date).ToString('yyyyMMdd_HHmmss') + '.csv'
if ($dlg.ShowDialog() -ne 'OK') { return }
$lines = New-Object System.Collections.ArrayList
[void]$lines.Add('时间,显存已用(MB),显存总量(MB),显存占用(%),GPU利用率(%)')
$tot = $script:State.TotalMiB
foreach ($sample in $hist) {
$pct = ''
if ($tot -gt 0) { $pct = ('{0:N2}' -f ($sample.mem / $tot * 100)) }
[void]$lines.Add(('{0},{1},{2},{3},{4}' -f $sample.t, $sample.mem, $tot, $pct, $sample.util))
}
$lines | Set-Content -LiteralPath $dlg.FileName -Encoding UTF8
[void](Show-Msg "已导出到:`n$($dlg.FileName)")
})
$btnElevate.Add_Click({
try {
$scriptPath = $PSCommandPath
if ([string]::IsNullOrWhiteSpace($scriptPath)) { $scriptPath = $MyInvocation.MyCommand.Path }
$arg = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $scriptPath + '" -Elevated'
Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $arg -Verb RunAs
$form.Close()
} catch {
[void](Show-Msg "提权失败:$($_.Exception.Message)`n`n请关闭后右键选择「以管理员身份运行」。" 'Error')
}
})
# 定时器:驱动异步采集与界面刷新
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 200
$timer.Add_Tick({
if ($script:State.Handle) { [void](Complete-Collect) }
if ($chkAuto.Checked -and -not $script:State.Busy) {
if ((Get-Date) -ge $script:State.NextRun) {
Start-Collect
$script:State.NextRun = (Get-Date).AddSeconds([int]$script:Config.Interval)
}
}
})
# ===========================================================================
# 启动
# ===========================================================================
# 全局异常兜底:任何界面回调里的未捕获异常都弹窗提示,
# 否则会被 -WindowStyle Hidden 的隐藏控制台吞掉,用户只看到「按钮没反应」。
try {
[System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException)
} catch { }
try {
[System.Windows.Forms.Application]::add_ThreadException(
[System.Windows.Forms.ThreadExceptionEventHandler]{
param($sender, $evt)
try {
$txt = "界面操作发生未处理的错误:`r`n`r`n" + $evt.Exception.Message +
"`r`n`r`n" + $evt.Exception.StackTrace
[void][System.Windows.Forms.MessageBox]::Show($form, $txt, "$($script:AppName) - 错误", 'OK', 'Error')
} catch { }
})
} catch { }
$form.Add_Shown({
if ($script:State.Admin) {
$lblAdmin.Text = '✔ 管理员权限'
$lblAdmin.ForeColor = $C_GREEN
$btnElevate.Visible = $false
} else {
$lblAdmin.Text = '⚠ 提权被取消:部分进程无法结束'
$lblAdmin.ForeColor = $C_RED
$btnElevate.Visible = $true
}
if (-not $script:State.NvSmi) {
$lblStatus.Text = '⚠ 未找到 nvidia-smi,将只使用 Windows 性能计数器数据。'
} else {
$lblStatus.Text = '正在初始化 GPU 性能计数器,首次采集约需 5~15 秒...'
}
$timer.Start()
Start-Collect
$script:State.NextRun = (Get-Date).AddSeconds([int]$script:Config.Interval)
})
$form.Add_FormClosing({
try { $timer.Stop() } catch { }
try {
if ($script:State.Handle) { $script:State.Ps.Stop() }
if ($script:State.Ps) { $script:State.Ps.Dispose() }
if ($script:State.Rs) { $script:State.Rs.Close(); $script:State.Rs.Dispose() }
} catch { }
})
[void]$form.ShowDialog()
$form.Dispose()