增加硬件监控
This commit is contained in:
Binary file not shown.
@@ -56,6 +56,7 @@ try:
|
||||
from .nodes_blockswap_sage import XB_Sage_BlockSwap
|
||||
from .nodes_wiring import XB_DynamicBus, XB_UNetNameBroadcaster, XB_CLIPNameBroadcaster
|
||||
from .nodes_dashboard import XB_Dashboard_Zen
|
||||
from .nodes_memory_viz import XB_MemoryVisualization
|
||||
from .nodes_tile import XB_SamplerChunkMaster
|
||||
from .nodes_wan_vae import (XB_WanImageToVideo, XB_WanFirstLastFrameToVideo, XB_WanSoundImageToVideo, XB_WanFunControlToVideo, XB_WanVaceToVideo, XB_Wan22FunControlToVideo, XB_WanInfiniteTalkToVideo, XB_WanInfiniteTalkToVideo_Single, XB_WanInfiniteTalkToVideo_Dual, XB_WanVAEDecodeTiled, XB_WanFunInpaintToVideo, XB_WanCameraImageToVideo, XB_WanPhantomSubjectToVideo, XB_WanHuMoImageToVideo, XB_Wan22ImageToVideoLatent, XB_WanSoundImageToVideoExtend, XB_WanSCAILToVideo, XB_WanSCAILToVideoPro, XB_WanDancerVideo, XB_WanDanceSwitcher, XB_WanDancerCombo, XB_BerniniConditioning)
|
||||
from .nodes_batch import XB_BatchFolderLoader
|
||||
@@ -135,6 +136,7 @@ try:
|
||||
"XB_UNetNameBroadcaster": XB_UNetNameBroadcaster,
|
||||
"XB_CLIPNameBroadcaster": XB_CLIPNameBroadcaster,
|
||||
"XB_Dashboard_Zen": XB_Dashboard_Zen,
|
||||
"XB_MemoryVisualization": XB_MemoryVisualization,
|
||||
"XB_SamplerChunkMaster": XB_SamplerChunkMaster,
|
||||
"XB_WanImageToVideo": XB_WanImageToVideo,
|
||||
"XB_WanFirstLastFrameToVideo": XB_WanFirstLastFrameToVideo,
|
||||
@@ -275,6 +277,7 @@ try:
|
||||
"XB_UNetNameBroadcaster": "XB-BOX - UNet Name Broadcaster",
|
||||
"XB_CLIPNameBroadcaster": "XB-BOX - CLIP Name Broadcaster",
|
||||
"XB_Dashboard_Zen": "XB-BOX - Dashboard Zen",
|
||||
"XB_MemoryVisualization": "XB 📊 硬件监控面板 (Memory Visualization)",
|
||||
"XB_SamplerChunkMaster": "XB-BOX - Sampler Chunk Master",
|
||||
"XB_WanImageToVideo": "XB-BOX - Wan Image2Video",
|
||||
"XB_WanFirstLastFrameToVideo": "XB-BOX - Wan First/Last Frame2Video",
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
# XB 📊 硬件监控面板 — 使用说明书
|
||||
|
||||
> **节点名称**: `XB_MemoryVisualization`
|
||||
> **显示名称**: `XB 📊 硬件监控面板 (Memory Visualization)`
|
||||
> **分类**: `XB_ToolBox/Dashboard`
|
||||
> **基于**: [ComfyUI-MemoryVisualization](https://github.com/kijai/ComfyUI-MemoryVisualization)
|
||||
> **适配**: 小白工具箱 (XB_ToolBox) / NVIDIA + AMD 双卡支持
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [功能概览](#1-功能概览)
|
||||
2. [安装与依赖](#2-安装与依赖)
|
||||
3. [快速上手](#3-快速上手)
|
||||
4. [面板布局详解](#4-面板布局详解)
|
||||
5. [右键菜单](#5-右键菜单)
|
||||
6. [迷你栏 (Mini Bar)](#6-迷你栏-mini-bar)
|
||||
7. [模型列表](#7-模型列表)
|
||||
8. [历史图表](#8-历史图表)
|
||||
9. [主题切换](#9-主题切换)
|
||||
10. [AMD GPU 用户须知](#10-amd-gpu-用户须知)
|
||||
11. [常见问题](#11-常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能概览
|
||||
|
||||
硬件监控面板是一个**实时、可拖拽、可缩放**的系统资源监控窗口,在 ComfyUI 界面右下角自动显示。
|
||||
|
||||
### 核心功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **VRAM 显存监控** | 实时显示 GPU 显存使用量、分段占比(模型/Torch/缓存/其他) |
|
||||
| **RAM 内存监控** | 系统内存、进程内存、固定内存、Python 占用 |
|
||||
| **GPU 状态** | 利用率、温度、功耗(NVIDIA 全支持 / AMD 需 LibreHardwareMonitor) |
|
||||
| **CPU 状态** | CPU 使用率、型号识别 |
|
||||
| **磁盘监控** | 磁盘读写速率、各分区剩余空间 |
|
||||
| **页面文件** | 虚拟内存使用情况 |
|
||||
| **模型详情** | 已加载模型列表、每个模型的 VRAM/内存分段占用 |
|
||||
| **历史图表** | VRAM 使用量趋势图(面积图/柱状图/K线图/点阵图) |
|
||||
| **快捷操作** | 一键卸载模型、清除缓存、执行/中断工作流 |
|
||||
|
||||
### 界面截图说明
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ≡ 硬件监控 [▶] [卸载▾] [⤤] [−] │ ← 标题栏(可拖拽)
|
||||
├─────────────────────────────────────────┤
|
||||
│ 内存 32.5GB|64.0GB │
|
||||
│ ████████░░░░░░░░░░░░░░ 固定/已加载/... │ ← 内容区
|
||||
│ 显存 9.0GB|15.9GB │
|
||||
│ ██████████████░░░░░░░░ 模型/Torch/... │
|
||||
│ 峰值: 12.3GB 缓存: 1.2GB GPU 45% 55°C │
|
||||
│ 页面文件 2.1GB|16.0GB │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 14:30 14:32 · 9.0GB 实时 │ ← 图表时间轴
|
||||
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← 历史图表
|
||||
│ ─────── ⬌ 拖拽调整图表高度 ─────── │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ▸ AutoencodingEngine 320 MB │ ← 模型列表
|
||||
│ ▸ Lumina2 5.7 GB │
|
||||
│ ▸ ZImageT2IModel 4.1 GB │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 安装与依赖
|
||||
|
||||
### 自动安装
|
||||
|
||||
面板**无需放置节点即可自动加载**。ComfyUI 启动后,监控窗口自动出现在界面右下角。
|
||||
|
||||
### 必备依赖
|
||||
|
||||
```bash
|
||||
pip install psutil pythonnet
|
||||
```
|
||||
|
||||
| 依赖 | 用途 | 必需 |
|
||||
|------|------|------|
|
||||
| `psutil` | CPU/内存/磁盘/进程信息 | ✅ 是 |
|
||||
| `pythonnet` | .NET 桥接,驱动 LibreHardwareMonitor | ✅ 是 |
|
||||
| `LibreHardwareMonitorLib.dll` | GPU 传感器读取(已内置) | ✅ 是 |
|
||||
| `nvidia-ml-py` | NVIDIA GPU 精准显存数据 | ⚠️ 可选 |
|
||||
|
||||
> `LibreHardwareMonitorLib.dll` 已内置在 `XB_ToolBox\` 目录中,无需额外下载。
|
||||
|
||||
### NVIDIA 用户(可选)
|
||||
|
||||
```bash
|
||||
pip install nvidia-ml-py
|
||||
```
|
||||
|
||||
安装后可获得设备级显存数据(而非进程级),Windows WDDM 下尤其有用。
|
||||
|
||||
---
|
||||
|
||||
## 3. 快速上手
|
||||
|
||||
### 3.1 启动面板
|
||||
|
||||
1. 启动 ComfyUI
|
||||
2. 面板**自动出现**在界面右下角
|
||||
3. 如果没有看到,检查 ComfyUI 控制台是否有报错
|
||||
|
||||
### 3.2 面板操作
|
||||
|
||||
| 操作 | 方法 |
|
||||
|------|------|
|
||||
| **移动面板** | 拖拽标题栏 |
|
||||
| **调整大小** | 拖拽面板左/右/下边缘或右下角 |
|
||||
| **折叠/展开** | 点击标题栏 `−` / `+` 按钮 |
|
||||
| **弹出窗口** | 点击 `⤤` 按钮(画中画模式) |
|
||||
| **运行工作流** | 点击 `▶` 按钮 |
|
||||
| **取消运行** | 运行中点击 `■` 按钮 |
|
||||
| **打开菜单** | 右键点击面板任意位置 |
|
||||
|
||||
### 3.3 工作流节点(可选)
|
||||
|
||||
你也可以将 `XB 📊 硬件监控面板` 节点拖入工作流:
|
||||
- 节点本身**不产生任何输出**
|
||||
- 执行节点时会在控制台打印当前 VRAM 状态
|
||||
- 主要用作工作流中的"书签"标记
|
||||
|
||||
---
|
||||
|
||||
## 4. 面板布局详解
|
||||
|
||||
### 4.1 标题栏
|
||||
|
||||
```
|
||||
≡ 硬件监控 [▶] [卸载▾] [⤤] [−]
|
||||
```
|
||||
|
||||
| 元素 | 功能 |
|
||||
|------|------|
|
||||
| `≡` | 拖拽手柄(停靠模式下可见) |
|
||||
| `硬件监控` | 面板标题 |
|
||||
| `▶` / `■` | 执行/中断工作流按钮 |
|
||||
| `卸载▾` | 卸载模型菜单 |
|
||||
| `⤤` | 弹出画中画窗口 |
|
||||
| `−` / `+` | 折叠/展开面板 |
|
||||
|
||||
### 4.2 内容区
|
||||
|
||||
#### 内存行
|
||||
```
|
||||
内存 32.5GB|64.0GB
|
||||
████████░░░░░░░░░░░░░░░░░░░░
|
||||
■ 固定 9.9GB ■ 已加载 0KB ■ Python 11.3GB ■ 其他 11.3GB
|
||||
```
|
||||
|
||||
- **固定内存 (Pinned)**:用于 GPU 快速传输的锁页内存
|
||||
- **已加载 (Loaded)**:已从磁盘加载到 RAM 的模型数据
|
||||
- **Python**:Python 进程自身占用
|
||||
- **其他**:系统及其他进程占用
|
||||
|
||||
#### 显存行
|
||||
```
|
||||
显存 (AMD Radeon RX 7900 XTX) 9.0GB|15.9GB
|
||||
████████████████░░░░░░░░░░░░░░░░
|
||||
■ 模型 5.7GB ■ Torch 2.1GB ■ 缓存 0.5GB ■ 其他 0.7GB
|
||||
峰值: 12.3GB 缓存: 1.2GB GPU 45% 55°C 9W ● 运行中
|
||||
```
|
||||
|
||||
- **模型 (Models)**:已加载模型占用的 VRAM
|
||||
- **Torch**:PyTorch 活跃分配
|
||||
- **缓存 (Cache)**:PyTorch 缓存池(可释放)
|
||||
- **其他 (Other)**:驱动及其他进程占用
|
||||
|
||||
底部信息行:
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| `峰值` | 自面板启动以来的 VRAM 最大使用量 |
|
||||
| `缓存` | PyTorch 缓存池大小 |
|
||||
| `GPU XX%` | GPU 核心利用率(点击可切换图表上的 GPU 曲线) |
|
||||
| `XX°C` | GPU 温度 |
|
||||
| `XX W` | GPU 功耗 |
|
||||
| `● 运行中` / `● 空闲` | 工作流执行状态 |
|
||||
|
||||
#### 页面文件行
|
||||
```
|
||||
页面文件 2.1GB|16.0GB
|
||||
████████░░░░░░░░░░░░░░░░░░░░░░
|
||||
```
|
||||
仅当系统启用了页面文件时显示。
|
||||
|
||||
---
|
||||
|
||||
## 5. 右键菜单
|
||||
|
||||
右键点击面板任意位置打开配置菜单:
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 缩放 ▸ │ 面板整体缩放 (50%~200%)
|
||||
│ 字号 ▸ │ 字体大小 (10px~20px)
|
||||
│ 轮询间隔 ▸ │ 数据刷新频率 (100ms~5s)
|
||||
│ 显示 ▸ │ 显示选项
|
||||
│ 迷你视图 ▸ │ 折叠状态下的迷你栏选项
|
||||
│ 图表 ▸ │ 图表样式和颜色
|
||||
│ 主题 ▸ │ 配色方案
|
||||
│ 停靠宽度 ▸ │ 停靠到顶栏时的宽度
|
||||
│ ✓ 停靠到顶栏 │ 切换浮动/停靠模式
|
||||
│ 重置历史 │ 清除图表历史数据
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 5.1 缩放子菜单
|
||||
|
||||
| 选项 | 效果 |
|
||||
|------|------|
|
||||
| 50% ~ 200% | 面板整体缩放,包括所有文字和图表 |
|
||||
|
||||
### 5.2 字号子菜单
|
||||
|
||||
| 选项 | 适用场景 |
|
||||
|------|----------|
|
||||
| 10px ~ 12px | 小屏幕 / 紧凑布局 |
|
||||
| 13px ~ 14px | 正常阅读 |
|
||||
| 16px ~ 20px | 大屏 / 远距离观看 |
|
||||
|
||||
> 字号与缩放独立:字号只改变文字大小,缩放改变整个面板尺寸。
|
||||
|
||||
### 5.3 轮询间隔子菜单
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| 100ms ~ 250ms | 极快刷新,CPU 开销较大 |
|
||||
| **500ms** | 默认,平衡刷新率和性能 |
|
||||
| 1s ~ 5s | 低频刷新,适合长时间监控 |
|
||||
|
||||
### 5.4 显示子菜单
|
||||
|
||||
| 选项 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| 模型条着色 | ☐ | 按模型类型着色进度条 |
|
||||
| 模型边框着色 | ☑ | 按模型类型着色边框 |
|
||||
| 模型名着色 | ☑ | 按模型类型着色名称 |
|
||||
| 显示图例 | ☑ | 显示分段图例文字 |
|
||||
| 显示标题 | ☑ | 显示面板标题 |
|
||||
| 执行按钮 | ☐ | 显示播放/停止按钮 |
|
||||
|
||||
### 5.5 迷你视图子菜单
|
||||
|
||||
控制面板折叠后迷你栏中显示哪些内容:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| RAM | 内存进度条 |
|
||||
| VRAM | 显存进度条 |
|
||||
| CPU | CPU 使用率 |
|
||||
| 页面文件 | 虚拟内存 |
|
||||
| GPU ▸ | GPU 子菜单(利用率/温度/功耗) |
|
||||
| 磁盘 ▸ | 磁盘子菜单(I/O + 各分区空间) |
|
||||
| 设备名称 | 显示 GPU/CPU 型号 |
|
||||
| 类型标签 | 显示 RAM/VRAM/CPU 标签 |
|
||||
| 数值显示 | 显示具体数值 |
|
||||
| 单位显示 | 显示 GB/MB/°C/W 单位 |
|
||||
| 分隔线 | 显示分组分隔线 |
|
||||
|
||||
### 5.6 图表子菜单
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| 面积图 (堆叠) | 默认,aimdo+Torch+其他分层显示 |
|
||||
| 柱状图 | 每3个采样点聚合为一个柱子 |
|
||||
| K线图 | 类似股票K线,显示开盘/收盘/最高/最低 |
|
||||
| 点阵 (渐变) | 由上到下渐变的点阵密度图 |
|
||||
| 显存颜色 | 自定义 VRAM 区域颜色 |
|
||||
| 总量颜色 | 自定义总量曲线颜色 |
|
||||
| 平滑度 | 总量曲线的移动平均窗口大小 (0~20) |
|
||||
|
||||
### 5.7 主题子菜单
|
||||
|
||||
| 主题 | 风格 |
|
||||
|------|------|
|
||||
| **default** | 深色主题,橙/绿/蓝配色 |
|
||||
| comfy | 跟随 ComfyUI 自身主题 |
|
||||
| light | 亮色主题 |
|
||||
| sepia | 复古暖色调 |
|
||||
| fallout | 哔哔小子辐射风(绿色 CRT) |
|
||||
| pink | 粉色少女风 |
|
||||
| lucifer | 黑底红字暗黑风 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 迷你栏 (Mini Bar)
|
||||
|
||||
面板折叠后显示的精简状态栏:
|
||||
|
||||
```
|
||||
RAM ████████░░░░ 32.5|64.0GB │ VRAM ██████░░░░ 9.0|15.9GB │ CPU ████░░ 45%
|
||||
```
|
||||
|
||||
迷你栏中的 GPU 区域支持**多行模式**(同时显示利用率/温度/功耗时自动启用更紧凑的排版)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 模型列表
|
||||
|
||||
展开面板后,底部显示所有已加载模型的详细信息:
|
||||
|
||||
```
|
||||
▸ AutoencodingEngine 320 MB [×]
|
||||
████████████████████████████████████░░░░░░░░
|
||||
■ VRAM 320MB ■ pinned 0KB ■ loaded 0KB ■ RAM 0KB
|
||||
|
||||
▾ Lumina2 5.7 GB [×]
|
||||
██████████████████████████████████░░░░░░░░░░
|
||||
■ VRAM 5.6GB ■ pinned 103MB ■ loaded 0KB ■ unloaded 4KB
|
||||
|
||||
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░ ← aimdo 页级驻留(需 aimdo)
|
||||
■ VRAM 5.6GB ■ unloaded 4KB
|
||||
```
|
||||
|
||||
| 元素 | 说明 |
|
||||
|------|------|
|
||||
| `▸` / `▾` | 点击模型名展开/折叠详情 |
|
||||
| `[×]` | 卸载该模型 |
|
||||
| 彩色进度条 | VRAM(橙) / 固定内存(蓝) / 已加载(深蓝) / 未加载(灰) |
|
||||
| 小方块网格 | aimdo 页面驻留可视化(需安装 comfy_aimdo) |
|
||||
| `(static)` | 标记为非动态加载的模型 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 历史图表
|
||||
|
||||
图表区域显示 VRAM 使用量随时间变化的趋势:
|
||||
|
||||
```
|
||||
14:30 14:32 · 9.0GB 实时
|
||||
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||
─────────────────────── ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← 虚线 = 总显存上限
|
||||
────────────────⬌ 拖拽调整图表高度 ────────────────
|
||||
```
|
||||
|
||||
| 操作 | 方法 |
|
||||
|------|------|
|
||||
| 查看历史 | 在图表上**左右拖拽**浏览历史数据 |
|
||||
| 回到实时 | 拖到最右侧自动恢复实时模式 |
|
||||
| 悬停详情 | 鼠标悬停在图表上查看具体时间/数值 |
|
||||
| 调整高度 | 拖拽图表下方的分隔条 |
|
||||
| 执行标记 | 绿色竖线 = 开始执行,红色竖线 = 执行结束 |
|
||||
|
||||
### 图表样式
|
||||
|
||||
| 样式 | 特点 |
|
||||
|------|------|
|
||||
| **面积图** | 堆叠显示 aimdo+Torch+其他,直观 |
|
||||
| 柱状图 | 每3秒聚合,干净简洁 |
|
||||
| K线图 | 精确显示波动范围 |
|
||||
| 点阵图 | 由上到下密度渐变,艺术感强 |
|
||||
|
||||
---
|
||||
|
||||
## 9. AMD GPU 用户须知
|
||||
|
||||
### 支持的 GPU
|
||||
|
||||
| GPU 系列 | 架构 | 利用率 | 温度 | 功耗 | VRAM |
|
||||
|----------|------|--------|------|------|------|
|
||||
| RX 7000 系列 | RDNA 3 | ✅ | ✅ | ✅ | ✅ |
|
||||
| RX 6000 系列 | RDNA 2 | ✅ | ✅ | ✅ | ✅ |
|
||||
| RX 5000 系列 | RDNA 1 | ✅ | ✅ | ✅ | ✅ |
|
||||
| Vega / Radeon VII | GCN | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### 监控原理
|
||||
|
||||
AMD GPU 监控通过 **LibreHardwareMonitor** 实现(一个跨厂商的开源硬件监控库)。原理是直接读取 GPU 的 SMU(System Management Unit)传感器数据,与 AMD Adrenalin 驱动面板使用相同的底层接口。
|
||||
|
||||
### 如果数据不显示
|
||||
|
||||
1. 确认 `XB_ToolBox\LibreHardwareMonitorLib.dll` 存在
|
||||
2. 确认已安装 `pythonnet`:`pip install pythonnet`
|
||||
3. 尝试**以管理员身份**运行 ComfyUI
|
||||
4. 查看 ComfyUI 控制台是否有 `aimdo-viz: LibreHardwareMonitor` 相关日志
|
||||
|
||||
### 功耗显示的差异
|
||||
|
||||
AMD 功耗显示为 **GPU Package**(整个显卡的总功耗),单位是瓦特 (W),没有功率上限对比。这与 NVIDIA 的显示格式略有不同(NVIDIA 有 `功耗/上限` 格式)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
### Q: 面板没有自动出现?
|
||||
|
||||
A: 检查 ComfyUI 控制台是否有 JavaScript 报错。常见原因:
|
||||
- `pythonnet` 未安装
|
||||
- `LibreHardwareMonitorLib.dll` 缺失
|
||||
- JS 文件加载失败(检查 `js/xb_memory_viz.js` 是否存在)
|
||||
|
||||
### Q: GPU 利用率/温度/功耗都显示"无数据"?
|
||||
|
||||
A:
|
||||
- **NVIDIA**:安装 `nvidia-ml-py` 并重启
|
||||
- **AMD**:确认 `pythonnet` 已安装,查看控制台日志
|
||||
|
||||
### Q: 面板太大/太小?
|
||||
|
||||
A: 右键 → **缩放** 调整面板大小,或右键 → **字号** 仅调整文字大小。
|
||||
|
||||
### Q: 如何让面板固定在顶栏?
|
||||
|
||||
A: 拖拽面板到顶部 ComfyUI 操作栏的"停靠左侧"或"停靠右侧"区域释放,或右键 → 停靠到顶栏。
|
||||
|
||||
### Q: 图表不更新?
|
||||
|
||||
A: 检查轮询间隔设置(右键 → 轮询间隔)。确保未将面板拖拽到历史数据中(右侧显示"实时"即为实时模式)。
|
||||
|
||||
### Q: 节点放到工作流中有用吗?
|
||||
|
||||
A: 面板**不需要**节点即可工作。节点的作用是:
|
||||
- 作为工作流书签
|
||||
- 执行时在控制台打印 VRAM 状态
|
||||
|
||||
---
|
||||
|
||||
## 附录:键盘与鼠标操作速查
|
||||
|
||||
| 操作 | 快捷键/方法 |
|
||||
|------|------------|
|
||||
| 移动面板 | 拖拽标题栏 |
|
||||
| 快速拖拽(任意位置) | `Ctrl` + 拖拽 |
|
||||
| 调整宽度 | 拖拽左/右边缘 |
|
||||
| 调整高度 | 拖拽底部边缘 |
|
||||
| 同时调整宽高 | 拖拽右下角 |
|
||||
| 折叠/展开 | 点击 `−` / `+` |
|
||||
| 弹出画中画 | 点击 `⤤` |
|
||||
| 打开菜单 | 右键面板 |
|
||||
| 关闭菜单 | 点击菜单外部 |
|
||||
| 浏览图表历史 | 在图表上左右拖拽 |
|
||||
| 图表悬停详情 | 鼠标悬停图表 |
|
||||
| 切换图表 GPU 曲线 | 点击 `GPU XX%` 文字 |
|
||||
| 折叠模型详情 | 点击模型名称 |
|
||||
| 卸载单个模型 | 点击模型行 `[×]` |
|
||||
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
aimdo memory visualization — static styles.
|
||||
|
||||
JS still owns dynamic values (positions, widths, scale/zoom, drop-zone hover state).
|
||||
All theme palettes live here, indexed by data-aimdo-theme on <html>. JS toggles
|
||||
that attribute and reads computed CSS variables back into a small C palette
|
||||
object — needed because <canvas> can't see CSS custom properties.
|
||||
*/
|
||||
|
||||
/* ---------------- themes ----------------
|
||||
:root holds the default theme. Other themes override only what differs from
|
||||
default — JS toggles them by writing data-aimdo-theme on <html>.
|
||||
fadeOutFrom / fadeOutTo are comma-separated RGB triplets parsed by JS for
|
||||
the canvas fade animation; CSS itself doesn't consume them.
|
||||
*/
|
||||
:root {
|
||||
--aimdo-bg: #181818;
|
||||
--aimdo-rowBg: #1f1f1f;
|
||||
--aimdo-headerBg: #202020;
|
||||
--aimdo-border: #2a2a2a;
|
||||
--aimdo-btn: #2a2a2a;
|
||||
--aimdo-btnText: #888;
|
||||
--aimdo-text: #b0b0b0;
|
||||
--aimdo-textDim: #707070;
|
||||
--aimdo-running: #b0b0b0;
|
||||
--aimdo-vram: #e67e22;
|
||||
--aimdo-torch: #2ecc71;
|
||||
--aimdo-pinned: #4a9eff;
|
||||
--aimdo-loadedRam: #0b3d66;
|
||||
--aimdo-unloaded: #3a3a3a;
|
||||
--aimdo-torchCache: #1a7a3a;
|
||||
--aimdo-python: #9b59b6;
|
||||
--aimdo-other: #505050;
|
||||
--aimdo-graphBg: #0e0e0e;
|
||||
--aimdo-gridLine: #1e1e1e;
|
||||
--aimdo-totalLine: #d0d0d0;
|
||||
--aimdo-gpuUtil: #c9a227;
|
||||
--aimdo-gpuUtilHi: #e74c3c;
|
||||
--aimdo-capLine: #555;
|
||||
--aimdo-barBg: #222;
|
||||
--aimdo-fadeOutFrom: 200, 60, 60;
|
||||
--aimdo-fadeOutTo: 58, 58, 58;
|
||||
|
||||
/* model type colors (match LiteGraph slot colors) */
|
||||
--aimdo-type-model: #B39DDB;
|
||||
--aimdo-type-vae: #FF6E6E;
|
||||
--aimdo-type-clip: #FFD500;
|
||||
--aimdo-type-clip_vision: #A8DADC;
|
||||
--aimdo-type-controlnet: #76B900;
|
||||
--aimdo-type-style_model: #C2FFAE;
|
||||
--aimdo-type-gligen: #9B59B6;
|
||||
--aimdo-type-upscale_model: #7AB1FF;
|
||||
}
|
||||
|
||||
[data-aimdo-theme="light"] {
|
||||
--aimdo-bg: #f5f5f5; --aimdo-rowBg: #ebebeb; --aimdo-headerBg: #e2e2e2;
|
||||
--aimdo-border: #a8a8a8; --aimdo-btn: #c8c8c8; --aimdo-btnText: #333;
|
||||
--aimdo-text: #1a1a1a; --aimdo-textDim: #555;
|
||||
--aimdo-graphBg: #fdfdfd; --aimdo-gridLine: #c8c8c8; --aimdo-barBg: #b8b8b8;
|
||||
--aimdo-vram: #c2410c; --aimdo-torch: #15803d; --aimdo-pinned: #1d4ed8;
|
||||
--aimdo-loadedRam: #7c9cc8;
|
||||
--aimdo-unloaded: #888; --aimdo-torchCache: #166534; --aimdo-python: #6d28d9;
|
||||
--aimdo-other: #666;
|
||||
--aimdo-totalLine: #222; --aimdo-gpuUtil: #854d0e; --aimdo-gpuUtilHi: #b91c1c;
|
||||
--aimdo-capLine: #555; --aimdo-running: #333;
|
||||
--aimdo-fadeOutFrom: 180, 60, 60; --aimdo-fadeOutTo: 136, 136, 136;
|
||||
|
||||
--aimdo-type-model: #7c3aed; --aimdo-type-vae: #dc2626; --aimdo-type-clip: #ca8a04;
|
||||
--aimdo-type-clip_vision: #0891b2; --aimdo-type-controlnet: #15803d;
|
||||
--aimdo-type-style_model: #22c55e; --aimdo-type-gligen: #9333ea; --aimdo-type-upscale_model: #1e40af;
|
||||
}
|
||||
|
||||
[data-aimdo-theme="sepia"] {
|
||||
--aimdo-bg: #f4ecd8; --aimdo-rowBg: #ebe1c9; --aimdo-headerBg: #e0d4b6;
|
||||
--aimdo-border: #b8a982; --aimdo-btn: #d6c89e; --aimdo-btnText: #6b5a3e;
|
||||
--aimdo-text: #3a2f1e; --aimdo-textDim: #7a6a4e;
|
||||
--aimdo-graphBg: #faf6e8; --aimdo-gridLine: #d8cba8; --aimdo-barBg: #c9b88a;
|
||||
--aimdo-vram: #b8732e; --aimdo-torch: #6b8e23; --aimdo-pinned: #5a7a8c;
|
||||
--aimdo-loadedRam: #3a5a6c;
|
||||
--aimdo-unloaded: #c9b88a; --aimdo-torchCache: #4a6e23; --aimdo-python: #7a4a8a;
|
||||
--aimdo-other: #8a7a5a;
|
||||
--aimdo-totalLine: #4a3a2a; --aimdo-gpuUtil: #8a6818; --aimdo-gpuUtilHi: #a83a2a;
|
||||
--aimdo-capLine: #8a7a5a; --aimdo-running: #5a4a3a;
|
||||
--aimdo-fadeOutFrom: 168, 88, 68; --aimdo-fadeOutTo: 201, 184, 138;
|
||||
|
||||
--aimdo-type-model: #7a5e8a; --aimdo-type-vae: #b03030; --aimdo-type-clip: #a87818;
|
||||
--aimdo-type-clip_vision: #5a8a8a; --aimdo-type-controlnet: #6b8e23;
|
||||
--aimdo-type-style_model: #7a9a5a; --aimdo-type-gligen: #7a4a8a; --aimdo-type-upscale_model: #5a7a9a;
|
||||
}
|
||||
|
||||
/* Pip-Boy CRT — green screen housed in metallic gray-brown chrome */
|
||||
[data-aimdo-theme="fallout"] {
|
||||
--aimdo-bg: #0a1a0a; --aimdo-rowBg: #0f2510; --aimdo-headerBg: #2a2520;
|
||||
--aimdo-border: #5a4a3a; --aimdo-btn: #3a3530; --aimdo-btnText: #c8b89a;
|
||||
--aimdo-text: #7fff7f; --aimdo-textDim: #3a8a3a;
|
||||
--aimdo-graphBg: #051005; --aimdo-gridLine: #1a4a1a; --aimdo-barBg: #0f2510;
|
||||
--aimdo-vram: #88ff66; --aimdo-torch: #ccff44; --aimdo-pinned: #44ffcc;
|
||||
--aimdo-loadedRam: #2a7a5a;
|
||||
--aimdo-unloaded: #1a4a1a; --aimdo-torchCache: #5a9a3a; --aimdo-python: #aaff88;
|
||||
--aimdo-other: #2a4a2a;
|
||||
--aimdo-totalLine: #7fff7f; --aimdo-gpuUtil: #ffcc44; --aimdo-gpuUtilHi: #ff5555;
|
||||
--aimdo-capLine: #3a8a3a; --aimdo-running: #88ff66;
|
||||
--aimdo-fadeOutFrom: 220, 80, 80; --aimdo-fadeOutTo: 26, 74, 26;
|
||||
|
||||
--aimdo-type-model: #aaff88; --aimdo-type-vae: #ff8866; --aimdo-type-clip: #ffdd44;
|
||||
--aimdo-type-clip_vision: #88ffdd; --aimdo-type-controlnet: #aaff44;
|
||||
--aimdo-type-style_model: #ccffaa; --aimdo-type-gligen: #ff88cc; --aimdo-type-upscale_model: #66ddff;
|
||||
}
|
||||
|
||||
/* pastel pink with mint/lavender accents so data colors stay distinguishable */
|
||||
[data-aimdo-theme="pink"] {
|
||||
--aimdo-bg: #fff0f5; --aimdo-rowBg: #ffeaf2; --aimdo-headerBg: #ffd6e8;
|
||||
--aimdo-border: #e8a4c4; --aimdo-btn: #ffc4d6; --aimdo-btnText: #7a3a5a;
|
||||
--aimdo-text: #4a1f3a; --aimdo-textDim: #8a4a6a;
|
||||
--aimdo-graphBg: #fff5fa; --aimdo-gridLine: #f5cadc; --aimdo-barBg: #f5c0d4;
|
||||
--aimdo-vram: #e91e63; --aimdo-torch: #10a37f; --aimdo-pinned: #a78bfa;
|
||||
--aimdo-loadedRam: #7a5ec0;
|
||||
--aimdo-unloaded: #d8b4c0; --aimdo-torchCache: #0f8a6a; --aimdo-python: #7c3aed;
|
||||
--aimdo-other: #a878a0;
|
||||
--aimdo-totalLine: #4a1f3a; --aimdo-gpuUtil: #e0a04a; --aimdo-gpuUtilHi: #d32f5e;
|
||||
--aimdo-capLine: #b67a8a; --aimdo-running: #5a2a4a;
|
||||
--aimdo-fadeOutFrom: 220, 80, 110; --aimdo-fadeOutTo: 216, 180, 192;
|
||||
|
||||
--aimdo-type-model: #e91e63; --aimdo-type-vae: #ef4444; --aimdo-type-clip: #f59e0b;
|
||||
--aimdo-type-clip_vision: #0ea5a4; --aimdo-type-controlnet: #65a30d;
|
||||
--aimdo-type-style_model: #f472b6; --aimdo-type-gligen: #a855f7; --aimdo-type-upscale_model: #3b82f6;
|
||||
}
|
||||
|
||||
/* Inherits ComfyUI's design tokens via var() so the panel adopts whatever
|
||||
palette the host is in (light/dark mode is read live through the cascade).
|
||||
--aimdo-type-* and --aimdo-fadeOut* are intentionally omitted — the
|
||||
LiteGraph slot hues and fade RGB triplets from :root carry through. */
|
||||
[data-aimdo-theme="comfy"] {
|
||||
--aimdo-bg: var(--comfy-menu-bg);
|
||||
--aimdo-rowBg: var(--comfy-menu-secondary-bg);
|
||||
--aimdo-headerBg: var(--comfy-menu-bg);
|
||||
--aimdo-border: var(--border-color);
|
||||
--aimdo-btn: var(--comfy-input-bg);
|
||||
--aimdo-btnText: var(--input-text);
|
||||
--aimdo-text: var(--input-text);
|
||||
--aimdo-textDim: var(--descrip-text);
|
||||
--aimdo-running: var(--input-text);
|
||||
--aimdo-graphBg: var(--comfy-menu-secondary-bg);
|
||||
--aimdo-gridLine: var(--border-color);
|
||||
--aimdo-barBg: var(--comfy-input-bg);
|
||||
--aimdo-totalLine: var(--fg-color);
|
||||
--aimdo-capLine: var(--border-color);
|
||||
|
||||
--aimdo-vram: var(--color-gold-500);
|
||||
--aimdo-torch: var(--color-jade-600);
|
||||
--aimdo-pinned: var(--color-azure-400);
|
||||
--aimdo-loadedRam: var(--color-cobalt-800);
|
||||
--aimdo-unloaded: var(--color-ash-800);
|
||||
--aimdo-torchCache: var(--color-jade-600);
|
||||
--aimdo-python: var(--color-magenta-700);
|
||||
--aimdo-other: var(--color-neutral-550);
|
||||
--aimdo-gpuUtil: var(--color-gold-500);
|
||||
--aimdo-gpuUtilHi: var(--color-coral-500);
|
||||
|
||||
/* Neutral fade endpoints chosen to read OK against both light and dark
|
||||
Comfy backgrounds — the dark-tuned :root defaults would streak black
|
||||
on light mode. */
|
||||
--aimdo-fadeOutFrom: 200, 100, 80;
|
||||
--aimdo-fadeOutTo: 128, 128, 128;
|
||||
}
|
||||
|
||||
/* CUSTOM THEME: Lucifer - Black and Red */
|
||||
[data-aimdo-theme="lucifer"] {
|
||||
--aimdo-bg: #000000;
|
||||
--aimdo-rowBg: #050000;
|
||||
--aimdo-headerBg: #000000;
|
||||
--aimdo-border: #FF0000;
|
||||
--aimdo-btn: #110000;
|
||||
--aimdo-btnText: #FF0000;
|
||||
--aimdo-text: #ffffff;
|
||||
--aimdo-textDim: #880000;
|
||||
--aimdo-running: #ffffff;
|
||||
--aimdo-vram: #FF0000;
|
||||
--aimdo-torch: #CC0000;
|
||||
--aimdo-pinned: #880000;
|
||||
--aimdo-loadedRam: #440000;
|
||||
--aimdo-unloaded: #222222;
|
||||
--aimdo-torchCache: #660000;
|
||||
--aimdo-python: #FF0000;
|
||||
--aimdo-other: #444444;
|
||||
--aimdo-graphBg: #000000;
|
||||
--aimdo-gridLine: #220000;
|
||||
--aimdo-totalLine: #ffffff;
|
||||
--aimdo-gpuUtil: #FF0000;
|
||||
--aimdo-gpuUtilHi: #FF5555;
|
||||
--aimdo-capLine: #FF0000;
|
||||
--aimdo-barBg: #110000;
|
||||
--aimdo-fadeOutFrom: 255, 0, 0;
|
||||
--aimdo-fadeOutTo: 0, 0, 0;
|
||||
|
||||
/* model type colors — all red to match the Obsidian Redline aesthetic */
|
||||
--aimdo-type-model: #FF0000;
|
||||
--aimdo-type-vae: #FF0000;
|
||||
--aimdo-type-clip: #FF0000;
|
||||
--aimdo-type-clip_vision: #FF0000;
|
||||
--aimdo-type-controlnet: #FF0000;
|
||||
--aimdo-type-style_model: #FF0000;
|
||||
--aimdo-type-gligen: #FF0000;
|
||||
--aimdo-type-upscale_model: #FF0000;
|
||||
}
|
||||
|
||||
/* ---------------- panel ---------------- */
|
||||
#aimdo-viz-panel {
|
||||
position: fixed;
|
||||
background: var(--aimdo-bg);
|
||||
color: var(--aimdo-text);
|
||||
border: 1px solid var(--aimdo-border);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
font-family: monospace;
|
||||
font-size: var(--aimdo-font-size, 12px);
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
width: 340px;
|
||||
max-width: 100vw;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.7);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* docked: flatten the panel into a horizontal row that fits the topbar actionbar */
|
||||
#aimdo-viz-panel.aimdo-docked {
|
||||
position: static !important;
|
||||
left: auto !important; top: auto !important; right: auto !important; bottom: auto !important;
|
||||
width: auto !important; height: auto !important;
|
||||
min-width: 0 !important; max-width: none !important; max-height: none !important;
|
||||
border: none !important; border-radius: 0 !important;
|
||||
box-shadow: none !important; background: transparent !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
zoom: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ---------------- header ---------------- */
|
||||
.aimdo-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: var(--aimdo-headerBg);
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: move;
|
||||
}
|
||||
.aimdo-title {
|
||||
font-weight: bold;
|
||||
color: var(--aimdo-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 2x3 circular-dot drag handle — matches the grip icon on ComfyUI's docked actionbar.
|
||||
Only shown when docked; floating mode lets the entire header act as the drag surface.
|
||||
The shape is an SVG mask painted by background-color: border-radius can't anti-alias
|
||||
a 2px circle reliably, and a radial-gradient ends up chunkier than what Chrome draws
|
||||
for true SVG primitives. SVG renders the same crisp dots in both browsers. */
|
||||
.aimdo-drag-handle {
|
||||
display: none;
|
||||
box-sizing: content-box;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
padding: 0 4px;
|
||||
background-color: var(--aimdo-textDim);
|
||||
-webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 10'><circle cx='1' cy='1' r='0.8'/><circle cx='5' cy='1' r='0.8'/><circle cx='1' cy='5' r='0.8'/><circle cx='5' cy='5' r='0.8'/><circle cx='1' cy='9' r='0.8'/><circle cx='5' cy='9' r='0.8'/></svg>");
|
||||
mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 10'><circle cx='1' cy='1' r='0.8'/><circle cx='5' cy='1' r='0.8'/><circle cx='1' cy='5' r='0.8'/><circle cx='5' cy='5' r='0.8'/><circle cx='1' cy='9' r='0.8'/><circle cx='5' cy='9' r='0.8'/></svg>");
|
||||
-webkit-mask-size: 6px 10px;
|
||||
mask-size: 6px 10px;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-origin: content-box;
|
||||
mask-origin: content-box;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-drag-handle {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.aimdo-drag-handle:active { cursor: grabbing; }
|
||||
.aimdo-drag-handle:hover { background-color: var(--aimdo-text); }
|
||||
.aimdo-header-right {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-header {
|
||||
padding: 2px 8px 2px 0 !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
flex-shrink: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------------- buttons ---------------- */
|
||||
.aimdo-btn {
|
||||
cursor: pointer;
|
||||
font-size: 0.833em;
|
||||
line-height: 1;
|
||||
padding: 1px 6px;
|
||||
background: var(--aimdo-btn);
|
||||
border-radius: 3px;
|
||||
color: var(--aimdo-btnText);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aimdo-btn-icon {
|
||||
cursor: pointer;
|
||||
color: var(--aimdo-btnText);
|
||||
padding: 0 4px;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-btn {
|
||||
font-size: 0.917em;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
/* play / cancel-running button — same shape as other header buttons, but the icon
|
||||
carries the state color (green play, red stop). Hover lifts the background. */
|
||||
.aimdo-exec-btn {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
min-width: 1em; /* keeps width stable across » /   */
|
||||
font-size: 0.833em;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
background: var(--aimdo-btn);
|
||||
border-radius: 3px;
|
||||
color: var(--aimdo-torch);
|
||||
white-space: nowrap;
|
||||
transition: filter 0.1s;
|
||||
}
|
||||
.aimdo-exec-btn:hover { filter: brightness(1.4); }
|
||||
.aimdo-exec-btn.is-running { color: var(--aimdo-gpuUtilHi); }
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-exec-btn {
|
||||
font-size: 1em;
|
||||
padding: 2.5px 10px;
|
||||
}
|
||||
|
||||
/* ---------------- mini-bar ---------------- */
|
||||
.aimdo-mini-bar {
|
||||
display: none;
|
||||
padding: 4px 10px 6px;
|
||||
font-size: 0.833em;
|
||||
color: var(--aimdo-textDim);
|
||||
}
|
||||
.aimdo-mini-row {
|
||||
display: flex; justify-content: space-between; gap: 6px; margin-bottom: 2px;
|
||||
}
|
||||
.aimdo-mini-track {
|
||||
background: var(--aimdo-barBg);
|
||||
border-radius: 2px;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.aimdo-mini-track-last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* GPU section: one row per metric — bar + value on the same line. The compact 8px
|
||||
font + 3px bar kicks in only when temp/power are also visible (the .is-multibar
|
||||
class); with only util showing, the row keeps the regular mini-bar styling so it
|
||||
matches RAM/VRAM/CPU above. */
|
||||
.aimdo-mini-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.mini-gpu-section.is-multibar .aimdo-mini-inline,
|
||||
.mini-disk-section.is-multibar .aimdo-mini-inline {
|
||||
line-height: 1;
|
||||
font-size: 0.667em;
|
||||
}
|
||||
.mini-gpu-section.is-multibar .aimdo-mini-inline .aimdo-mini-track,
|
||||
.mini-disk-section.is-multibar .aimdo-mini-inline .aimdo-mini-track { height: 3px; }
|
||||
.aimdo-mini-inline .aimdo-mini-track {
|
||||
flex: 1 1 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.aimdo-mini-inline > .mini-gpu-usage,
|
||||
.aimdo-mini-inline > .mini-temp-usage,
|
||||
.aimdo-mini-inline > .mini-power-usage,
|
||||
.aimdo-mini-inline > .mini-disk-read-usage,
|
||||
.aimdo-mini-inline > .mini-disk-write-usage {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
min-width: 32px;
|
||||
}
|
||||
/* push the header value to the right even when the GPU label is hidden */
|
||||
.mini-gpu-header-value {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* single-bar mode: hide the inline value next to the bar — the value lives in
|
||||
the title row instead so the layout matches RAM/VRAM/CPU above. */
|
||||
.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-gpu-usage,
|
||||
.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-temp-usage,
|
||||
.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-power-usage {
|
||||
display: none;
|
||||
}
|
||||
.aimdo-mini-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
transition: width 0.35s ease-out, background 0.35s ease-out;
|
||||
}
|
||||
.mini-disk-read-fill { background: var(--aimdo-pinned); }
|
||||
.mini-disk-write-fill { background: var(--aimdo-vram); }
|
||||
.mini-faults-fill { background: var(--aimdo-gpuUtil); }
|
||||
|
||||
/* Optional group dividers: a thin line between logical groups (memory,
|
||||
storage, compute). The .mini-group-start class marks the first section
|
||||
of each non-leading group. Sections of the same group sit flush; if every
|
||||
section in a group is hidden the divider still lands on the next visible
|
||||
group-start (acceptable, matches the leading-edge quirk elsewhere). */
|
||||
.aimdo-mini-bar.has-separators > .mini-group-start {
|
||||
border-top: 1px solid var(--aimdo-border);
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar.has-separators > .mini-group-start {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
border-left: 1px solid var(--aimdo-border);
|
||||
padding-left: 16px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* free disk space: one row per selected drive, mountpoint left, bar middle, "free" right.
|
||||
Mountpoint column is fixed-width so multiple drives align vertically. */
|
||||
.mini-diskspace-section.is-multibar .aimdo-mini-inline { line-height: 1; font-size: 0.667em; }
|
||||
.mini-diskspace-section.is-multibar .aimdo-mini-inline .aimdo-mini-track { height: 3px; }
|
||||
.mini-diskspace-drive-row > .mini-diskspace-mp {
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
min-width: 18px;
|
||||
max-width: 36px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mini-diskspace-drive-row > .mini-diskspace-usage {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
min-width: 32px;
|
||||
}
|
||||
.mini-diskspace-section.no-units .mini-diskspace-drive-row > .mini-diskspace-usage {
|
||||
min-width: 18px;
|
||||
}
|
||||
|
||||
/* docked mini-bar: horizontal, fixed-width sections */
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar {
|
||||
display: flex !important; flex-direction: row !important;
|
||||
padding: 0 !important; gap: 12px; align-items: center;
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-mini-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 0 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-mini-row > span {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar > * {
|
||||
width: var(--aimdo-dock-section-w, 110px);
|
||||
min-width: var(--aimdo-dock-section-w, 110px);
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------------- body ---------------- */
|
||||
#aimdo-viz-body {
|
||||
padding: 8px 10px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
#aimdo-viz-panel.aimdo-docked #aimdo-viz-body { display: none !important; }
|
||||
#aimdo-viz-panel.aimdo-docked.aimdo-docked-expanded #aimdo-viz-body {
|
||||
display: flex !important;
|
||||
position: fixed !important;
|
||||
z-index: 1500;
|
||||
width: 420px;
|
||||
max-height: 60vh;
|
||||
background: var(--aimdo-bg);
|
||||
color: var(--aimdo-text);
|
||||
border: 1px solid var(--aimdo-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
/* ---------------- bar segments (mini-bar + main content rows) ---------------- */
|
||||
.aimdo-seg { height: 100%; }
|
||||
.aimdo-seg-vram { background: var(--aimdo-vram); }
|
||||
.aimdo-seg-torch { background: var(--aimdo-torch); }
|
||||
.aimdo-seg-torchCache { background: var(--aimdo-torchCache); }
|
||||
.aimdo-seg-pinned { background: var(--aimdo-pinned); }
|
||||
.aimdo-seg-loadedRam { background: var(--aimdo-loadedRam); }
|
||||
.aimdo-seg-python { background: var(--aimdo-python); }
|
||||
.aimdo-seg-other { background: var(--aimdo-other); }
|
||||
.aimdo-seg-unloaded { background: var(--aimdo-unloaded); }
|
||||
|
||||
/* ---------------- model rows (scroll area) ---------------- */
|
||||
#aimdo-models { scrollbar-width: thin; scrollbar-color: var(--aimdo-btn) transparent; }
|
||||
#aimdo-models::-webkit-scrollbar { width: 8px; }
|
||||
#aimdo-models::-webkit-scrollbar-track { background: transparent; }
|
||||
#aimdo-models::-webkit-scrollbar-thumb { background: var(--aimdo-btn); border-radius: 4px; }
|
||||
#aimdo-models::-webkit-scrollbar-thumb:hover { background: var(--aimdo-textDim); }
|
||||
|
||||
.aimdo-model-row {
|
||||
margin-top: 6px;
|
||||
background: var(--aimdo-rowBg);
|
||||
/* transparent border reserves space so the row height stays constant when the colored stroke toggles */
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.aimdo-model-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.aimdo-model-name {
|
||||
cursor: pointer; user-select: none;
|
||||
padding: 1px 4px; margin: -1px -4px;
|
||||
border-radius: 3px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.aimdo-model-name:hover { background: var(--aimdo-btn); }
|
||||
.aimdo-model-chevron {
|
||||
color: var(--aimdo-textDim);
|
||||
margin-right: 4px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.aimdo-model-right {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.aimdo-model-bar {
|
||||
background: var(--aimdo-barBg);
|
||||
border-radius: 3px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
.aimdo-model-legend {
|
||||
display: flex; gap: 8px;
|
||||
font-size: 0.833em;
|
||||
color: var(--aimdo-textDim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.aimdo-unload-btn, .aimdo-reset-wm-btn {
|
||||
cursor: pointer;
|
||||
font-size: 0.75em;
|
||||
padding: 0px 4px;
|
||||
background: var(--aimdo-btn);
|
||||
border-radius: 2px;
|
||||
color: var(--aimdo-btnText);
|
||||
}
|
||||
|
||||
.aimdo-graph-canvas { cursor: crosshair; }
|
||||
|
||||
/* graph-height resize bar — highlights on hover and stays lit while dragging */
|
||||
.aimdo-graph-resize {
|
||||
height: 4px; cursor: ns-resize;
|
||||
flex-shrink: 0; margin: 1px 0;
|
||||
}
|
||||
.aimdo-graph-resize:hover,
|
||||
.aimdo-graph-resize.is-dragging { background: var(--aimdo-btn); }
|
||||
|
||||
/* ---------------- menus (root + submenus + unload menu) ---------------- */
|
||||
.aimdo-menu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1500;
|
||||
background: var(--aimdo-headerBg);
|
||||
color: var(--aimdo-text);
|
||||
border: 1px solid var(--aimdo-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 0;
|
||||
min-width: 120px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.7);
|
||||
font-family: monospace;
|
||||
font-size: 0.833em;
|
||||
}
|
||||
.aimdo-menu-item {
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aimdo-menu-item:hover {
|
||||
background: var(--aimdo-btn);
|
||||
}
|
||||
.aimdo-menu-item.is-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.aimdo-menu-header {
|
||||
padding: 4px 10px 2px;
|
||||
color: var(--aimdo-textDim);
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-top: 1px solid var(--aimdo-border);
|
||||
margin-top: 2px;
|
||||
cursor: default;
|
||||
}
|
||||
.aimdo-menu-item.is-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.aimdo-menu-parent {
|
||||
padding: 4px 10px; cursor: default; white-space: nowrap;
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
.aimdo-menu-parent:hover {
|
||||
background: var(--aimdo-btn);
|
||||
}
|
||||
.aimdo-chevron { color: var(--aimdo-textDim); }
|
||||
.aimdo-check {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------------- resize handles ---------------- */
|
||||
.aimdo-edge-handle { position: absolute; z-index: 1; }
|
||||
.aimdo-edge-left { top: 28px; bottom: 0; left: 0; width: 4px; cursor: ew-resize; }
|
||||
.aimdo-edge-right { top: 28px; bottom: 0; right: 0; width: 4px; cursor: ew-resize; }
|
||||
.aimdo-edge-bottom { left: 4px; right: 4px; bottom: 0; height: 4px; cursor: ns-resize; }
|
||||
.aimdo-corner-handle {
|
||||
position: absolute; right: 0; bottom: 0;
|
||||
width: 8px; height: 8px; cursor: nwse-resize; z-index: 2;
|
||||
background: linear-gradient(135deg,
|
||||
transparent 50%,
|
||||
var(--aimdo-textDim) 50%, var(--aimdo-textDim) 60%,
|
||||
transparent 60%, transparent 75%,
|
||||
var(--aimdo-textDim) 75%, var(--aimdo-textDim) 85%,
|
||||
transparent 85%);
|
||||
border-bottom-right-radius: 4px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.aimdo-corner-handle:hover { opacity: 1; }
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-edge-handle,
|
||||
#aimdo-viz-panel.aimdo-docked .aimdo-corner-handle { display: none !important; }
|
||||
|
||||
/* ---------------- drop zone (shown during a drag toward the topbar) ---------------- */
|
||||
.aimdo-dropzone {
|
||||
position: relative; z-index: 1501;
|
||||
align-self: stretch; margin: 6px;
|
||||
padding: 0 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: monospace; font-size: 0.917em;
|
||||
border: 2px dashed #3b82f6; border-radius: 6px;
|
||||
background: rgba(59,130,246,0.4); color: #fff;
|
||||
pointer-events: auto; min-width: 160px;
|
||||
opacity: 0.9;
|
||||
transition: transform 0.12s, opacity 0.12s, box-shadow 0.12s, border-width 0.12s;
|
||||
}
|
||||
.aimdo-dropzone-left { order: -2; }
|
||||
.aimdo-dropzone-right { order: 2; }
|
||||
.aimdo-dropzone.is-hover {
|
||||
opacity: 1; transform: scale(1.04);
|
||||
border-width: 3px; box-shadow: 0 0 20px rgba(59,130,246,0.45);
|
||||
}
|
||||
+3158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
// Graph subsystem: history ring buffer, persistence, drawGraph, graph submenu.
|
||||
//
|
||||
// External dependencies (palette, color utilities, persistence) are wired by
|
||||
// initGraph(deps) — graph.js doesn't import from aimdo_viz.js so the dependency
|
||||
// graph stays one-way (main → graph).
|
||||
|
||||
let C = null;
|
||||
let hexToRgb = null;
|
||||
let hexToRgba = null;
|
||||
let saveState = null;
|
||||
let _t = null;
|
||||
|
||||
export function initGraph(deps) {
|
||||
C = deps.C;
|
||||
hexToRgb = deps.hexToRgb;
|
||||
hexToRgba = deps.hexToRgba;
|
||||
saveState = deps.saveState;
|
||||
_t = deps._t || ((k) => k);
|
||||
}
|
||||
|
||||
export const GRAPH_POINTS = 120; // visible window size on the canvas
|
||||
export const HISTORY_BUFFER = 1200; // total points retained for scrollback (~20 min @ 1s)
|
||||
export const HISTORY_TICK_MS = 1000; // min interval between history snapshots
|
||||
const EXEC_EVENTS_MAX = 200;
|
||||
const EXEC_NOOP_MS = 1500; // start→end shorter than this is treated as a no-op
|
||||
const HISTORY_DB_NAME = "aimdo_viz";
|
||||
const HISTORY_DB_VERSION = 1;
|
||||
const HISTORY_STORE = "kv";
|
||||
const HISTORY_KEY = "history";
|
||||
const HISTORY_SCHEMA = 1; // bump when the stored record shape changes
|
||||
const GPU_SMOOTH_WINDOW = 3;
|
||||
|
||||
// Mutable graph state. Both main and graph mutate via property assignment so
|
||||
// `let` exports' read-only semantics don't get in the way.
|
||||
export const graphState = {
|
||||
gpuLineVisible: true,
|
||||
graphStyle: "area", // "area" | "dots" | "ticker" | "bars"
|
||||
graphVramColor: null, // null = use theme C.vram; otherwise hex override
|
||||
graphTotalColor: null, // null = use theme C.totalLine; otherwise hex override
|
||||
graphTotalSmoothness: 0, // 0 = raw, larger = wider moving-average window radius
|
||||
};
|
||||
|
||||
export const history = {
|
||||
torch_active: new Float64Array(HISTORY_BUFFER),
|
||||
aimdo_usage: new Float64Array(HISTORY_BUFFER),
|
||||
free_vram: new Float64Array(HISTORY_BUFFER),
|
||||
gpu_util: new Float64Array(HISTORY_BUFFER),
|
||||
times: new Float64Array(HISTORY_BUFFER),
|
||||
total_vram: 1,
|
||||
head: 0,
|
||||
len: 0,
|
||||
pushCount: 0, // monotonic total pushes; absolute sample id
|
||||
viewOffset: 0, // points back from newest; 0 = right edge (live)
|
||||
followLive: true,
|
||||
execEvents: [], // {type: "start"|"end", time: ms}
|
||||
};
|
||||
|
||||
// hover-line state for the graph; null when cursor is outside the data range.
|
||||
export const graphHover = { x: null, idx: null };
|
||||
|
||||
const gpuRawBuf = [];
|
||||
export function smoothGpuUtil(raw) {
|
||||
// NVML util frequently dips to 0 mid-workload; peak-hold preserves real peaks.
|
||||
if (raw == null) {
|
||||
gpuRawBuf.length = 0;
|
||||
return null;
|
||||
}
|
||||
gpuRawBuf.push(raw);
|
||||
if (gpuRawBuf.length > GPU_SMOOTH_WINDOW) gpuRawBuf.shift();
|
||||
let m = 0;
|
||||
for (const v of gpuRawBuf) if (v > m) m = v;
|
||||
return m;
|
||||
}
|
||||
|
||||
export function pushExecEvent(type) {
|
||||
if (type === "end" && history.execEvents.length > 0) {
|
||||
const last = history.execEvents[history.execEvents.length - 1];
|
||||
if (last.type === "start" && Date.now() - last.time < EXEC_NOOP_MS) {
|
||||
history.execEvents.pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
history.execEvents.push({ type, time: Date.now() });
|
||||
if (history.execEvents.length > EXEC_EVENTS_MAX) {
|
||||
history.execEvents.splice(0, history.execEvents.length - EXEC_EVENTS_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
export function pushHistory(data) {
|
||||
history.total_vram = data.total_vram;
|
||||
const i = history.head;
|
||||
// store non-overlapping values matching the bar logic
|
||||
if (data.aimdo_usage > 0) {
|
||||
history.aimdo_usage[i] = data.aimdo_usage;
|
||||
history.torch_active[i] = 0;
|
||||
} else {
|
||||
history.aimdo_usage[i] = 0;
|
||||
history.torch_active[i] = data.torch_active;
|
||||
}
|
||||
history.free_vram[i] = data.free_vram;
|
||||
history.gpu_util[i] = data.gpu_util != null ? data.gpu_util : 0;
|
||||
history.times[i] = Date.now();
|
||||
history.head = (i + 1) % HISTORY_BUFFER;
|
||||
if (history.len < HISTORY_BUFFER) history.len++;
|
||||
history.pushCount++;
|
||||
// when scrolled back, advance viewOffset so the user's pinned window keeps
|
||||
// showing the same chronological data instead of sliding with new points.
|
||||
if (!history.followLive) {
|
||||
const maxOffset = Math.max(0, history.len - GRAPH_POINTS);
|
||||
history.viewOffset = Math.min(maxOffset, history.viewOffset + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function historyGet(arr, idx) {
|
||||
// idx 0 = oldest valid, idx len-1 = newest
|
||||
return arr[(history.head - history.len + idx + HISTORY_BUFFER) % HISTORY_BUFFER];
|
||||
}
|
||||
|
||||
export function resetHistoryState() {
|
||||
history.head = 0;
|
||||
history.len = 0;
|
||||
history.pushCount = 0;
|
||||
history.viewOffset = 0;
|
||||
history.followLive = true;
|
||||
history.torch_active.fill(0);
|
||||
history.aimdo_usage.fill(0);
|
||||
history.free_vram.fill(0);
|
||||
history.gpu_util.fill(0);
|
||||
history.times.fill(0);
|
||||
history.execEvents.length = 0;
|
||||
}
|
||||
|
||||
let _dbPromise = null;
|
||||
function openHistoryDb() {
|
||||
if (_dbPromise) return _dbPromise;
|
||||
_dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(HISTORY_DB_NAME, HISTORY_DB_VERSION);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(HISTORY_STORE);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
_dbPromise.catch(() => { _dbPromise = null; }); // allow retry after open failure
|
||||
return _dbPromise;
|
||||
}
|
||||
|
||||
// cleanup the localStorage key for users upgrading from the previous version.
|
||||
try { localStorage.removeItem("aimdo_viz_history"); } catch {}
|
||||
|
||||
export async function saveHistory() {
|
||||
if (history.len === 0) return;
|
||||
try {
|
||||
const db = await openHistoryDb();
|
||||
db.transaction(HISTORY_STORE, "readwrite").objectStore(HISTORY_STORE).put({
|
||||
v: HISTORY_SCHEMA,
|
||||
head: history.head,
|
||||
len: history.len,
|
||||
buffer_size: HISTORY_BUFFER,
|
||||
total_vram: history.total_vram,
|
||||
times: history.times,
|
||||
torch_active: history.torch_active,
|
||||
aimdo_usage: history.aimdo_usage,
|
||||
free_vram: history.free_vram,
|
||||
gpu_util: history.gpu_util,
|
||||
execEvents: history.execEvents,
|
||||
}, HISTORY_KEY);
|
||||
} catch (err) {
|
||||
if (!saveHistory._warned) {
|
||||
saveHistory._warned = true;
|
||||
console.warn("aimdo-viz: history save failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearHistoryStorage() {
|
||||
try {
|
||||
const db = await openHistoryDb();
|
||||
db.transaction(HISTORY_STORE, "readwrite").objectStore(HISTORY_STORE).delete(HISTORY_KEY);
|
||||
} catch (err) { console.warn("aimdo-viz: history clear failed", err); }
|
||||
}
|
||||
|
||||
export async function loadHistory() {
|
||||
try {
|
||||
const db = await openHistoryDb();
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
const req = db.transaction(HISTORY_STORE, "readonly").objectStore(HISTORY_STORE).get(HISTORY_KEY);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
if (!data || data.v !== HISTORY_SCHEMA || typeof data.len !== "number") return;
|
||||
if (!data.times || data.times.length !== HISTORY_BUFFER) return;
|
||||
const copy = (target, source) => { if (source) target.set(source); };
|
||||
history.total_vram = data.total_vram || 1;
|
||||
copy(history.times, data.times);
|
||||
copy(history.torch_active, data.torch_active);
|
||||
copy(history.aimdo_usage, data.aimdo_usage);
|
||||
copy(history.free_vram, data.free_vram);
|
||||
copy(history.gpu_util, data.gpu_util);
|
||||
history.len = Math.min(data.len, HISTORY_BUFFER);
|
||||
history.head = (typeof data.head === "number" ? data.head : history.len) % HISTORY_BUFFER;
|
||||
history.pushCount = history.len;
|
||||
if (Array.isArray(data.execEvents)) history.execEvents = data.execEvents.slice(-EXEC_EVENTS_MAX);
|
||||
} catch (err) { console.warn("aimdo-viz: history load failed", err); }
|
||||
}
|
||||
|
||||
export function loadGraphSavedState(saved) {
|
||||
if (typeof saved.gpuLineVisible === "boolean") graphState.gpuLineVisible = saved.gpuLineVisible;
|
||||
if (typeof saved.graphStyle === "string") graphState.graphStyle = saved.graphStyle;
|
||||
if (typeof saved.graphVramColor === "string") graphState.graphVramColor = saved.graphVramColor;
|
||||
if (typeof saved.graphTotalColor === "string") graphState.graphTotalColor = saved.graphTotalColor;
|
||||
if (typeof saved.graphTotalSmoothness === "number") {
|
||||
graphState.graphTotalSmoothness = Math.max(0, Math.min(20, saved.graphTotalSmoothness | 0));
|
||||
}
|
||||
}
|
||||
|
||||
export function drawGraph(ctx, w, h) {
|
||||
const total = history.total_vram;
|
||||
const len = history.len;
|
||||
if (len < 2) return;
|
||||
|
||||
const visible = Math.min(GRAPH_POINTS, len - history.viewOffset);
|
||||
if (visible < 2) return;
|
||||
const startIdx = len - visible - history.viewOffset;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = C.gridLine;
|
||||
ctx.lineWidth = 1;
|
||||
for (const pct of [0.25, 0.5, 0.75]) {
|
||||
const y = Math.round(h - h * pct) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const stepX = w / (GRAPH_POINTS - 1);
|
||||
const yFor = val => h - (val / total) * h;
|
||||
const dataStartX = (GRAPH_POINTS - visible) * stepX;
|
||||
const xFor = i => (GRAPH_POINTS - visible + i) * stepX;
|
||||
const at = (arr, i) => historyGet(arr, startIdx + i);
|
||||
|
||||
// smoothed total-used values, precomputed once per frame and shared by the total
|
||||
// line, dots, and area-"other" layer so dots/areas stay at-or-below the line.
|
||||
const smoothN = graphState.graphTotalSmoothness;
|
||||
const totalValues = new Float64Array(visible);
|
||||
for (let i = 0; i < visible; i++) totalValues[i] = total - at(history.free_vram, i);
|
||||
let smoothedValues;
|
||||
if (smoothN > 0 && visible > 0) {
|
||||
smoothedValues = new Float64Array(visible);
|
||||
let sum = 0, count = 0;
|
||||
const initEnd = Math.min(visible - 1, smoothN);
|
||||
for (let k = 0; k <= initEnd; k++) { sum += totalValues[k]; count++; }
|
||||
smoothedValues[0] = sum / count;
|
||||
for (let i = 1; i < visible; i++) {
|
||||
const add = i + smoothN;
|
||||
const drop = i - smoothN - 1;
|
||||
if (add < visible) { sum += totalValues[add]; count++; }
|
||||
if (drop >= 0) { sum -= totalValues[drop]; count--; }
|
||||
smoothedValues[i] = sum / count;
|
||||
}
|
||||
} else {
|
||||
smoothedValues = totalValues;
|
||||
}
|
||||
const valueAt = (i) => smoothedValues[i];
|
||||
|
||||
const style = graphState.graphStyle;
|
||||
const vramHex = graphState.graphVramColor || C.vram;
|
||||
if (style === "area") {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dataStartX, h);
|
||||
for (let i = 0; i < visible; i++) {
|
||||
ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i)));
|
||||
}
|
||||
ctx.lineTo(xFor(visible - 1), h);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = hexToRgba(vramHex, 0.35);
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dataStartX, yFor(at(history.aimdo_usage, 0)));
|
||||
for (let i = 0; i < visible; i++) {
|
||||
ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i) + at(history.torch_active, i)));
|
||||
}
|
||||
for (let i = visible - 1; i >= 0; i--) {
|
||||
ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i)));
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = hexToRgba(C.torch, 0.4);
|
||||
ctx.fill();
|
||||
|
||||
const stackTop = (i) => at(history.aimdo_usage, i) + at(history.torch_active, i);
|
||||
const topFor = (i) => Math.max(stackTop(i), valueAt(i));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dataStartX, yFor(stackTop(0)));
|
||||
for (let i = 0; i < visible; i++) {
|
||||
ctx.lineTo(xFor(i), yFor(topFor(i)));
|
||||
}
|
||||
for (let i = visible - 1; i >= 0; i--) {
|
||||
ctx.lineTo(xFor(i), yFor(stackTop(i)));
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = hexToRgba(C.other, 0.4);
|
||||
ctx.fill();
|
||||
} else if (style === "bars") {
|
||||
const groupSize = 3;
|
||||
const barStep = stepX * groupSize;
|
||||
const barW = Math.max(4, barStep - 4);
|
||||
ctx.fillStyle = hexToRgba(vramHex, 0.55);
|
||||
const newestAbs = history.pushCount - 1 - history.viewOffset;
|
||||
const oldestAbs = newestAbs - visible + 1;
|
||||
const firstGroupAbs = Math.floor(oldestAbs / groupSize) * groupSize;
|
||||
const lastGroupAbs = Math.floor(newestAbs / groupSize) * groupSize;
|
||||
for (let absG = firstGroupAbs; absG <= lastGroupAbs; absG += groupSize) {
|
||||
const sStart = Math.max(absG, oldestAbs);
|
||||
const sEnd = Math.min(absG + groupSize - 1, newestAbs);
|
||||
let peak = 0;
|
||||
for (let a = sStart; a <= sEnd; a++) {
|
||||
const v = total - at(history.free_vram, a - oldestAbs);
|
||||
if (v > peak) peak = v;
|
||||
}
|
||||
const centerRel = (absG - oldestAbs) + (groupSize - 1) / 2;
|
||||
const cx = xFor(centerRel);
|
||||
const y = yFor(peak);
|
||||
ctx.fillRect(cx - barW / 2, y, barW, h - y);
|
||||
}
|
||||
} else if (style === "ticker") {
|
||||
const groupSize = 3;
|
||||
const candleStep = stepX * groupSize;
|
||||
const barW = Math.max(5, candleStep - 3);
|
||||
const up = "#10b981";
|
||||
const down = "#ef4444";
|
||||
const newestAbs = history.pushCount - 1 - history.viewOffset;
|
||||
const oldestAbs = newestAbs - visible + 1;
|
||||
const firstGroupAbs = Math.floor(oldestAbs / groupSize) * groupSize;
|
||||
const lastGroupAbs = Math.floor(newestAbs / groupSize) * groupSize;
|
||||
ctx.lineWidth = 1;
|
||||
for (let absG = firstGroupAbs; absG <= lastGroupAbs; absG += groupSize) {
|
||||
const sStart = Math.max(absG, oldestAbs);
|
||||
const sEnd = Math.min(absG + groupSize - 1, newestAbs);
|
||||
const oRel = sStart - oldestAbs;
|
||||
const cRel = sEnd - oldestAbs;
|
||||
const o = total - at(history.free_vram, oRel);
|
||||
const c = total - at(history.free_vram, cRel);
|
||||
const hiLoStartRel = Math.max(0, oRel - 1);
|
||||
const hiLoEndRel = Math.min(visible - 1, cRel + 1);
|
||||
let hi = o, lo = o;
|
||||
for (let rel = hiLoStartRel; rel <= hiLoEndRel; rel++) {
|
||||
const v = total - at(history.free_vram, rel);
|
||||
if (v > hi) hi = v;
|
||||
if (v < lo) lo = v;
|
||||
}
|
||||
const yO = yFor(o), yC = yFor(c);
|
||||
const yTop = Math.min(yO, yC);
|
||||
const bodyH = Math.max(1, Math.abs(yC - yO));
|
||||
const centerRel = (absG - oldestAbs) + (groupSize - 1) / 2;
|
||||
const cx = xFor(centerRel);
|
||||
const isUp = c >= o;
|
||||
const color = isUp ? up : down;
|
||||
const yBot = yTop + bodyH;
|
||||
ctx.strokeStyle = color;
|
||||
const wickX = Math.round(cx) + 0.5;
|
||||
const yHi = yFor(hi);
|
||||
const yLo = yFor(lo);
|
||||
if (yHi < yTop) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(wickX, yHi);
|
||||
ctx.lineTo(wickX, yTop);
|
||||
ctx.stroke();
|
||||
}
|
||||
if (yLo > yBot) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(wickX, yBot);
|
||||
ctx.lineTo(wickX, yLo);
|
||||
ctx.stroke();
|
||||
}
|
||||
const bx = Math.round(cx - barW / 2);
|
||||
if (isUp) {
|
||||
ctx.strokeRect(bx + 0.5, Math.round(yTop) + 0.5, barW - 1, bodyH);
|
||||
} else {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(bx, Math.round(yTop), barW, bodyH);
|
||||
}
|
||||
}
|
||||
} else if (style === "dots") {
|
||||
const dotR = 1;
|
||||
const dotStep = 8;
|
||||
const colStep = 2;
|
||||
const [r, g, b] = hexToRgb(vramHex);
|
||||
for (let y = 0; y < h; y += dotStep) {
|
||||
const f = 1 - y / h;
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},${(0.15 + 0.65 * f).toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < visible; i += colStep) {
|
||||
if (y < yFor(valueAt(i))) continue;
|
||||
const x = xFor(i);
|
||||
ctx.moveTo(x + dotR, y);
|
||||
ctx.arc(x, y, dotR, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// total used line — ticker already encodes the value in each candle's edge,
|
||||
// so skip the line to avoid overlapping the bars.
|
||||
if (style !== "ticker") {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = graphState.graphTotalColor || C.totalLine;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
if (smoothN > 0 && visible >= 3) {
|
||||
const xs = new Float64Array(visible);
|
||||
const ys = new Float64Array(visible);
|
||||
for (let i = 0; i < visible; i++) { xs[i] = xFor(i); ys[i] = yFor(valueAt(i)); }
|
||||
ctx.moveTo(xs[0], ys[0]);
|
||||
for (let i = 1; i < visible - 1; i++) {
|
||||
const mx = (xs[i] + xs[i + 1]) / 2;
|
||||
const my = (ys[i] + ys[i + 1]) / 2;
|
||||
ctx.quadraticCurveTo(xs[i], ys[i], mx, my);
|
||||
}
|
||||
ctx.lineTo(xs[visible - 1], ys[visible - 1]);
|
||||
} else {
|
||||
for (let i = 0; i < visible; i++) {
|
||||
const x = xFor(i);
|
||||
const y = yFor(valueAt(i));
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// capacity line
|
||||
ctx.strokeStyle = C.capLine;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, yFor(total));
|
||||
ctx.lineTo(w, yFor(total));
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// exec start/end markers — positioned by interpolating each event's time into the
|
||||
// visible sample times array, so markers stay glued to the data during scrubbing
|
||||
// even if pollInterval drifts between ticks.
|
||||
if (history.execEvents.length) {
|
||||
const sampleTimes = new Float64Array(visible);
|
||||
for (let i = 0; i < visible; i++) sampleTimes[i] = historyGet(history.times, startIdx + i);
|
||||
const tFirst = sampleTimes[0];
|
||||
const tLast = sampleTimes[visible - 1];
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2, 3]);
|
||||
for (const evt of history.execEvents) {
|
||||
if (evt.time < tFirst || evt.time > tLast) continue;
|
||||
let lo = 0, hi = visible - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (sampleTimes[mid] <= evt.time) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
const tA = sampleTimes[lo];
|
||||
const tB = lo < visible - 1 ? sampleTimes[lo + 1] : tA;
|
||||
const frac = tB > tA ? (evt.time - tA) / (tB - tA) : 0;
|
||||
const x = xFor(lo + frac);
|
||||
ctx.strokeStyle = evt.type === "start" ? C.torch : C.gpuUtilHi;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 0.5, 0);
|
||||
ctx.lineTo(x + 0.5, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// gpu line uses its own 0..100 scale, not the VRAM byte scale
|
||||
if (graphState.gpuLineVisible) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = C.gpuUtil;
|
||||
ctx.lineWidth = 1.25;
|
||||
for (let i = 0; i < visible; i++) {
|
||||
const y = h - (at(history.gpu_util, i) / 100) * h;
|
||||
if (i === 0) ctx.moveTo(xFor(i), y); else ctx.lineTo(xFor(i), y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (graphHover.x != null && graphHover.idx != null) {
|
||||
const hi = graphHover.idx - startIdx;
|
||||
if (hi >= 0 && hi < visible) {
|
||||
ctx.strokeStyle = C.vram;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(graphHover.x + 0.5, 0);
|
||||
ctx.lineTo(graphHover.x + 0.5, h);
|
||||
ctx.stroke();
|
||||
|
||||
const dot = (x, y, fill) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = C.graphBg;
|
||||
ctx.stroke();
|
||||
};
|
||||
const totalY = yFor(total - at(history.free_vram, hi));
|
||||
dot(graphHover.x, totalY, C.totalLine);
|
||||
if (graphState.gpuLineVisible) {
|
||||
const gpuY = h - (at(history.gpu_util, hi) / 100) * h;
|
||||
dot(graphHover.x, gpuY, C.gpuUtil);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the "Graph" submenu (style + colors + smoothness). The caller passes the
|
||||
// submenu container + a redraw callback that's tied to the active panel instance.
|
||||
export function buildGraphSubmenu(submenu, redrawGraph) {
|
||||
const graphStyles = [
|
||||
{ key: "area", label: _t("graphArea") },
|
||||
{ key: "bars", label: _t("graphBars") },
|
||||
{ key: "ticker", label: _t("graphTicker") },
|
||||
{ key: "dots", label: _t("graphDots") },
|
||||
];
|
||||
const items = new Map();
|
||||
function renderItems() {
|
||||
for (const [k, item] of items) {
|
||||
const on = k === graphState.graphStyle;
|
||||
const label = graphStyles.find(s => s.key === k).label;
|
||||
item.innerHTML = `<span class="aimdo-check">${on ? "✓" : ""}</span>${label}`;
|
||||
}
|
||||
}
|
||||
for (const s of graphStyles) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "aimdo-menu-item";
|
||||
item.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
graphState.graphStyle = s.key;
|
||||
saveState({ graphStyle: s.key });
|
||||
renderItems();
|
||||
redrawGraph();
|
||||
});
|
||||
submenu.appendChild(item);
|
||||
items.set(s.key, item);
|
||||
}
|
||||
renderItems();
|
||||
|
||||
// VRAM color picker
|
||||
const colorRow = document.createElement("div");
|
||||
colorRow.style.cssText = `padding:6px 10px;display:flex;align-items:center;gap:8px;font-size:0.917em;`;
|
||||
colorRow.addEventListener("click", (e) => e.stopPropagation());
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color";
|
||||
colorInput.value = graphState.graphVramColor || C.vram;
|
||||
colorInput.style.cssText = `width:24px;height:18px;padding:0;border:none;background:transparent;cursor:pointer;`;
|
||||
colorInput.addEventListener("input", () => {
|
||||
graphState.graphVramColor = colorInput.value;
|
||||
saveState({ graphVramColor: colorInput.value });
|
||||
redrawGraph();
|
||||
});
|
||||
const resetColor = document.createElement("span");
|
||||
resetColor.textContent = _t("reset");
|
||||
resetColor.style.cssText = `margin-left:auto;color:var(--aimdo-textDim);cursor:pointer;font-size:0.833em;`;
|
||||
resetColor.addEventListener("click", () => {
|
||||
graphState.graphVramColor = null;
|
||||
saveState({ graphVramColor: null });
|
||||
colorInput.value = C.vram;
|
||||
redrawGraph();
|
||||
});
|
||||
colorRow.appendChild(colorInput);
|
||||
colorRow.appendChild(document.createTextNode(_t("vramColor")));
|
||||
colorRow.appendChild(resetColor);
|
||||
submenu.appendChild(colorRow);
|
||||
|
||||
// Total-line color picker
|
||||
const totalColorRow = document.createElement("div");
|
||||
totalColorRow.style.cssText = colorRow.style.cssText;
|
||||
totalColorRow.addEventListener("click", (e) => e.stopPropagation());
|
||||
const totalColorInput = document.createElement("input");
|
||||
totalColorInput.type = "color";
|
||||
totalColorInput.value = graphState.graphTotalColor || C.totalLine;
|
||||
totalColorInput.style.cssText = colorInput.style.cssText;
|
||||
totalColorInput.addEventListener("input", () => {
|
||||
graphState.graphTotalColor = totalColorInput.value;
|
||||
saveState({ graphTotalColor: totalColorInput.value });
|
||||
redrawGraph();
|
||||
});
|
||||
const resetTotalColor = document.createElement("span");
|
||||
resetTotalColor.textContent = _t("reset");
|
||||
resetTotalColor.style.cssText = resetColor.style.cssText;
|
||||
resetTotalColor.addEventListener("click", () => {
|
||||
graphState.graphTotalColor = null;
|
||||
saveState({ graphTotalColor: null });
|
||||
totalColorInput.value = C.totalLine;
|
||||
redrawGraph();
|
||||
});
|
||||
totalColorRow.appendChild(totalColorInput);
|
||||
totalColorRow.appendChild(document.createTextNode(_t("totalColor")));
|
||||
totalColorRow.appendChild(resetTotalColor);
|
||||
submenu.appendChild(totalColorRow);
|
||||
|
||||
// Total-line smoothness slider
|
||||
const smoothRow = document.createElement("div");
|
||||
smoothRow.style.cssText = `padding:6px 10px;display:flex;flex-direction:column;gap:4px;min-width:180px;font-size:0.833em;`;
|
||||
smoothRow.addEventListener("click", (e) => e.stopPropagation());
|
||||
const smoothLabel = document.createElement("div");
|
||||
smoothLabel.style.cssText = `display:flex;justify-content:space-between;color:var(--aimdo-textDim);`;
|
||||
smoothLabel.innerHTML = `<span>${_t("smoothness")}</span><span class="aimdo-sm-val">${graphState.graphTotalSmoothness}</span>`;
|
||||
const smoothSlider = document.createElement("input");
|
||||
smoothSlider.type = "range";
|
||||
smoothSlider.min = "0";
|
||||
smoothSlider.max = "20";
|
||||
smoothSlider.step = "1";
|
||||
smoothSlider.value = String(graphState.graphTotalSmoothness);
|
||||
smoothSlider.style.cssText = `width:100%;accent-color:var(--aimdo-vram);cursor:pointer;`;
|
||||
const smoothValSpan = smoothLabel.querySelector(".aimdo-sm-val");
|
||||
smoothSlider.addEventListener("input", () => {
|
||||
graphState.graphTotalSmoothness = parseInt(smoothSlider.value, 10);
|
||||
smoothValSpan.textContent = String(graphState.graphTotalSmoothness);
|
||||
saveState({ graphTotalSmoothness: graphState.graphTotalSmoothness });
|
||||
redrawGraph();
|
||||
});
|
||||
smoothRow.appendChild(smoothLabel);
|
||||
smoothRow.appendChild(smoothSlider);
|
||||
submenu.appendChild(smoothRow);
|
||||
}
|
||||
@@ -4670,5 +4670,14 @@
|
||||
"name": "📝 系统提示词"
|
||||
}
|
||||
}
|
||||
},
|
||||
"XB_MemoryVisualization": {
|
||||
"display_name": "XB 📊 硬件监控面板 (Memory Visualization)",
|
||||
"description": "实时显存内存监控面板——显示GPU/CPU/内存/磁盘等硬件资源使用情况。将此节点添加到工作流即可在界面右下角激活可拖拽的监控面板。",
|
||||
"inputs": {
|
||||
"any_input": {
|
||||
"name": "任意输入(可选)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import ctypes
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
import torch
|
||||
import server
|
||||
from aiohttp import web
|
||||
import psutil
|
||||
import comfy.model_management
|
||||
import comfy.memory_management
|
||||
import comfy.model_base
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import comfy_aimdo.control
|
||||
except ImportError:
|
||||
comfy_aimdo = None
|
||||
|
||||
# ── AMD / ROCm GPU 监控 ──
|
||||
# 优先使用 LibreHardwareMonitor(跨厂商硬件监控库),
|
||||
# 其次尝试 rocm-smi CLI。
|
||||
# LHM 需要 pythonnet + LibreHardwareMonitorLib.dll (net8.0)
|
||||
_lhm_state = {"checked": False, "available": False, "computer": None}
|
||||
_amd_state = {"checked": False, "available": False, "gpu_name": None,
|
||||
"rocm_smi_path": None,
|
||||
"util_cache": None, "temp_cache": None,
|
||||
"power_cache": None, "cache_time": 0}
|
||||
|
||||
def _init_lhm():
|
||||
"""初始化 LibreHardwareMonitor (跨厂商硬件监控)"""
|
||||
if _lhm_state["checked"]:
|
||||
return _lhm_state["available"]
|
||||
_lhm_state["checked"] = True
|
||||
|
||||
try:
|
||||
import pythonnet
|
||||
pythonnet.load('coreclr')
|
||||
import clr
|
||||
|
||||
dll_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"LibreHardwareMonitorLib.dll")
|
||||
if not os.path.exists(dll_path):
|
||||
log.info("aimdo-viz: LibreHardwareMonitorLib.dll 未找到")
|
||||
return False
|
||||
|
||||
clr.AddReference(dll_path)
|
||||
from LibreHardwareMonitor.Hardware import Computer
|
||||
|
||||
computer = Computer()
|
||||
computer.IsGpuEnabled = True
|
||||
computer.Open()
|
||||
|
||||
_lhm_state["computer"] = computer
|
||||
_lhm_state["available"] = True
|
||||
log.info("aimdo-viz: LibreHardwareMonitor 初始化成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: LHM 初始化失败: %s", e)
|
||||
_lhm_state["available"] = False
|
||||
return False
|
||||
|
||||
def _query_lhm():
|
||||
"""通过 LibreHardwareMonitor 读取 AMD GPU 利用率/温度/功耗"""
|
||||
computer = _lhm_state.get("computer")
|
||||
if computer is None:
|
||||
return None, None, None
|
||||
|
||||
try:
|
||||
util = None
|
||||
temp = None
|
||||
power = None
|
||||
|
||||
for hw in computer.Hardware:
|
||||
hw.Update()
|
||||
hw_type = str(hw.HardwareType)
|
||||
|
||||
# 只处理 AMD 独立显卡,跳过集成显卡
|
||||
if "GpuAmd" not in hw_type:
|
||||
continue
|
||||
|
||||
hw_name = str(hw.Name).lower()
|
||||
# 如果是集成显卡(通常包含 "graphics" 不含具体型号标记),跳过
|
||||
# RX 7900 XTX 等独显名称包含 "rx" 或 "radeon rx"
|
||||
is_dgpu = any(kw in hw_name for kw in ["rx", "7900", "7800", "7700", "7600",
|
||||
"6900", "6800", "6700", "6600",
|
||||
"5700", "5600", "5500", "vega",
|
||||
"radeon vii", "fury"])
|
||||
|
||||
for sensor in hw.Sensors:
|
||||
s_type = str(sensor.SensorType)
|
||||
s_name = str(sensor.Name).lower()
|
||||
s_val = sensor.Value
|
||||
if s_val is None:
|
||||
continue
|
||||
|
||||
if s_type == "Load" and s_name == "gpu core" and is_dgpu:
|
||||
util = s_val
|
||||
elif s_type == "Temperature" and s_name == "gpu core" and is_dgpu:
|
||||
temp = s_val
|
||||
elif s_type == "Power" and s_name == "gpu package" and is_dgpu:
|
||||
power = s_val # 瓦特
|
||||
|
||||
# 如果独显没找到数据,回退到任意 AMD GPU
|
||||
if util is None and not is_dgpu:
|
||||
for sensor in hw.Sensors:
|
||||
s_type = str(sensor.SensorType)
|
||||
s_name = str(sensor.Name).lower()
|
||||
s_val = sensor.Value
|
||||
if s_val is None:
|
||||
continue
|
||||
if s_type == "Load" and s_name == "gpu core" and util is None:
|
||||
util = s_val
|
||||
elif s_type == "Temperature" and s_name == "gpu core" and temp is None:
|
||||
temp = s_val
|
||||
elif s_type == "Power" and s_name == "gpu package" and power is None:
|
||||
power = s_val
|
||||
|
||||
# power 是瓦特,转换为毫瓦以匹配 NVIDIA API
|
||||
if power is not None:
|
||||
power = power * 1000
|
||||
|
||||
return util, temp, power
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: LHM 查询失败: %s", e)
|
||||
return None, None, None
|
||||
|
||||
def _detect_amd_gpu():
|
||||
"""检测是否为 AMD GPU 并初始化监控后端"""
|
||||
if _amd_state["checked"]:
|
||||
return _amd_state["available"]
|
||||
_amd_state["checked"] = True
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
return False
|
||||
|
||||
device = comfy.model_management.get_torch_device()
|
||||
gpu_name = torch.cuda.get_device_name(device)
|
||||
|
||||
is_amd = any(kw in gpu_name.lower() for kw in
|
||||
["amd", "radeon", "instinct", "mi", "rx", "vega", "navi",
|
||||
"gfx", "radeon rx", "radeon pro", "firepro"])
|
||||
if not is_amd:
|
||||
return False
|
||||
|
||||
log.info("aimdo-viz: 检测到 AMD GPU: %s", gpu_name)
|
||||
_amd_state["gpu_name"] = gpu_name
|
||||
|
||||
# 方案 1: LibreHardwareMonitor (推荐,支持 RDNA 3)
|
||||
if _init_lhm():
|
||||
_amd_state["available"] = True
|
||||
log.info("aimdo-viz: 使用 LibreHardwareMonitor 监控 AMD GPU")
|
||||
return True
|
||||
|
||||
# 方案 2: rocm-smi CLI (Linux / 未来 Windows)
|
||||
import shutil
|
||||
candidates = []
|
||||
if platform.system() == "Windows":
|
||||
candidates = [
|
||||
r"C:\Program Files\AMD\ROCm\7.1\bin\rocm-smi.exe",
|
||||
r"C:\Program Files\AMD\ROCm\6.2\bin\rocm-smi.exe",
|
||||
"rocm-smi.exe",
|
||||
]
|
||||
else:
|
||||
candidates = ["rocm-smi", "/opt/rocm/bin/rocm-smi"]
|
||||
|
||||
for path in candidates:
|
||||
if "*" not in path and shutil.which(path):
|
||||
_amd_state["rocm_smi_path"] = path
|
||||
log.info("aimdo-viz: 使用 rocm-smi: %s", path)
|
||||
_amd_state["available"] = True
|
||||
return True
|
||||
|
||||
log.info("aimdo-viz: AMD GPU 监控不可用,仅显示 VRAM")
|
||||
_amd_state["available"] = True
|
||||
return True
|
||||
|
||||
def _query_amd_gpu():
|
||||
"""查询 AMD GPU 状态,返回 (util, temp_c, power_mw)"""
|
||||
# LHM 优先
|
||||
if _lhm_state.get("available"):
|
||||
now = time.time()
|
||||
if now - _amd_state["cache_time"] < 2:
|
||||
return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"]
|
||||
_amd_state["cache_time"] = now
|
||||
u, t, p = _query_lhm()
|
||||
_amd_state["util_cache"] = u
|
||||
_amd_state["temp_cache"] = t
|
||||
_amd_state["power_cache"] = p
|
||||
return u, t, p
|
||||
|
||||
# rocm-smi 回退
|
||||
if _amd_state.get("rocm_smi_path"):
|
||||
now = time.time()
|
||||
if now - _amd_state["cache_time"] < 2:
|
||||
return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"]
|
||||
_amd_state["cache_time"] = now
|
||||
|
||||
path = _amd_state["rocm_smi_path"]
|
||||
import json as _json
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[path, "--showuse", "--showtemp", "--showpower", "--json"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
data = _json.loads(proc.stdout)
|
||||
for card_key in data:
|
||||
card = data[card_key]
|
||||
try:
|
||||
_amd_state["util_cache"] = int(float(card.get("GPU use (%)", "0")))
|
||||
except (ValueError, TypeError):
|
||||
_amd_state["util_cache"] = None
|
||||
try:
|
||||
_amd_state["temp_cache"] = float(card.get("Temperature (C)", "0"))
|
||||
except (ValueError, TypeError):
|
||||
_amd_state["temp_cache"] = None
|
||||
try:
|
||||
_amd_state["power_cache"] = float(card.get("Average Graphics Package Power (W)", 0)) * 1000
|
||||
except (ValueError, TypeError):
|
||||
_amd_state["power_cache"] = None
|
||||
break
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: rocm-smi 错误: %s", e)
|
||||
|
||||
return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"]
|
||||
|
||||
# NVML handle + power-cap cache. Cap and device name are static for a given
|
||||
# driver state, so we only query them once. Handle init is best-effort; failures stick.
|
||||
_nvml_state = {"handle": None, "tried": False, "power_limit": None, "gpu_name": None}
|
||||
|
||||
def _resolve_nvml_handle(pynvml, device):
|
||||
# NVML enumerates physical GPUs and ignores CUDA_VISIBLE_DEVICES, so the torch
|
||||
# device index can point at the wrong card on multi-GPU systems. Match by UUID.
|
||||
idx = device.index if device.index is not None else torch.cuda.current_device()
|
||||
try:
|
||||
torch_uuid = "GPU-" + str(torch.cuda.get_device_properties(idx).uuid)
|
||||
for i in range(pynvml.nvmlDeviceGetCount()):
|
||||
h = pynvml.nvmlDeviceGetHandleByIndex(i)
|
||||
uuid = pynvml.nvmlDeviceGetUUID(h)
|
||||
if (uuid.decode() if isinstance(uuid, bytes) else uuid) == torch_uuid:
|
||||
return h
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: nvml uuid match failed: %s", e)
|
||||
return pynvml.nvmlDeviceGetHandleByIndex(idx)
|
||||
|
||||
def _nvml_handle(device):
|
||||
if _nvml_state["tried"]:
|
||||
return _nvml_state["handle"]
|
||||
_nvml_state["tried"] = True
|
||||
try:
|
||||
import pynvml
|
||||
pynvml.nvmlInit()
|
||||
_nvml_state["handle"] = _resolve_nvml_handle(pynvml, device)
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: pynvml init failed: %s", e)
|
||||
return _nvml_state["handle"]
|
||||
|
||||
def _nvml_mem_info(device):
|
||||
# On Windows WDDM, cudaMemGetInfo reports per-process memory, hiding other
|
||||
# processes' VRAM. NVML is always device-wide.
|
||||
h = _nvml_handle(device)
|
||||
if h is None:
|
||||
return None
|
||||
try:
|
||||
import pynvml
|
||||
info = pynvml.nvmlDeviceGetMemoryInfo(h)
|
||||
return info.free, info.total
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: nvmlDeviceGetMemoryInfo failed: %s", e)
|
||||
return None
|
||||
|
||||
def _nvml_power_limit(device):
|
||||
if _nvml_state["power_limit"] is not None:
|
||||
return _nvml_state["power_limit"]
|
||||
h = _nvml_handle(device)
|
||||
if h is None:
|
||||
return None
|
||||
try:
|
||||
import pynvml
|
||||
_nvml_state["power_limit"] = pynvml.nvmlDeviceGetPowerManagementLimit(h)
|
||||
return _nvml_state["power_limit"]
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: nvmlDeviceGetPowerManagementLimit failed: %s", e)
|
||||
return None
|
||||
|
||||
def _get_lock():
|
||||
# Stored on comfy.model_management so the same lock survives hot reloads.
|
||||
mm = comfy.model_management
|
||||
if not hasattr(mm, '_viz_model_lock'):
|
||||
mm._viz_model_lock = asyncio.Lock()
|
||||
return mm._viz_model_lock
|
||||
|
||||
# cached; CPU model doesn't change at runtime.
|
||||
_cpu_name_cache = {"tried": False, "name": None}
|
||||
|
||||
def _get_cpu_name():
|
||||
if _cpu_name_cache["tried"]:
|
||||
return _cpu_name_cache["name"]
|
||||
_cpu_name_cache["tried"] = True
|
||||
try:
|
||||
sys_name = platform.system()
|
||||
if sys_name == "Windows":
|
||||
import winreg
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as key:
|
||||
name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
|
||||
_cpu_name_cache["name"] = name.strip()
|
||||
elif sys_name == "Darwin":
|
||||
_cpu_name_cache["name"] = subprocess.check_output(
|
||||
["sysctl", "-n", "machdep.cpu.brand_string"], timeout=2
|
||||
).decode().strip()
|
||||
elif sys_name == "Linux":
|
||||
with open("/proc/cpuinfo") as f:
|
||||
for line in f:
|
||||
if "model name" in line:
|
||||
_cpu_name_cache["name"] = line.split(":", 1)[1].strip()
|
||||
break
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: cpu name lookup failed: %s", e)
|
||||
return _cpu_name_cache["name"]
|
||||
|
||||
# Fixed drives + volume labels are static for the session — enumerate once and cache.
|
||||
# Sleepy/external drives could make GetVolumeInformationW block, so do it lazily off
|
||||
# the polling path (first time a client asks for the list).
|
||||
_disks_cache = {"tried": False, "list": []}
|
||||
|
||||
def _get_volume_label(mountpoint):
|
||||
try:
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
buf = ctypes.create_unicode_buffer(256)
|
||||
fs = ctypes.create_unicode_buffer(256)
|
||||
ok = ctypes.windll.kernel32.GetVolumeInformationW(
|
||||
ctypes.c_wchar_p(mountpoint), buf, 256, None, None, None, fs, 256)
|
||||
if ok:
|
||||
return buf.value or None
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: GetVolumeInformationW failed for %s: %s", mountpoint, e)
|
||||
return None
|
||||
|
||||
def _get_disk_partitions():
|
||||
if _disks_cache["tried"]:
|
||||
return _disks_cache["list"]
|
||||
_disks_cache["tried"] = True
|
||||
try:
|
||||
# all=False filters CD-ROM, removable, and network on Windows
|
||||
parts = []
|
||||
for p in psutil.disk_partitions(all=False):
|
||||
parts.append({"mountpoint": p.mountpoint, "label": _get_volume_label(p.mountpoint)})
|
||||
_disks_cache["list"] = parts
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: disk_partitions failed: %s", e)
|
||||
return _disks_cache["list"]
|
||||
|
||||
|
||||
# Windows reads true pagefile.sys usage via NtQuerySystemInformation
|
||||
# falls back to psutil on non-Windows or on any failure.
|
||||
_pagefile_state = {"tried": False, "query": None}
|
||||
|
||||
def _build_win_pagefile_query():
|
||||
from ctypes import wintypes
|
||||
|
||||
class UNICODE_STRING(ctypes.Structure):
|
||||
_fields_ = [("Length", wintypes.USHORT),
|
||||
("MaximumLength", wintypes.USHORT),
|
||||
("Buffer", wintypes.LPWSTR)]
|
||||
|
||||
class SYSTEM_PAGEFILE_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [("NextEntryOffset", wintypes.ULONG),
|
||||
("TotalSize", wintypes.ULONG),
|
||||
("TotalInUse", wintypes.ULONG),
|
||||
("PeakUsage", wintypes.ULONG),
|
||||
("PageFileName", UNICODE_STRING)]
|
||||
|
||||
class SYSTEM_INFO(ctypes.Structure):
|
||||
_fields_ = [("wProcessorArchitecture", wintypes.WORD),
|
||||
("wReserved", wintypes.WORD),
|
||||
("dwPageSize", wintypes.DWORD),
|
||||
("lpMinimumApplicationAddress", ctypes.c_void_p),
|
||||
("lpMaximumApplicationAddress", ctypes.c_void_p),
|
||||
("dwActiveProcessorMask", ctypes.POINTER(wintypes.DWORD)),
|
||||
("dwNumberOfProcessors", wintypes.DWORD),
|
||||
("dwProcessorType", wintypes.DWORD),
|
||||
("dwAllocationGranularity", wintypes.DWORD),
|
||||
("wProcessorLevel", wintypes.WORD),
|
||||
("wProcessorRevision", wintypes.WORD)]
|
||||
|
||||
SystemPageFileInformation = 18
|
||||
ntdll = ctypes.WinDLL("ntdll")
|
||||
ntdll.NtQuerySystemInformation.restype = wintypes.LONG
|
||||
si = SYSTEM_INFO()
|
||||
ctypes.windll.kernel32.GetSystemInfo(ctypes.byref(si))
|
||||
page = si.dwPageSize
|
||||
buf = ctypes.create_string_buffer(8192)
|
||||
ret = wintypes.ULONG(0)
|
||||
|
||||
def query():
|
||||
status = ntdll.NtQuerySystemInformation(SystemPageFileInformation, buf, len(buf), ctypes.byref(ret))
|
||||
if status < 0 or ret.value == 0:
|
||||
return 0, 0
|
||||
total = used = 0
|
||||
off = 0
|
||||
while True:
|
||||
info = SYSTEM_PAGEFILE_INFORMATION.from_buffer(buf, off)
|
||||
total += info.TotalSize
|
||||
used += info.TotalInUse
|
||||
if info.NextEntryOffset == 0:
|
||||
break
|
||||
off += info.NextEntryOffset
|
||||
return total * page, used * page
|
||||
|
||||
return query
|
||||
|
||||
def _pagefile_usage():
|
||||
"""(total_bytes, used_bytes) of system pagefiles — true on-disk usage."""
|
||||
if platform.system() == "Windows":
|
||||
st = _pagefile_state
|
||||
if not st["tried"]:
|
||||
st["tried"] = True
|
||||
try:
|
||||
st["query"] = _build_win_pagefile_query()
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: pagefile query init failed: %s", e)
|
||||
if st["query"] is not None:
|
||||
try:
|
||||
return st["query"]()
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: pagefile query failed: %s", e)
|
||||
try:
|
||||
sw = psutil.swap_memory()
|
||||
return sw.total, sw.used
|
||||
except Exception:
|
||||
return 0, 0
|
||||
|
||||
|
||||
# Hard page faults — faults that hit disk, i.e. memory paged back in. A rising
|
||||
# rate is the thrashing signal. Windows reads SYSTEM_PERFORMANCE_INFORMATION;
|
||||
# Linux reads /proc/vmstat pgmajfault. Returns a monotonic count; the client
|
||||
# differentiates it into faults/sec.
|
||||
_hardfault_state = {"tried": False, "query": None}
|
||||
|
||||
def _build_win_hardfault_query():
|
||||
from ctypes import wintypes
|
||||
|
||||
class PERF_PREFIX(ctypes.Structure):
|
||||
_fields_ = [("IdleProcessTime", ctypes.c_longlong),
|
||||
("IoReadTransferCount", ctypes.c_longlong),
|
||||
("IoWriteTransferCount", ctypes.c_longlong),
|
||||
("IoOtherTransferCount", ctypes.c_longlong),
|
||||
("IoReadOperationCount", wintypes.ULONG),
|
||||
("IoWriteOperationCount", wintypes.ULONG),
|
||||
("IoOtherOperationCount", wintypes.ULONG),
|
||||
("AvailablePages", wintypes.ULONG),
|
||||
("CommittedPages", wintypes.ULONG),
|
||||
("CommitLimit", wintypes.ULONG),
|
||||
("PeakCommitment", wintypes.ULONG),
|
||||
("PageFaultCount", wintypes.ULONG),
|
||||
("CopyOnWriteCount", wintypes.ULONG),
|
||||
("TransitionCount", wintypes.ULONG),
|
||||
("CacheTransitionCount", wintypes.ULONG),
|
||||
("DemandZeroCount", wintypes.ULONG),
|
||||
("PageReadCount", wintypes.ULONG),
|
||||
("PageReadIoCount", wintypes.ULONG)]
|
||||
|
||||
SystemPerformanceInformation = 2
|
||||
ntdll = ctypes.WinDLL("ntdll")
|
||||
ntdll.NtQuerySystemInformation.restype = wintypes.LONG
|
||||
buf = ctypes.create_string_buffer(8192)
|
||||
ret = wintypes.ULONG(0)
|
||||
|
||||
def query():
|
||||
status = ntdll.NtQuerySystemInformation(SystemPerformanceInformation, buf, len(buf), ctypes.byref(ret))
|
||||
if status < 0 or ret.value < ctypes.sizeof(PERF_PREFIX):
|
||||
return None
|
||||
return PERF_PREFIX.from_buffer(buf).PageReadIoCount
|
||||
|
||||
return query
|
||||
|
||||
def _hard_fault_count():
|
||||
"""Monotonic count of hard/major page faults, or None if unavailable."""
|
||||
sysname = platform.system()
|
||||
if sysname == "Windows":
|
||||
st = _hardfault_state
|
||||
if not st["tried"]:
|
||||
st["tried"] = True
|
||||
try:
|
||||
st["query"] = _build_win_hardfault_query()
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: hardfault query init failed: %s", e)
|
||||
if st["query"] is not None:
|
||||
try:
|
||||
return st["query"]()
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: hardfault query failed: %s", e)
|
||||
return None
|
||||
if sysname == "Linux":
|
||||
try:
|
||||
with open("/proc/vmstat") as f:
|
||||
for line in f:
|
||||
if line.startswith("pgmajfault "):
|
||||
return int(line.split()[1])
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: /proc/vmstat read failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _vbar_residency(vbar, used_pages):
|
||||
"""Per-page residency flags for the used range only. vbar.get_residency()
|
||||
builds a Python list over the whole ~10x-overallocated VBAR every poll
|
||||
(hundreds of us for large models); we run the same native fill but convert
|
||||
only the used slice. Falls back to the full read on any binding mismatch."""
|
||||
try:
|
||||
lib = comfy_aimdo.control.lib
|
||||
nr = vbar.get_nr_pages()
|
||||
buf = (ctypes.c_uint8 * nr)()
|
||||
lib.vbar_get_residency(vbar._devctx, vbar._ptr, buf, nr)
|
||||
return buf[:max(0, min(used_pages, nr))]
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: bounded residency read failed, using full: %s", e)
|
||||
try:
|
||||
return vbar.get_residency()[:used_pages]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _detect_model_type(model_obj):
|
||||
"""Classify a loaded model into a ComfyUI slot type so the UI can color it
|
||||
consistently with node connection colors. Returns None when nothing matches
|
||||
so the UI falls back to the default text color rather than mislabeling."""
|
||||
try:
|
||||
if isinstance(model_obj, comfy.model_base.BaseModel):
|
||||
return "model"
|
||||
except Exception:
|
||||
pass
|
||||
cls = model_obj.__class__
|
||||
name = cls.__name__.lower()
|
||||
module = (cls.__module__ or "").lower()
|
||||
# order matters: clip_vision before clip, style_model before model substring matches.
|
||||
if "clipvision" in name or "clip_vision" in name or "clip_vision" in module:
|
||||
return "clip_vision"
|
||||
if "controlnet" in name or "t2iadapter" in name or "controlnet" in module:
|
||||
return "controlnet"
|
||||
if "stylemodel" in name or "style_model" in name:
|
||||
return "style_model"
|
||||
if "gligen" in name:
|
||||
return "gligen"
|
||||
if "vae" in name or "autoencod" in name or module.endswith(".vae"):
|
||||
return "vae"
|
||||
if "clip" in name or "t5" in name or "textencoder" in name or "text_encoders" in module:
|
||||
return "clip"
|
||||
if "esrgan" in name or "upscal" in name or "rrdb" in name or "spandrel" in module:
|
||||
return "upscale_model"
|
||||
return None
|
||||
routes = server.PromptServer.instance.routes
|
||||
|
||||
@routes.get("/aimdo/vram")
|
||||
async def aimdo_vram_status(request):
|
||||
device = comfy.model_management.get_torch_device()
|
||||
if not torch.cuda.is_available() or device.type != "cuda":
|
||||
return web.json_response({"enabled": False})
|
||||
|
||||
aimdo_active = getattr(comfy.memory_management, 'aimdo_enabled', False) and comfy_aimdo is not None
|
||||
|
||||
models = []
|
||||
loaded_models = list(comfy.model_management.current_loaded_models)
|
||||
for model_idx, lm in enumerate(loaded_models):
|
||||
patcher = lm.model
|
||||
if patcher is None:
|
||||
continue
|
||||
|
||||
model_obj = patcher.model
|
||||
if model_obj is None:
|
||||
continue
|
||||
|
||||
name = model_obj.__class__.__name__
|
||||
is_dynamic = patcher.is_dynamic()
|
||||
total_size = patcher.model_size()
|
||||
loaded = patcher.loaded_size()
|
||||
|
||||
# RAM side: pinned host memory used for fast transfers, plus
|
||||
# non-pinned loaded host memory when the patcher exposes it.
|
||||
pinned_ram = 0
|
||||
try:
|
||||
if hasattr(patcher, 'pinned_memory_size'):
|
||||
pinned_ram = patcher.pinned_memory_size()
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: pinned_memory_size failed: %s", e)
|
||||
loaded_ram = 0
|
||||
try:
|
||||
if hasattr(patcher, 'loaded_ram_size'):
|
||||
loaded_ram = max(0, patcher.loaded_ram_size() - pinned_ram)
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: loaded_ram_size failed: %s", e)
|
||||
|
||||
# VBAR state per device (aimdo only)
|
||||
vbars = []
|
||||
vbar_loaded_total = 0
|
||||
|
||||
if aimdo_active and is_dynamic and hasattr(model_obj, "dynamic_vbars"):
|
||||
for dev, vbar in model_obj.dynamic_vbars.items():
|
||||
try:
|
||||
loaded_bytes = vbar.loaded_size()
|
||||
vbar_loaded_total += loaded_bytes
|
||||
page_size = getattr(vbar, 'page_size', 32 * 1024 * 1024)
|
||||
vbar_offset = getattr(vbar, 'offset', 0)
|
||||
if vbar_offset > 0:
|
||||
used_pages = (vbar_offset + page_size - 1) // page_size
|
||||
else:
|
||||
used_pages = (total_size + page_size - 1) // page_size
|
||||
vbars.append({
|
||||
"device": str(dev),
|
||||
"loaded": loaded_bytes,
|
||||
"watermark": vbar.get_watermark(),
|
||||
"residency": _vbar_residency(vbar, used_pages),
|
||||
})
|
||||
except Exception as e:
|
||||
log.warning("aimdo-viz: VBAR query failed: %s", e)
|
||||
|
||||
entry = {
|
||||
"index": model_idx,
|
||||
"name": name,
|
||||
"type": _detect_model_type(model_obj),
|
||||
"total_size": total_size,
|
||||
"loaded_size": loaded,
|
||||
"vbar_loaded": vbar_loaded_total,
|
||||
"ram_size": max(0, total_size - vbar_loaded_total),
|
||||
"pinned_ram": pinned_ram,
|
||||
"loaded_ram": loaded_ram,
|
||||
"dynamic": is_dynamic,
|
||||
"vbars": vbars,
|
||||
}
|
||||
|
||||
models.append(entry)
|
||||
|
||||
has_dynamic = any(m.get("dynamic") for m in models)
|
||||
aimdo_usage = comfy_aimdo.control.get_total_vram_usage() if aimdo_active and has_dynamic else 0
|
||||
|
||||
# prefer NVML (device-wide on every driver model) and fall back to cudaMemGetInfo
|
||||
# — the latter under-reports on Windows WDDM by hiding other processes' VRAM.
|
||||
_mem = _nvml_mem_info(device)
|
||||
if _mem is not None:
|
||||
free_cuda, total_vram = _mem
|
||||
else:
|
||||
free_cuda, total_vram = torch.cuda.mem_get_info(device)
|
||||
|
||||
try:
|
||||
gpu_util = torch.cuda.utilization(device)
|
||||
except Exception:
|
||||
gpu_util = None
|
||||
|
||||
try:
|
||||
gpu_temp = torch.cuda.temperature(device)
|
||||
except Exception:
|
||||
gpu_temp = None
|
||||
|
||||
try:
|
||||
gpu_power = torch.cuda.power_draw(device) # mW
|
||||
except Exception:
|
||||
gpu_power = None
|
||||
gpu_power_limit = _nvml_power_limit(device) # mW
|
||||
|
||||
# ── AMD/ROCm 回退方案 ──
|
||||
# 当 NVIDIA 专有 API 返回 None 时(AMD 显卡),尝试 rocm-smi
|
||||
if _detect_amd_gpu() and (gpu_util is None or gpu_temp is None):
|
||||
amd_util, amd_temp, amd_power = _query_amd_gpu()
|
||||
if gpu_util is None and amd_util is not None:
|
||||
gpu_util = amd_util
|
||||
if gpu_temp is None and amd_temp is not None:
|
||||
gpu_temp = amd_temp
|
||||
if gpu_power is None and amd_power is not None:
|
||||
gpu_power = amd_power
|
||||
|
||||
gpu_name = _nvml_state["gpu_name"]
|
||||
if gpu_name is None:
|
||||
h = _nvml_handle(device)
|
||||
if h is not None:
|
||||
try:
|
||||
import pynvml
|
||||
name = pynvml.nvmlDeviceGetName(h)
|
||||
gpu_name = _nvml_state["gpu_name"] = name.decode() if isinstance(name, bytes) else name
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: nvmlDeviceGetName failed: %s", e)
|
||||
if gpu_name is None:
|
||||
try:
|
||||
gpu_name = _nvml_state["gpu_name"] = torch.cuda.get_device_name(device)
|
||||
except Exception:
|
||||
pass
|
||||
# AMD 回退:torch.cuda.get_device_name 在 ROCm 下可用
|
||||
if gpu_name is None and _amd_state.get("gpu_name"):
|
||||
gpu_name = _amd_state["gpu_name"]
|
||||
|
||||
# non-blocking; first call after process start returns 0, subsequent calls are real
|
||||
try:
|
||||
cpu_util = psutil.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
cpu_util = None
|
||||
|
||||
# pytorch internal stats
|
||||
stats = torch.cuda.memory_stats(device)
|
||||
torch_active = stats.get('active_bytes.all.current', 0)
|
||||
torch_reserved = stats.get('reserved_bytes.all.current', 0)
|
||||
|
||||
ram = psutil.virtual_memory()
|
||||
proc = psutil.Process()
|
||||
|
||||
process_ram = proc.memory_info().rss
|
||||
swap_total = swap_used = 0
|
||||
if request.query.get("pagefile") == "1":
|
||||
swap_total, swap_used = _pagefile_usage()
|
||||
|
||||
hard_faults = None
|
||||
if request.query.get("faults") == "1":
|
||||
hard_faults = _hard_fault_count()
|
||||
|
||||
disk_read = disk_write = None
|
||||
if request.query.get("disk") == "1":
|
||||
try:
|
||||
d = psutil.disk_io_counters()
|
||||
if d is not None:
|
||||
disk_read, disk_write = d.read_bytes, d.write_bytes
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# client opts in by sending list_disks=1; backend skips entirely when absent.
|
||||
disks_list = None
|
||||
if request.query.get("list_disks") == "1":
|
||||
disks_list = []
|
||||
for p in _get_disk_partitions():
|
||||
entry = {"mountpoint": p["mountpoint"], "label": p["label"], "total": None, "free": None}
|
||||
try:
|
||||
du = psutil.disk_usage(p["mountpoint"])
|
||||
entry["total"] = du.total
|
||||
entry["free"] = du.free
|
||||
except Exception as e:
|
||||
log.debug("aimdo-viz: disk_usage(%s) failed: %s", p["mountpoint"], e)
|
||||
disks_list.append(entry)
|
||||
|
||||
total_pinned = sum(m.get("pinned_ram", 0) for m in models)
|
||||
total_loaded_ram = sum(m.get("loaded_ram", 0) for m in models)
|
||||
|
||||
return web.json_response({
|
||||
"enabled": True,
|
||||
"aimdo_active": aimdo_active,
|
||||
"total_vram": total_vram,
|
||||
"free_vram": free_cuda,
|
||||
"gpu_util": gpu_util,
|
||||
"gpu_temp": gpu_temp,
|
||||
"gpu_power": gpu_power,
|
||||
"gpu_power_limit": gpu_power_limit,
|
||||
"gpu_name": gpu_name,
|
||||
"cpu_util": cpu_util,
|
||||
"cpu_name": _get_cpu_name(),
|
||||
"aimdo_usage": aimdo_usage,
|
||||
"torch_active": torch_active,
|
||||
"torch_reserved": torch_reserved,
|
||||
"total_ram": ram.total,
|
||||
"used_ram": ram.used,
|
||||
"total_swap": swap_total,
|
||||
"used_swap": swap_used,
|
||||
"hard_faults": hard_faults,
|
||||
"disk_read": disk_read,
|
||||
"disk_write": disk_write,
|
||||
"disks": disks_list,
|
||||
"process_ram": process_ram,
|
||||
"pinned_ram": total_pinned,
|
||||
"loaded_ram": total_loaded_ram,
|
||||
"models": models,
|
||||
})
|
||||
|
||||
@routes.post("/aimdo/unload_all")
|
||||
async def aimdo_unload_all(request):
|
||||
if _is_executing():
|
||||
return web.json_response({"error": "cannot unload during execution"}, status=409)
|
||||
async with _get_lock():
|
||||
await asyncio.get_running_loop().run_in_executor(None, comfy.model_management.unload_all_models)
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
def _is_executing():
|
||||
return bool(server.PromptServer.instance.prompt_queue.currently_running)
|
||||
|
||||
def _get_model_idx(data):
|
||||
idx = data.get("index")
|
||||
if not isinstance(idx, int) or isinstance(idx, bool):
|
||||
return None, web.json_response({"error": "missing or invalid index"}, status=400)
|
||||
models = comfy.model_management.current_loaded_models
|
||||
if idx < 0 or idx >= len(models):
|
||||
return None, web.json_response({"error": "index out of range"}, status=400)
|
||||
return idx, None
|
||||
|
||||
@routes.post("/aimdo/reset_watermark")
|
||||
async def aimdo_reset_watermark(request):
|
||||
idx, err = _get_model_idx(await request.json())
|
||||
if err:
|
||||
return err
|
||||
async with _get_lock():
|
||||
torch.cuda.empty_cache()
|
||||
models = comfy.model_management.current_loaded_models
|
||||
if idx >= len(models):
|
||||
return web.json_response({"error": "model no longer at index"}, status=409)
|
||||
patcher = models[idx].model
|
||||
if patcher is not None and hasattr(patcher, '_vbar_get'):
|
||||
vbar = patcher._vbar_get()
|
||||
if vbar is not None:
|
||||
vbar.prioritize()
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
@routes.post("/aimdo/unload_model")
|
||||
async def aimdo_unload_model(request):
|
||||
if _is_executing():
|
||||
return web.json_response({"error": "cannot unload during execution"}, status=409)
|
||||
idx, err = _get_model_idx(await request.json())
|
||||
if err:
|
||||
return err
|
||||
async with _get_lock():
|
||||
models = comfy.model_management.current_loaded_models
|
||||
if idx >= len(models):
|
||||
return web.json_response({"error": "model no longer at index"}, status=409)
|
||||
models[idx].model_unload()
|
||||
models.pop(idx)
|
||||
comfy.model_management.soft_empty_cache()
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# XB_MemoryVisualization — 显存内存观察节点
|
||||
# ============================================================
|
||||
class XB_MemoryVisualization:
|
||||
"""显存内存实时监控面板节点
|
||||
|
||||
【重要说明】
|
||||
监控面板会在 ComfyUI 启动时自动加载到界面右下角,无需将此节点添加到工作流。
|
||||
面板提供可拖拽的硬件监控窗口,功能包括:
|
||||
- VRAM/内存使用率分段条形图
|
||||
- GPU利用率/温度/功耗实时显示
|
||||
- 磁盘I/O读写速率
|
||||
- 页面文件使用情况
|
||||
- CPU使用率
|
||||
- 已加载模型列表及显存占用详情
|
||||
- 历史VRAM使用量趋势图(面积图/柱状图/K线图/点阵图)
|
||||
- 模型卸载、缓存清理快捷按钮
|
||||
|
||||
此节点的作用:
|
||||
- 作为工作流中的"锚点",方便快速定位监控面板
|
||||
- 可通过右键菜单 → "字号"调节面板字体大小
|
||||
- 可通过右键菜单 → "缩放"调节面板整体缩放
|
||||
- 执行此节点不会影响工作流,仅作为标记使用
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
"any_input": ("*", {
|
||||
"tooltip": "可选任意输入——面板启动时自动显示,此输入仅用于触发节点执行"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "show_panel"
|
||||
CATEGORY = "XB_ToolBox/Dashboard"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "实时显存内存监控面板——启动时自动显示在界面右下角,可拖拽/缩放/调节字号。右键面板查看更多选项。"
|
||||
|
||||
def show_panel(self, any_input=None, **kwargs):
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
free, total = torch.cuda.mem_get_info()
|
||||
free_gb = free / (1024**3)
|
||||
total_gb = total / (1024**3)
|
||||
used_gb = total_gb - free_gb
|
||||
print(f"[XB 硬件监控] GPU显存: {used_gb:.1f}GB / {total_gb:.1f}GB (空闲 {free_gb:.1f}GB)")
|
||||
else:
|
||||
print("[XB 硬件监控] 未检测到CUDA GPU,面板仅显示CPU/内存信息")
|
||||
return ()
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS["XB_MemoryVisualization"] = XB_MemoryVisualization
|
||||
NODE_DISPLAY_NAME_MAPPINGS["XB_MemoryVisualization"] = "XB 📊 硬件监控面板 (Memory Visualization)"
|
||||
@@ -16,6 +16,12 @@ easyocr # OCR 文字识别 (漫画工具)
|
||||
onnxruntime>=1.18.0 # ONNX 推理 (人像分割 + CosyVoice3)
|
||||
imageio-ffmpeg # FFmpeg 回退 (视频服务器)
|
||||
|
||||
# ── 硬件监控面板依赖 (Memory Visualization) ──
|
||||
psutil # CPU/内存/磁盘系统信息
|
||||
pythonnet # .NET 桥接 (LibreHardwareMonitor GPU 监控)
|
||||
# LibreHardwareMonitorLib.dll 已内置在工具箱目录中,无需额外下载
|
||||
# NVIDIA GPU: 可选安装 nvidia-ml-py 以获得更精准的显存数据
|
||||
|
||||
# ── Llama 节点依赖 (LLM/VLM 本地推理) ──
|
||||
diskcache # 磁盘缓存
|
||||
gguf # GGUF 模型格式解析
|
||||
|
||||
Reference in New Issue
Block a user