{"id":50324979,"url":"https://github.com/pathcosmos/mig-gpu-mon","last_synced_at":"2026-05-29T05:04:28.090Z","repository":{"id":344965467,"uuid":"1183862722","full_name":"pathcosmos/mig-gpu-mon","owner":"pathcosmos","description":"Real-time TUI monitor for NVIDIA MIG GPU environments — GPU Util, Memory Util, SM Util, VRAM, CPU cores, RAM (btop-style)","archived":false,"fork":false,"pushed_at":"2026-03-25T08:28:54.000Z","size":1968,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-26T12:26:18.178Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pathcosmos.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-17T02:44:16.000Z","updated_at":"2026-03-25T08:28:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pathcosmos/mig-gpu-mon","commit_stats":null,"previous_names":["pathcosmos/mig-gpu-mon"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/pathcosmos/mig-gpu-mon","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pathcosmos%2Fmig-gpu-mon","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pathcosmos%2Fmig-gpu-mon/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pathcosmos%2Fmig-gpu-mon/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pathcosmos%2Fmig-gpu-mon/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pathcosmos","download_url":"https://codeload.github.com/pathcosmos/mig-gpu-mon/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pathcosmos%2Fmig-gpu-mon/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33637486,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-29T05:04:14.146Z","updated_at":"2026-05-29T05:04:28.076Z","avatar_url":"https://github.com/pathcosmos.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mig-gpu-mon\n\n**한국어** | [English](README_EN.md)\n\nNVIDIA MIG(Multi-Instance GPU) 환경에서 `nvidia-smi`가 제공하지 못하는 GPU 메트릭을 실시간 모니터링하는 터미널 TUI 프로그램.\n\nbtop/nvtop 스타일의 실시간 sparkline 그래프를 터미널에서 표시하며, CPU 코어별 사용률과 시스템 RAM도 함께 모니터링한다.\n\n\u003e **Ubuntu 특화:** 개발 및 테스트가 Ubuntu 환경에서 이루어졌으며, 라이브러리 탐색 경로·에러 메시지·문서 모두 Ubuntu를 1차 대상으로 작성되었습니다. RHEL 계열, 컨테이너, WSL2 등 다른 환경에서도 동작하지만, Ubuntu에서 가장 매끄럽게 동작합니다.\n\n## Screen Layout\n\n### ASCII 구성도\n\n```\n┌─ mig-gpu-mon ────────────────────────────────── 2026-03-17 02:15:30 PM ┐\n│                                        pathcosmos@gmail.com           │ ← Header\n├─ CPU (64 cores) 23.4% ─────────┬─ Devices ────────────────────────────┤\n│ 17 ▮▮▮▮▮▮▮  92%   5 ▮▮▮▯▯ 34% │ \u003e MIG 0 (GPU 0: A100) GPU:45% Mem:… │ ↑ 20%\n│  2 ▮▮▮▮▮▯▯  65%  40 ▮▮▯▯▯ 18% │   MIG 1 (GPU 0: A100) GPU:12% Mem:… │ ↓\n│  0 ▮▮▮▮▯▯▯  52%  33 ▮▯▯▯▯  5% ├─ Detail ─────────────────────────────┤    ← Top 45%\n│ 12 ▮▮▮▯▯▯▯  38%   8 ▯▯▯▯▯  2% │ Name: MIG 0 (GPU 0: A100-SXM4-80GB) │ ↑\n│  ...                            │ UUID: MIG-a1b2...  Arch:Ampere CC:8.0│ │\n│                                 │ VRAM 12288 MB / 20480 MB (60.0%)    │ │\n│                                 │ GPU: 45%  Mem: 38%  SM: 45%         │ │ 50%\n│                                 │ Enc: 0%  Dec: 0%                     │ │\n│                                 │ Clk: 1410/1410/1215 MHz  P0          │ │\n│                                 │ Temp: 62°C (↓90 ✕92)  Power:127/300W│ │\n│                                 │ PCIe: Gen4 x16  TX:12.3 RX:56.7 MB/s│ │\n│                                 │ ECC: On  Corr:0  Uncorr:0            │ │\n│                                 │ Throttle: None   Processes: 2        │ ↓\n│                                 ├─ Top Processes ──────────────────────┤\n│                                 │ PID     Process         VRAM        │ ↑\n│                                 │ 12345   python3          8192 MB    │ │ 30%\n│                                 │ 12400   pt_main_thread   4096 MB    │ ↓\n├─ GPU Util 45% ──────────────────┬─ CPU Total 23.4% ───────────────────┤\n│ ▁▂▃▅▇█▇▅▃▂▁▂▃▅▇█▇▅            │ ▂▂▃▃▂▂▃▂▃▃▂▂▃▃▂▃                   │ ← 40%\n├─ Mem Ctrl 38% ──────────────────┤RAM ▮▮▮▮▮▮▯▯▯▯▯▯▯▯▯▯▯▯▯             │ ← RAM/SWP 바 (텍스트 없음)\n│ ▃▃▃▄▄▅▅▅▄▃▃▃▄▄▅▅▄             │SWP ▮▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯             │    ← Bottom 55%\n├─ VRAM 12288/20480 MB (60.0%) ──│ ▮ used  ▮ cached  ▮ free  RAM …     │ ← Memory 범례 (2줄)\n│ ▅▅▅▅▆▆▆▆▆▆▇▇▇▇▇▇▇             │ 70.1G/12.5G/6.6G  avl:77.5G        │\n├─ PCIe TX:12.3 RX:56.7 MB/s ────├─ RAM ─────────────────────────────┤\n│ ▂▃▃▅▅▆▅▃▂▂▃▅▆▆▅▃              │ ▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅ ← used+cached 색상│ (PCIe 있을 때)\n├────────────────────────────────────────────────────────────────────────┤\n│ q Quit  Tab/↑↓ Switch GPU  [1/3]                                      │ ← Footer\n└────────────────────────────────────────────────────────────────────────┘\n```\n\n### 레이아웃 계층 구조\n\n코드(`dashboard.rs`)의 실제 레이아웃 트리. 비율은 `Constraint` 값 그대로.\n\n```\ndraw()\n├── Header                          Length(3)\n├── Main                            Min(10)\n│   ├── [Top 45%]  ─── Horizontal ──────────────────────────\n│   │   ├── System Panel  50%\n│   │   │   └── CPU Cores         (전체 영역)  \" CPU ({N} cores) {pct}% \"\n│   │   │       └── dynamic N-column bars   \"{idx} ▮▮▯▯ {pct}%\" (사용량 높은 순 정렬)\n│   │   └── GPU Panel     50%\n│   │       ├── Device List        20%       \" Devices \"\n│   │       │   └── \"{\u003e} {MIG|GPU} {idx}: {name} | GPU:{pct}% Mem:{pct}%\"\n│   │       ├── GPU Detail         50%       \" Detail \"\n│   │       │   ├── Name:      {name} [Parent: GPU {n}]   (MIG일 때)\n│   │       │   ├── UUID:      {uuid}  Arch:{arch}  CC:{major.minor}\n│   │       │   ├── VRAM      {used} MB / {total} MB ({pct}%)\n│   │       │   ├── GPU: {pct}%  Mem: {pct}%  SM: {pct}%  (가로 압축)\n│   │       │   ├── Enc: {pct}%  Dec: {pct}%\n│   │       │   ├── Clk: {gfx}/{sm}/{mem} MHz  {PState}\n│   │       │   ├── Temp: {val}°C (↓{slowdown} ✕{shutdown})  Power: {u}/{l}W\n│   │       │   ├── PCIe: Gen{n} x{w}  TX:{mb} RX:{mb} MB/s\n│   │       │   ├── ECC: On/Off  Corr:{n}  Uncorr:{n}\n│   │       │   ├── Throttle: None / {reasons}\n│   │       │   └── Processes: {count}\n│   │       └── Top Processes      30%       \" Top Processes \"\n│   │           ├── Header: PID / Process / VRAM\n│   │           └── {pid} {name (max 15)} {vram} MB  (top 5 by VRAM desc)\n│   └── [Bottom 55%] ─── Horizontal ────────────────────────\n│       ├── GPU Charts    50%\n│       │   ├── GPU Util {pct}%        sparkline   25% (PCIe 있을 때) / 33%\n│       │   ├── Mem Ctrl {pct}%        sparkline   25% / 33%\n│       │   ├── VRAM {u}/{t} MB ({p}%) sparkline   25% / 34%\n│       │   └── PCIe TX/RX MB/s       sparkline   25% (PCIe 데이터 있을 때만)\n│       └── System Charts  50%\n│           ├── CPU Total {pct}%       sparkline   40%\n│           ├── RAM/SWP Bars           Length(2)    바만 표시 (텍스트 수치 없음)\n│           │   ├── RAM line                        \"RAM ▮▮▮▮▯▯\" (segmented: used/cached/free)\n│           │   └── SWP line                        \"SWP ▮▮▯▯\"\n│           ├── Memory Legend          Length(2)    2줄 범례 (RAM 차트 바로 위)\n│           │   ├── Line 1: \"▮ used  ▮ cached  ▮ free  RAM {u}/{t} GiB ({p}%)\"\n│           │   └── Line 2: \"{used}G/{cached}G/{free}G  avl:{avail}G\"\n│           └── RAM                    segmented chart Min(3)\n│               └── 세그먼트 bar chart: used(Green/Yellow/Red) + cached(Blue), 매 tick 수직 막대\n└── Footer                          Length(3)\n```\n\n### 색상 코딩\n\n| 요소 | 색상 | 조건 |\n|------|------|------|\n| CPU 코어 바 | Green / Yellow / Red | 0-50% / 50-80% / 80%+ |\n| RAM 바 (Used 구간) | Green / Yellow / Red | 0-50% / 50-80% / 80%+ (전체 사용률 기준) |\n| RAM 바 (Cached 구간) | Blue | 커널 캐시/버퍼 (available - free) |\n| RAM 바 (Free 구간) | DarkGray | 완전 미사용 |\n| RAM 수치 (avl) | White | available (스왑 없이 사용 가능한 양) |\n| Swap 바 | DarkGray / Yellow / Red | 0-20% / 20-50% / 50%+ |\n| GPU Util sparkline | Green | — |\n| Mem Ctrl sparkline | Blue | — |\n| VRAM sparkline | Magenta | — |\n| PCIe sparkline | LightCyan | PCIe 데이터 있을 때만 표시 |\n| CPU sparkline | Cyan | — |\n| RAM 차트 (Used 구간) | Green / Yellow / Red | 0-50% / 50-80% / 80%+ (used% 기준) |\n| RAM 차트 (Cached 구간) | Blue | 커널 캐시/버퍼 (available - free) |\n| VRAM % (Detail) | Green / Yellow / Red | 0-70% / 70-90% / 90%+ |\n| Temp | Green / Yellow / Red | 0-60°C / 60-80°C / 80°C+ |\n| Clock 값 | Cyan | — |\n| PState | Green / Yellow / Red | P0 / P1-P4 / P5+ |\n| PCIe 정보 | LightCyan | — |\n| Encoder/Decoder | Magenta | — |\n| Throttle \"None\" | Green | 정상 |\n| Throttle 활성 | Red + Bold | 쓰로틀 경고 |\n| ECC errors 0 | Green | 정상 |\n| ECC uncorrected \u003e 0 | Red + Bold | 위험 |\n| 선택된 GPU | Green + Bold | — |\n| Header | Cyan + Bold | — |\n\n## Why\n\nMIG 환경에서 `nvidia-smi`는 GPU Utilization, Memory Utilization 등 핵심 메트릭을 표시하지 못한다.\n`nvmlDeviceGetUtilizationRates()`가 MIG 디바이스 핸들에서 `NVML_ERROR_NOT_SUPPORTED`를 반환하기 때문이다.\n\n이 도구는 NVML C API를 직접 호출하는 **3단계 폴백 메커니즘**으로 이 제한을 우회한다:\n\n1. **1단계:** `nvmlDeviceGetUtilizationRates()` — 표준 API (비-MIG GPU에서 동작)\n2. **2단계:** `nvmlDeviceGetProcessUtilization()` — 프로세스별 SM/Memory utilization 수집 후 집계\n3. **3단계:** `nvmlDeviceGetSamples(GPU_UTILIZATION_SAMPLES)` — 부모 GPU 샘플링 + MIG 슬라이스 비율 스케일링\n\n모든 utilization API가 실패하면 (드라이버 535.x MIG에서 흔한 상황) 오해를 유발하는 0% 대신 \"N/A\"를 표시한다.\n\n## Features\n\n- MIG 인스턴스별 GPU Util, Mem Ctrl(메모리 컨트롤러), SM Util, VRAM 사용량 실시간 표시\n- **Top Processes** — PID, 프로세스명, VRAM 사용량(MB) 기준 상위 5개 표시 (compute + graphics 프로세스 통합, VRAM 미지원 시 \"N/A\" 표시)\n- 부모 GPU 메트릭(온도, 전력, 프로세스 수) 동시 표시\n- **Clock Speeds** — Graphics/SM/Memory 클럭 (MHz) + Performance State (P0~P15)\n- **PCIe Throughput** — Gen/Width + TX/RX 전송률 (MB/s), 조건부 sparkline 그래프\n- **Encoder/Decoder Utilization** — NVENC/NVDEC 사용률 (%)\n- **ECC 상태** — 활성 여부 + Corrected/Uncorrected 에러 카운트\n- **Temperature Thresholds** — Slowdown/Shutdown 임계 온도 표시\n- **Throttle Reasons** — GPU 쓰로틀 원인 실시간 표시 (PwrCap, HW-Therm 등)\n- **Architecture \u0026 Compute Capability** — GPU 아키텍처 (Ampere, Hopper 등) + CUDA CC\n- CPU 코어별 사용률 (사용량 높은 순 정렬, 터미널 너비에 따라 동적 멀티컬럼 바 그래프)\n- 시스템 RAM (세그먼트 바: used/cached/free 색상 구분 + 각 수치 색상별 표시 + available/total) / Swap 사용량\n  - RAM 계산: `used = total - available` (비해제 가능), `cached = available - free` (해제 가능 캐시/버퍼), `free = MemFree`\n- GPU Util / Mem Ctrl / **VRAM** / **PCIe** / CPU Total 시계열 sparkline 그래프 + **RAM 세그먼트 차트** (used/cached 색상 구분, 타이틀에 현재값 표시)\n  - 모든 그래프 진행 방향 통일: **RightToLeft** — 최신 데이터가 오른쪽, 시간이 지남에 따라 왼쪽으로 이동 (RAM 세그먼트 차트와 동일)\n- Tab/방향키로 GPU/MIG 인스턴스 전환\n- 단일 바이너리 배포 (~1.5MB, libc 동적 링크 — 별도 런타임 설치 불필요)\n\n### MIG 환경 메트릭 가용성\n\nMIG 인스턴스에서는 일부 메트릭이 Parent GPU에서만 제공된다:\n\n| 메트릭 | MIG 인스턴스 | Parent GPU | Cloud vGPU |\n|--------|-------------|-----------|-----------|\n| GPU/Mem/SM Util | O (폴백) | O | O |\n| VRAM | O | O | O |\n| Architecture, CC | O | O | O |\n| Clock Speeds | N/A | O | O |\n| PCIe Throughput | N/A | O | 제한적 |\n| Performance State | N/A | O | O |\n| Temperature, Power | N/A | O | O |\n| Temp Thresholds | N/A | O | O |\n| ECC 상태/에러 | N/A | O | 제한적 |\n| Throttle Reasons | N/A | O | 제한적 |\n| Encoder/Decoder | N/A | O | O |\n\n## Requirements\n\n- NVIDIA GPU + 드라이버 설치됨\n- `libnvidia-ml.so.1` 접근 가능 (드라이버 설치 시 포함)\n- 컨테이너 환경: `--gpus all` 또는 nvidia-docker 사용\n\n### NVML 라이브러리 탐색 경로\n\n프로그램 시작 시 다음 경로를 순서대로 탐색하여 NVML 라이브러리를 로딩한다.\n`LD_LIBRARY_PATH`에 등록되지 않은 환경(컨테이너, WSL, 비표준 설치 경로)에서도 자동으로 라이브러리를 찾는다.\n\n| 순서 | 경로 | 대상 환경 |\n|------|------|-----------|\n| 0 | `--nvml-path` 인자 | 사용자 지정 (최우선) |\n| 0+ | `LD_LIBRARY_PATH` 내 경로 | 환경변수 기반 (클라우드 커스텀 설정) |\n| 1 | `libnvidia-ml.so.1` | 동적 링커 (표준) |\n| 2 | `libnvidia-ml.so` | 기본 (심볼릭 링크) |\n| 3 | `/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1` | Debian / Ubuntu (x86_64) |\n| 4 | `/usr/lib64/libnvidia-ml.so.1` | RHEL / CentOS / Rocky / Amazon Linux |\n| 5 | `/usr/lib/aarch64-linux-gnu/libnvidia-ml.so.1` | Debian / Ubuntu (ARM64, Graviton) |\n| 6 | `/usr/local/nvidia/lib64/libnvidia-ml.so.1` | NVIDIA Container Toolkit (vast.io, RunPod, EKS, GKE, AKS) |\n| 7 | `/usr/local/nvidia/lib/libnvidia-ml.so.1` | NVIDIA Container Toolkit (대체 경로) |\n| 8 | `/run/nvidia/driver/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1` | NVIDIA GPU Operator (Kubernetes) |\n| 9 | `/run/nvidia/driver/usr/lib64/libnvidia-ml.so.1` | NVIDIA GPU Operator (Kubernetes, RHEL) |\n| 10 | `/usr/local/cuda/lib64/stubs/libnvidia-ml.so` | CUDA stubs (빌드 전용) |\n| 11 | `/usr/lib/wsl/lib/libnvidia-ml.so.1` | WSL2 |\n\n### 환경별 실행 가이드\n\n| 환경 | 실행 방법 |\n|------|-----------|\n| **Native (Ubuntu/RHEL)** | `mig-gpu-mon` (드라이버 설치 시 즉시 동작) |\n| **Docker** | `docker run --gpus all ...` 또는 `--runtime=nvidia -e NVIDIA_DRIVER_CAPABILITIES=compute,utility` |\n| **AWS (EC2 p4d/p5, EKS)** | Deep Learning AMI: 즉시 동작. EKS: nvidia-device-plugin 설치 필요 |\n| **GCP (a2/a3 VM, GKE)** | GPU VM: 즉시 동작. GKE: nvidia-driver-installer DaemonSet 필요 |\n| **vast.io** | 컨테이너에 자동 마운트, 즉시 동작 |\n| **RunPod** | 컨테이너에 자동 마운트, 즉시 동작 |\n| **Lambda Labs** | 즉시 동작 (네이티브 드라이버 설치) |\n| **WSL2** | `wsl --install` 후 Windows NVIDIA 드라이버 설치 필요 |\n\n### WSL2 설정 가이드\n\n**전제 조건:**\n- Windows 11 (또는 Windows 10 21H2+)\n- WSL2 (WSL1은 GPU 미지원)\n- Windows용 NVIDIA 드라이버 (Linux용 아님)\n\n**설치 확인:**\n1. PowerShell에서: `wsl -l -v` → VERSION이 2인지 확인\n2. WSL 내에서: `nvidia-smi` → GPU 정보 표시되는지 확인\n3. WSL 내에서: `ls /usr/lib/wsl/lib/libnvidia-ml.so.1` → 파일 존재 확인\n\n**트러블슈팅:**\n- `nvidia-smi` 안 됨 → Windows NVIDIA 드라이버 업데이트\n- WSL1 사용 중 → `wsl --set-version \u003cdistro\u003e 2`로 변환\n- 라이브러리 없음 → Windows NVIDIA 드라이버 재설치\n\n자동 탐지가 실패할 경우 수동 지정:\n```bash\nmig-gpu-mon --nvml-path /custom/path/libnvidia-ml.so.1\n```\n\n## Quick Start (처음부터 끝까지)\n\n새 서버에서 Rust 설치부터 실행까지 **자동 설치 스크립트** 한 번이면 끝:\n\n```bash\n# git이 없으면 먼저 설치 (Ubuntu: sudo apt install git / Rocky: sudo dnf install git)\ngit clone https://github.com/pathcosmos/mig-gpu-mon.git\ncd mig-gpu-mon\n./install.sh\n```\n\n`install.sh`가 자동으로 처리하는 것:\n1. `sudo` 사용 가능 여부 확인 (root가 아니고 sudo 없으면 안내 후 중단)\n2. `curl` 미설치 시 → 자동 설치 (apt/dnf/yum 자동 판별)\n3. `gcc` (C 링커) 미설치 시 → `build-essential`(Ubuntu) 또는 `gcc`(Rocky/RHEL) 자동 설치\n4. `git` 미설치 시 → 자동 설치\n5. Rust 미설치 시 → rustup으로 자동 설치\n6. `cargo build --release` → 최적화 빌드 (LTO + strip, ~1.5MB)\n7. 바이너리 복사 (`~/.cargo/bin` → `/usr/local/bin` → `~/.local/bin` 순으로 탐색) + PATH 확인\n\n\u003e Ubuntu, Rocky Linux, CentOS, RHEL, Amazon Linux 모두 대응. 패키지 매니저(apt/dnf/yum)를 자동 감지한다.\n\n설치 완료 후 바로 실행:\n```bash\nmig-gpu-mon\n```\n\n### 수동 설치 (단계별)\n\n```bash\n# 0. 빌드 의존성 (Ubuntu)\nsudo apt install -y curl git build-essential\n# 0. 빌드 의존성 (Rocky/RHEL)\n# sudo dnf install -y curl git gcc\n\n# 1. Rust 설치 (이미 설치되어 있으면 생략)\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nsource \"$HOME/.cargo/env\"\n\n# 2. 소스 다운로드\ngit clone https://github.com/pathcosmos/mig-gpu-mon.git\ncd mig-gpu-mon\n\n# 3. 빌드 + 시스템 등록 (한 줄)\ncargo install --path .\n\n# 4. 실행\nmig-gpu-mon\n```\n\n`cargo install`은 릴리즈 빌드(LTO + strip) 후 `~/.cargo/bin/mig-gpu-mon`에 자동 등록한다.\n`~/.cargo/bin`이 `PATH`에 포함되어 있으므로 어디서든 `mig-gpu-mon`으로 실행 가능.\n\n### 원라인 설치 (복사-붙여넣기용)\n\n\u003e **전제:** `curl`, `git`, `gcc`가 설치되어 있어야 한다. 없으면 위의 수동 설치 0번 단계를 먼저 실행.\n\n```bash\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \u0026\u0026 source \"$HOME/.cargo/env\" \u0026\u0026 git clone https://github.com/pathcosmos/mig-gpu-mon.git /tmp/mig-gpu-mon \u0026\u0026 cargo install --path /tmp/mig-gpu-mon \u0026\u0026 mig-gpu-mon --help\n```\n\n### 다른 서버에 바이너리만 복사 (Rust 없이)\n\n같은 아키텍처(x86_64 Linux)의 다른 서버에 빌드된 바이너리만 복사하면 된다.\n대상 서버의 glibc 버전이 빌드 서버와 같거나 높아야 한다 (`ldd --version`으로 확인).\n\n```bash\n# 빌드 서버에서\nscp target/release/mig-gpu-mon user@target-server:/usr/local/bin/\n\n# 대상 서버에서 (Rust 설치 불필요)\nmig-gpu-mon\n```\n\n### 제거\n\n```bash\ncargo uninstall mig-gpu-mon      # cargo install로 설치한 경우\n# 또는\nrm ~/.cargo/bin/mig-gpu-mon      # install.sh로 설치한 경우\nrm /usr/local/bin/mig-gpu-mon    # 수동 복사한 경우\n```\n\n## Build \u0026 Install (상세)\n\n```bash\n# 릴리즈 빌드 (최적화 + LTO + strip)\ncargo build --release\n\n# 바이너리 위치\nls -lh target/release/mig-gpu-mon  # ~1.5MB\n\n# 시스템 경로에 설치\ncp target/release/mig-gpu-mon /usr/local/bin/\n\n# 또는 직접 실행\n./target/release/mig-gpu-mon\n```\n\n## Usage\n\n```bash\n# 기본 실행 (1초 간격)\nmig-gpu-mon\n\n# 폴링 간격 조정 (밀리초)\nmig-gpu-mon --interval 2000    # 2초 간격 (리소스 절약)\nmig-gpu-mon -i 500             # 0.5초 간격 (빠른 반응)\n\n# NVML 라이브러리 경로 수동 지정 (자동 탐지 실패 시)\nmig-gpu-mon --nvml-path /usr/local/nvidia/lib64/libnvidia-ml.so.1\n\n# 디버그 모드 (NVML API 호출 로깅)\nmig-gpu-mon --debug              # /tmp/mig-gpu-mon-debug.log에 기록\n\n# 도움말\nmig-gpu-mon --help\n```\n\n### 키보드 조작\n\n| 키 | 동작 |\n|----|------|\n| `Tab` / `↓` / `→` | 다음 GPU/MIG 인스턴스 |\n| `Shift+Tab` / `↑` / `←` | 이전 GPU/MIG 인스턴스 |\n| `q` / `Ctrl+C` | 종료 |\n\n## Tech Stack\n\n| 역할 | 크레이트 | 용도 |\n|------|----------|------|\n| GPU Metrics | `nvml-wrapper` + `nvml-wrapper-sys` | NVML C API 바인딩, MIG FFI 직접 호출 |\n| TUI Rendering | `ratatui` + `crossterm` | sparkline, gauge, 레이아웃 위젯 |\n| System Metrics | `sysinfo` | CPU 코어별 사용률, RAM/Swap |\n| CLI | `clap` | 인자 파싱 |\n| Error Handling | `anyhow` | 에러 체인 |\n\n## Architecture\n\n```\nsrc/\n  main.rs           진입점, 메인 루프 (collect → draw → event poll)\n  app.rs            앱 상태 (메트릭, 히스토리, 선택 GPU)\n  event.rs          키보드 / tick 이벤트 핸들링\n  gpu/\n    mod.rs          모듈 선언\n    nvml.rs         NVML 래퍼 + MIG raw FFI + 디바이스 캐시\n    metrics.rs      GPU/시스템 메트릭 구조체 + VecDeque 링 버퍼 히스토리\n  ui/\n    mod.rs          모듈 선언\n    dashboard.rs    전체 TUI 레이아웃 및 위젯 렌더링\n```\n\n### 메인 루프 흐름\n\n```\nloop {\n    tick_start = Instant::now()\n    1. GPU 메트릭 수집 (NVML API)\n       - 물리 GPU: utilization_rates(), memory_info(), temperature(), ...\n       - MIG 인스턴스: utilization_rates() 실패 시\n         → nvmlDeviceGetProcessUtilization() 폴백으로 SM/Mem util 집계\n    2. 시스템 메트릭 수집 (sysinfo)\n       - CPU 코어별 usage, 총 RAM/Swap\n    3. TUI 렌더링 (ratatui)\n    4. CPU 버퍼 재활용 (이전 SystemMetrics에서 회수 → zero-alloc)\n    5. 이벤트 대기 (crossterm poll, interval - elapsed 만큼 블로킹)\n       - 드리프트 보정: 작업 시간을 빼고 남은 시간만 poll\n       - 키 입력 처리 또는 tick → 다음 루프\n}\n```\n\n## MIG Utilization 수집 메커니즘\n\n### 4단계 폴백 아키텍처\n\nMIG 환경에서 GPU/Memory utilization 수집은 계단식 폴백 전략을 사용한다:\n\n```\nnvmlDeviceGetMigDeviceHandleByIndex(parent, idx)\n    → mig_handle\n\n1단계: nvmlDeviceGetUtilizationRates(mig_handle)\n    → 성공: gpu_util, memory_util 직접 사용\n    → 실패 (NVML_ERROR_NOT_SUPPORTED): 2단계로 진행\n\n2단계: nvmlDeviceGetProcessUtilization(mig_handle, samples, \u0026count, 0)\n    → 1차 호출: count=0 → NVML_ERROR_INSUFFICIENT_SIZE, count에 필요 크기 반환\n    → 2차 호출: 버퍼 전달 → 프로세스별 smUtil, memUtil 수집\n    → max(smUtil), max(memUtil) 집계하여 인스턴스 레벨 값 산출\n    → 모든 샘플이 0이거나 fetch 실패 시: 3단계로 진행\n\n3단계: nvmlDeviceGetSamples(parent_handle, GPU_UTILIZATION_SAMPLES) — gpu_util 전용\n    → 부모 GPU에서 raw GPU utilization 샘플 수집 (20ms 간격)\n    → 최근 5개 샘플 평균, /10000으로 0-100% 스케일 변환\n    → MIG 슬라이스 비율로 스케일링: mig_util = parent_util × total_slices / mig_slices\n    → 예: parent=29%, MIG 3g.40gb → 29% × 7/3 ≈ 67%\n    → 불가능 시: \"N/A\" 표시\n\n4단계: nvmlDeviceGetSamples(parent_handle, MEMORY_UTILIZATION_SAMPLES) — memory_util 전용\n    → 부모 GPU에서 memory controller utilization 샘플 수집\n    → 3단계와 동일한 /10000 스케일링 + MIG 슬라이스 비율 환산\n    → Ampere/Hopper 모든 아키텍처에서 Mem Ctrl 표시 가능\n    → 또는 parent utilization_rates().memory 스케일링 (samples 미지원 드라이버 대응)\n    → 모두 실패 시: \"N/A\" 표시\n```\n\n\u003e **v0.3.16 변경:** GPM (`nvmlGpmMigSampleGet`) 코드를 전면 제거했다. 드라이버 535.129.03 + H100 MIG 환경에서 GPM 호출이 NVML 드라이버 내부 상태를 영구적으로 오염시켜 `memory_info()`가 `InUse` 에러를 반환하는 현상이 확인되었다. Startup Probe와 Phase 1.5 Post-probe 등 다중 방어 코드에도 불구하고, GPM 호출 자체가 세션 수준에서 NVML을 오염시키므로 근본적으로 제거하는 것이 유일한 해결책이었다. Mem Ctrl은 드라이버 550+ 업그레이드 시 `nvmlDeviceGetSamples(MEM_UTIL)` 또는 `utilization_rates()` 정상 동작으로 해결 가능하다.\n\n### 드라이버 535.x MIG 제한사항 (심층 조사)\n\n**H100 PCIe + MIG 3g.40gb + 드라이버 535.129.03** 환경에서 광범위한 테스트 결과, 이 드라이버 버전에서는 **어떤 표준 NVML API로도** MIG 인스턴스별 GPU utilization 또는 메모리 컨트롤러 utilization을 수집할 수 없음을 확인했다.\n\n#### NVML API 테스트 결과 (드라이버 535.129.03)\n\n| NVML API | 부모 GPU | MIG 인스턴스 |\n|---|---|---|\n| `nvmlDeviceGetUtilizationRates()` | NotSupported (ret=3) | NotSupported (ret=3) |\n| `nvmlDeviceGetProcessUtilization()` | size query OK → fetch NotSupported | size query OK → fetch InvalidArg (ret=2) |\n| `nvmlDeviceGetSamples(GPU_UTIL)` | **동작** (119개 샘플, raw 값) | InvalidArg (ret=2) |\n| `nvmlDeviceGetSamples(MEM_UTIL)` | NotSupported | InvalidArg |\n| `nvmlDeviceGetFieldValues(GPU_UTIL=203)` | FAIL (ret=2) | \"OK\"이지만 val=0 (더미 데이터) |\n| `nvmlDeviceGetFieldValues(MEM_UTIL=204)` | FAIL (ret=2) | \"OK\"이지만 val=0 (더미 데이터) |\n\n#### 디바이스별 메트릭 가용성\n\n| 메트릭 | MIG 인스턴스 | 부모 GPU |\n|---|---|---|\n| VRAM 사용/총량 | OK | NoPermission |\n| Temperature | InvalidArg | OK |\n| Power usage | NotSupported | OK |\n| Clock speeds | NotSupported | OK |\n| PCIe throughput | InvalidArg | OK |\n| Process list | OK | - |\n| `nvmlDeviceGetSamples(GPU_UTIL)` | InvalidArg | **OK** (raw 값 ~290000 범위) |\n\n#### Raw 샘플 값 해석\n\n부모 디바이스의 `nvmlDeviceGetSamples(GPU_UTILIZATION_SAMPLES)` 반환값:\n- ~119개 샘플, 20ms 간격\n- 값 범위: ~230000-340000 (0-100%가 아님)\n- 10000으로 나누면 합리적인 utilization 퍼센트 산출 (~29%)\n- 여러 라운드에서 안정적으로 확인 (avg=291419, 292075, 292760)\n\n#### MIG 슬라이스 비율 스케일링\n\nMIG별 utilization 직접 수집이 불가하므로, 부모의 집계된 utilization을 비례 배분:\n- MIG 디바이스 속성에서 `gpuInstanceSliceCount` 제공 (예: 3g.40gb → 3)\n- `MaxMigDeviceCount`로 전체 슬라이스 수 확인 (예: H100 → 7)\n- 공식: `mig_util = parent_util × total_slices / mig_slices`\n- 예: parent=29%, slices=3/7 → MIG util ≈ 67%\n\n이것은 **근사값**이다 — 부모의 모든 utilization이 해당 MIG 인스턴스에서 발생한다고 가정한다. 여러 MIG 인스턴스가 동시에 활성화되면 부모 utilization이 분배된다.\n\n#### 메모리 컨트롤러 Utilization — 전수 조사 결과\n\nMIG 환경에서 메모리 컨트롤러(Memory Controller) utilization을 수집하기 위해 가능한 **모든 NVML API**를 조사했다.\n\n##### 시도한 API 목록 및 결과\n\n| # | API / 접근법 | 부모 GPU | MIG 인스턴스 | 판정 |\n|---|---|---|---|---|\n| 1 | `nvmlDeviceGetUtilizationRates().memory` | NotSupported | NotSupported | ❌ MIG 공식 미지원 |\n| 2 | `nvmlDeviceGetProcessUtilization()` → `memUtil` | fetch 실패 | InvalidArg | ❌ 0 또는 에러 |\n| 3 | `nvmlDeviceGetSamples(MEM_UTIL)` | **NotSupported** | InvalidArg | ❌ 부모에서도 불가 → 스케일링 원천 차단 |\n| 4 | `nvmlDeviceGetFieldValues(MEM_UTIL=204)` | FAIL (ret=2) | \"OK\" but val=0 | ❌ 더미 데이터 |\n| 5 | `nvidia-smi dmon` mem% | — | MIG 미지원 | ❌ nvidia-smi 자체 제한 |\n| 6 | CUDA `cudaMemGetInfo` | 용량만 | 용량만 | ❌ 컨트롤러 활용률 아닌 용량 |\n| 7 | `nvmlDeviceGetMemoryBusWidth` | 정적값 | 정적값 | ❌ 버스 폭(bit)이지 utilization 아님 |\n| 8 | 드라이버 545/550/555 | — | — | ❌ 표준 API 제한 해제 없음 |\n| 9 | **NVML GPM `DRAM_BW_UTIL`** | — | **Hopper+ 동작** | ✅ 유일한 경로 |\n\n##### GPU Util과의 핵심 차이\n\nGPU Util은 부모 GPU의 `nvmlDeviceGetSamples(GPU_UTIL)`이 **동작**하여 MIG 슬라이스 비율 스케일링이 가능했다. 그러나 `MEM_UTIL`은 **부모 GPU에서조차 NotSupported**이므로 스케일링할 원본 데이터 자체가 존재하지 않는다.\n\n##### GPM (GPU Performance Monitoring) — Hopper+ 전용 솔루션\n\nNVML GPM API는 드라이버 520+에서 **Hopper (H100) 이상** 아키텍처에 도입되었다. `NVML_GPM_METRIC_DRAM_BW_UTIL` (ID 10)은 이론적 최대 대비 DRAM 대역폭 사용률(0.0~100.0%)을 제공하며, MIG 인스턴스에서도 `nvmlGpmMigSampleGet()`으로 수집 가능하다.\n\n\u003e **v0.3.16:** GPM 수집 코드가 전면 제거되었다. `--debug` 플래그로 확인한 결과:\n\u003e - GPM Startup Probe 전: `memory_info()` = OK\n\u003e - GPM Startup Probe 후: `memory_info()` = FAIL (`InUse`) — 영구 오염\n\u003e - `nvmlDeviceGetSamples(MEM_UTIL)`: size query OK (72개) → data fetch `NOT_SUPPORTED` (ret=3)\n\u003e - parent `utilization_rates()`: FAIL\n\u003e GPM이 유일한 Mem Ctrl 소스였지만, VRAM 안정성을 위해 제거. 드라이버 550+ 권장.\n\n| GPU 아키텍처 | Mem Ctrl 표시 (v0.3.16) | 비고 |\n|---|---|---|\n| Ampere (A100/A30) | \"N/A\" | nvmlDeviceGetSamples(MEM_UTIL) 미지원 |\n| Hopper (H100) + 드라이버 535 | \"N/A\" | GPM 제거 (NVML 오염), samples NOT_SUPPORTED |\n| Hopper (H100) + 드라이버 550+ | 표시 예상 | utilization_rates() 또는 samples 정상 동작 예상 |\n\n\u003e **구현 상태 (v0.3.16):** GPM 코드가 전면 제거되었다. `--debug` 플래그가 추가되어 모든 NVML API 호출의 성공/실패를 `/tmp/mig-gpu-mon-debug.log`에 기록할 수 있다.\n\n\u003e **참고:** NVIDIA 드라이버 550+ (CUDA 12.4+)부터 MIG 디바이스 핸들에서 `nvmlDeviceGetUtilizationRates()` 정식 지원이 추가되어, 3단계 폴백이 불필요해진다.\n\n#### VRAM + Mem Ctrl 동시 표시 버그 분석 및 수정 (v0.3)\n\n##### 증상\n\nMIG 환경에서 VRAM이 첫 tick에만 표시되고 이후 `0/0 MB`로 사라지며, Mem Ctrl만 \"N/A\" 또는 값으로 표시되는 현상. 둘 다 동시에 정상 표시되어야 함.\n\n##### 근본 원인 분석 — 3가지 연쇄 버그\n\n**버그 1: `get_device_info`의 GPM 쿼리가 MIG 핸들에서 NVML 상태 오염**\n\n```\ncollect_device_metrics() 호출 순서 (수정 전):\n  line 543: get_device_info(mig_device)\n            → nvmlGpmQueryDeviceSupport(mig_handle)  ← NVML 상태 오염!\n  line 546: memory_info()                             ← 오염된 상태에서 VRAM 쿼리 → 실패\n```\n\n`get_device_info()`가 MIG 핸들에 대해 `nvmlGpmQueryDeviceSupport()`를 호출. 이 GPM 쿼리가 NVML 드라이버 내부 상태를 오염시켜, 바로 아래의 `memory_info()` VRAM 쿼리가 실패하거나 `(0, 0)` 반환. DeviceInfo 캐시 덕분에 첫 tick에서만 발생하지만, 버그 2와 결합하면 매 tick 문제.\n\n**버그 2: 크로스-틱 GPM 상태 오염 (핵심 메커니즘)**\n\n```\nTick N:  VRAM 쿼리(성공) → GPM fallback(nvmlGpmMigSampleGet) → NVML 상태 오염\nTick N+1: VRAM 쿼리(실패 — 이전 tick GPM 오염 잔존) → GPM fallback → 또 오염\nTick N+2: VRAM 쿼리(실패) → ...\n```\n\n`collect_mig_instances`에서 GPM fallback(`nvmlGpmMigSampleGet`)이 VRAM 쿼리 이후에 실행되지만, 이 GPM 호출이 NVML 드라이버 상태를 오염시키면 **다음 tick**의 VRAM 쿼리가 실패. 같은 함수 내에서 순서를 바꾸는 것만으로는 해결 불가 — tick 간 상태가 지속됨.\n\n**버그 3: `memory_used`/`memory_total`이 실패를 은닉**\n\n```rust\n// 수정 전: unwrap_or((0, 0)) — 실패 시 0/0으로 조용히 사라짐\nlet (memory_used, memory_total) = device.memory_info()\n    .map(|m| (m.used, m.total))\n    .unwrap_or((0, 0));  // ← VRAM 0/0 MB (0.0%) — 사용자는 \"비활성화\"로 인식\n```\n\nVRAM 쿼리 실패 시 `u64` 타입이라 `(0, 0)`으로 fallback되어 \"VRAM 0/0 MB (0.0%)\"로 표시. `memory_util`은 `Option\u003cu32\u003e`라서 \"Mem Ctrl N/A\"로 명시적 표시되는 것과 대조적. 사용자 입장에서 VRAM이 \"사라진\" 것처럼 보임.\n\n##### 타임라인 재현\n\n```\nTick 1 (첫 tick):\n  ├── get_device_info(mig) → nvmlGpmQueryDeviceSupport(mig_handle) [첫 호출, 캐시 미스]\n  │   → NVML 상태 오염 가능 (but 캐시되어 이후 호출 없음)\n  ├── memory_info() → 성공 또는 실패 (오염 정도에 따라)\n  ├── utilization_rates() → NVML_ERROR_NOT_SUPPORTED (MIG 제한)\n  ├── process_util fallback → sm/mem util 수집\n  └── GPM fallback → nvmlGpmMigSampleGet(parent, gi_id) → 첫 tick이라 prev_sample 없음 → None\n      → 그러나 GPM 호출 자체가 NVML 상태 오염\n\nTick 2 (이후 tick):\n  ├── get_device_info(mig) → 캐시 히트 (GPM 쿼리 안 함)\n  ├── memory_info() → 실패! (Tick 1 GPM 호출의 잔존 오염)\n  │   → unwrap_or((0, 0)) → VRAM 0/0 MB ← 사용자가 보는 \"비활성화\"\n  ├── ... (나머지 동일)\n  └── GPM fallback → nvmlGpmMigSampleGet → prev_sample 있음 → memory_util 값 반환!\n      → 그러나 또 NVML 상태 오염 → Tick 3 VRAM도 실패\n\n결과: VRAM은 Tick 1에서만 표시, Tick 2+에서 0/0 MB\n      Mem Ctrl은 Tick 2+에서 값 표시 (또는 Ampere에서 항상 N/A)\n```\n\n##### 수정 내용 (3건)\n\n**수정 1: `get_device_info`에서 MIG 핸들 GPM 쿼리 차단** (`nvml.rs`)\n\n```rust\n// 수정 전: 모든 디바이스에 대해 GPM 쿼리 실행\nfn get_device_info(\u0026self, device: \u0026Device) -\u003e DeviceInfo {\n    gpm_supported: nvmlGpmQueryDeviceSupport(device.handle(), ...)  // MIG 핸들 → 오염!\n}\n\n// 수정 후: skip_gpm_query 파라미터 추가\nfn get_device_info(\u0026self, device: \u0026Device, skip_gpm_query: bool) -\u003e DeviceInfo {\n    gpm_supported: if skip_gpm_query { false } else { nvmlGpmQueryDeviceSupport(...) }\n}\n// MIG 핸들: get_device_info(mig_device, true)  → GPM 쿼리 스킵\n// Parent:   get_device_info(parent_device, false) → GPM 쿼리 정상 실행\n```\n\n**수정 2: `collect_mig_instances` 2-phase 분리** (`nvml.rs`)\n\n```rust\n// 수정 전: MIG 인스턴스별 VRAM + GPM 인터리브\nfor mig in mig_instances {\n    metrics = collect_device_metrics(mig)  // VRAM 쿼리\n    gpm_fallback(mig)                      // GPM 호출 → 다음 MIG의 VRAM 쿼리 오염\n}\n\n// 수정 후: 2-phase 분리\n// Phase 1: 모든 MIG VRAM 수집 (GPM 호출 없음)\nfor mig in mig_instances {\n    metrics = collect_device_metrics(mig)  // VRAM + utilization + process util\n    phase1.push(metrics)\n}\n// Phase 2: GPM fallback (모든 VRAM 이미 수집 완료)\nfor metrics in \u0026mut phase1 {\n    gpm_fallback(metrics)  // GPM 호출 → 오염되어도 VRAM에 영향 없음\n}\n```\n\n**수정 3: `memory_used`/`memory_total` → `Option\u003cu64\u003e`** (`metrics.rs` + `dashboard.rs`)\n\n```rust\n// 수정 전: u64 — 실패 시 (0, 0)으로 은닉\npub memory_used: u64,\npub memory_total: u64,\n// → \"VRAM 0/0 MB (0.0%)\" — 사용자에게 혼란\n\n// 수정 후: Option\u003cu64\u003e — 실패 시 \"N/A\" 명시\npub memory_used: Option\u003cu64\u003e,\npub memory_total: Option\u003cu64\u003e,\n// → \"VRAM N/A\" — gpu_util, memory_util과 동일한 패턴\n```\n\nUI도 동시 업데이트:\n- Detail 패널: `VRAM N/A` 표시 (DarkGray 색상)\n- Sparkline 타이틀: `VRAM N/A` 표시\n- 히스토리: `Some`일 때만 push → 실패 tick에서 그래프 데이터 오염 방지\n\n##### 수정 파일 목록\n\n| 파일 | 변경 내용 |\n|------|-----------|\n| `src/gpu/nvml.rs` | `get_device_info(skip_gpm_query)` 파라미터 추가, `collect_mig_instances` 2-phase 분리, MIG 호출자에서 `skip_gpm_query=true` 전달 |\n| `src/gpu/metrics.rs` | `memory_used`/`memory_total` → `Option\u003cu64\u003e`, `memory_used_mb()`/`memory_total_mb()`/`memory_percent()` → `Option` 반환 |\n| `src/ui/dashboard.rs` | VRAM detail/sparkline에 `N/A` fallback 추가, `vram_max` 계산에 `and_then` 사용 |\n\n##### 상호 검증 매트릭스\n\n| 시나리오 | VRAM 표시 | Mem Ctrl 표시 | 검증 |\n|----------|-----------|-------------|------|\n| Hopper+ MIG, Tick 1 | 정상값 (Phase 1에서 수집) | N/A (GPM 첫 tick, prev_sample 없음) | Phase 1에서 VRAM 수집 → Phase 2 GPM 오염 무관 |\n| Hopper+ MIG, Tick 2+ | 정상값 (Phase 1) | 정상값 (Phase 2 GPM delta) | GPM 오염이 있어도 VRAM은 Phase 1에서 이미 완료 |\n| Ampere MIG | 정상값 | N/A (GPM 미지원) | GPM 호출 자체가 없음 → VRAM 오염 불가 |\n| Non-MIG GPU | 정상값 | 정상값 또는 GPM값 | GPM은 non-MIG에서만 직접 호출, VRAM 먼저 수집 |\n| memory_info() 실패 | \"VRAM N/A\" | 별도 경로 | Option\u003cu64\u003e로 명시적 실패 표시 |\n| get_device_info 첫 호출 (MIG) | 정상값 | — | skip_gpm_query=true → GPM 쿼리 스킵 → NVML 오염 없음 |\n\n#### VRAM 정체(Stagnation) 버그 분석 및 수정 (v0.3.1)\n\n##### 증상\n\nMIG 환경에서 VRAM이 초반 몇 tick에 정상 표시되다가 이후 값이 **정체**(고정)된다. 텍스트 값이 변하지 않고, sparkline 그래프도 멈춤.\n\n##### 근본 원인 분석 — 2가지 연쇄 버그\n\n**버그 1: 크로스-틱 GPM 오염이 `memory_info()` 실패를 유발**\n\nv0.3에서 2-phase 분리로 **같은 tick 내** GPM→VRAM 오염을 차단했지만, GPM 호출이 남긴 NVML 드라이버 상태 오염은 **다음 tick까지 지속**된다.\n\n```\nTick N:   Phase 1(VRAM 성공) → Phase 2(GPM 호출 → NVML 상태 오염)\nTick N+1: Phase 1(memory_info() 실패 — 이전 tick GPM 오염 잔존) → memory_used = None\nTick N+2: Phase 1(또 실패) → ...\n```\n\n2-phase는 같은 tick 내 보호만 제공. **tick 간** 오염 잔존은 별도 대응 필요.\n\n**버그 2: `MetricsHistory::push()`가 `None`일 때 push 안 함 → sparkline 정체**\n\n```rust\n// 수정 전: None이면 push 자체를 건너뜀\nif let Some(val) = metrics.memory_used_mb() {\n    Self::push_ring(\u0026mut self.memory_used_mb, val, self.max_entries);\n}\n// → memory_info() 실패 시 ring buffer 업데이트 중단 → sparkline 고정\n```\n\n`memory_used`가 `None`이면 `memory_used_mb` 링 버퍼에 아무것도 push되지 않아 sparkline이 마지막 성공 값에서 멈춤. 동일 문제가 `gpu_util`, `memory_util`, `temperature` 등 모든 sparkline 메트릭에 존재.\n\n##### 수정 내용 (2건)\n\n**수정 1: `update_metrics()`에서 VRAM carry-forward** (`app.rs`)\n\n```rust\n// 수정 전: 그대로 저장\npub fn update_metrics(\u0026mut self, new_metrics: Vec\u003cGpuMetrics\u003e) { ... }\n\n// 수정 후: memory_used가 None이면 이전 tick의 동일 UUID에서 마지막 값 계승\npub fn update_metrics(\u0026mut self, mut new_metrics: Vec\u003cGpuMetrics\u003e) {\n    for m in \u0026mut new_metrics {\n        if m.memory_used.is_none() {\n            if let Some(prev) = self.metrics.iter().find(|p| p.uuid == m.uuid) {\n                m.memory_used = prev.memory_used;\n                m.memory_total = prev.memory_total;\n            }\n        }\n    }\n    // ... 기존 로직\n}\n```\n\n- GPM 오염으로 `memory_info()`가 실패해도 텍스트 표시가 \"N/A\"로 빠지지 않음\n- UUID 기반 매칭으로 MIG 인스턴스 간 혼선 없음\n\n**수정 2: `MetricsHistory::push()`에 `push_or_repeat()` 적용** (`metrics.rs`)\n\n```rust\n// 수정 전: None이면 push 건너뜀\nif let Some(val) = metrics.gpu_util {\n    Self::push_ring(\u0026mut self.gpu_util, val, self.max_entries);\n}\n\n// 수정 후: None이면 마지막 값 반복 push → sparkline 계속 롤링\nfn push_or_repeat\u003cT: Copy\u003e(buf: \u0026mut VecDeque\u003cT\u003e, val: Option\u003cT\u003e, max: usize) {\n    let v = match val {\n        Some(v) =\u003e v,\n        None =\u003e match buf.back() {\n            Some(\u0026last) =\u003e last,\n            None =\u003e return,  // 한 번도 관측 안 된 메트릭은 데이터 조작 방지\n        },\n    };\n    Self::push_ring(buf, v, max);\n}\n```\n\n모든 sparkline 메트릭(`gpu_util`, `memory_util`, `memory_used_mb`, `sm_util`, `temperature`, `power_usage_w`, `clock_graphics_mhz`, `pcie_tx_kbps`, `pcie_rx_kbps`)에 동일 적용.\n\n##### 수정 파일 목록\n\n| 파일 | 변경 내용 |\n|------|-----------|\n| `src/app.rs` | `update_metrics()` — VRAM carry-forward (이전 tick 동일 UUID에서 계승) |\n| `src/gpu/metrics.rs` | `push_or_repeat()` — 모든 sparkline 메트릭에 None 시 마지막 값 반복 push |\n\n##### 상호 검증 매트릭스\n\n| 시나리오 | VRAM 텍스트 | VRAM sparkline | 검증 |\n|----------|------------|---------------|------|\n| Tick 1 (정상) | 정상값 | 정상 push | Phase 1에서 수집 성공 |\n| Tick 2+ (GPM 오염으로 memory_info 실패) | 이전 값 유지 (carry-forward) | 이전 값 반복 push (rolling) | update_metrics에서 계승 + push_or_repeat |\n| Tick 2+ (memory_info 정상 복구) | 새 값 표시 | 새 값 push | carry-forward는 None일 때만 동작 |\n| GPU util 일시 None | 마지막 값 유지 | 마지막 값 반복 push | push_or_repeat 적용 |\n| 한 번도 관측 안 된 메트릭 | N/A | push 안 함 | `buf.back() == None` → return, 데이터 조작 방지 |\n| Ampere MIG (GPM 없음) | 정상값 | 정상 push | GPM 호출 없어 오염 불가 |\n| Non-MIG GPU | 정상값 | 정상 push | memory_info() 정상 동작 |\n\n##### v0.3 수정과의 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3 Fix 1: `skip_gpm_query` | MIG 핸들에서 GPM 쿼리 차단 → 첫 호출 오염 방지 | `get_device_info()` |\n| v0.3 Fix 2: 2-phase 분리 | 같은 tick 내 GPM→VRAM 오염 차단 | `collect_mig_instances()` |\n| v0.3 Fix 3: `Option\u003cu64\u003e` | VRAM 실패 시 0/0 대신 \"N/A\" 명시 | `metrics.rs` |\n| **v0.3.1 Fix 1: carry-forward** | **tick 간 GPM 오염 잔존 시 VRAM 값 계승** | `app.rs:update_metrics()` |\n| **v0.3.1 Fix 2: push_or_repeat** | **None 발생 시 sparkline 정체 방지** | `metrics.rs:push()` |\n\nv0.3은 \"오염 자체를 차단\"하는 방어, v0.3.1은 \"오염이 발생해도 표시를 유지\"하는 복원력(resilience) 계층.\n\n#### Top Processes 누락 버그 분석 및 수정 (v0.3.2)\n\n##### 증상\n\nMIG 환경에서 \"Top Processes\" 패널에 \"No compute processes\"만 표시되고, 실제 실행 중인 프로세스가 보이지 않는 현상.\n\n##### 근본 원인 분석 — 3가지 문제\n\n**문제 1: `UsedGpuMemory::Unavailable` 프로세스 완전 제거**\n\n```rust\n// 수정 전: VRAM 정보 없으면 프로세스 자체를 제외\nlet mut entries: Vec\u003c(u32, u64)\u003e = procs\n    .iter()\n    .filter_map(|p| {\n        let vram = match p.used_gpu_memory {\n            UsedGpuMemory::Used(bytes) =\u003e bytes,\n            UsedGpuMemory::Unavailable =\u003e return None,  // ← 프로세스 누락!\n        };\n        Some((p.pid, vram))\n    })\n    .collect();\n```\n\nMIG 환경(특히 드라이버 535.x)에서 NVML이 프로세스의 VRAM을 `Unavailable`로 반환하면, 해당 프로세스가 `filter_map`에서 완전히 제거되어 UI에 나타나지 않았다.\n\n**문제 2: Compute 프로세스만 수집**\n\n```rust\n// 수정 전: compute 프로세스만 수집\nlet (process_count, top_processes) = match device.running_compute_processes() { ... };\n// → graphics 프로세스 (Vulkan/OpenGL 등)는 수집되지 않음\n```\n\n`running_graphics_processes()`를 호출하지 않아, CUDA를 사용하지 않는 그래픽 프로세스가 누락되었다.\n\n**문제 3: API 에러 시 무조건 빈 리스트**\n\n```rust\n// 수정 전: 에러 → 빈 리스트, 원인 파악 불가\nErr(_) =\u003e (0, Vec::new()),\n```\n\n`running_compute_processes()` 실패 시 `(0, Vec::new())`를 반환하여, 프로세스가 실제로 없는 것처럼 표시되었다.\n\n##### 수정 내용 (3건)\n\n**수정 1: `GpuProcessInfo.vram_used` → `Option\u003cu64\u003e`** (`metrics.rs`)\n\n```rust\n// 수정 전: u64 — Unavailable 프로세스 배제\npub vram_used: u64,\n\n// 수정 후: Option\u003cu64\u003e — Unavailable은 None으로 보존\npub vram_used: Option\u003cu64\u003e,\n\npub fn vram_used_mb(\u0026self) -\u003e Option\u003cu64\u003e {\n    self.vram_used.map(|v| v / (1024 * 1024))\n}\n```\n\n**수정 2: Compute + Graphics 프로세스 통합 수집** (`nvml.rs`)\n\n```rust\n// 수정 후: 양쪽 모두 수집, PID 기반 dedup\nlet mut seen_pids = HashSet::new();\nlet mut entries: Vec\u003c(u32, Option\u003cu64\u003e)\u003e = Vec::new();\n\n// Compute processes (PyTorch, CUDA)\nif let Ok(procs) = device.running_compute_processes() {\n    for p in \u0026procs { /* ... */ if seen_pids.insert(p.pid) { entries.push(...); } }\n}\n// Graphics processes (Vulkan, OpenGL)\nif let Ok(procs) = device.running_graphics_processes() {\n    for p in \u0026procs { /* ... */ if seen_pids.insert(p.pid) { entries.push(...); } }\n}\n```\n\n- 양쪽 API 모두 개별 `if let Ok` — 한쪽 실패해도 다른 쪽은 정상 수집\n- `HashSet\u003cu32\u003e`로 PID 중복 방지 (양쪽에 동시 존재하는 프로세스)\n\n**수정 3: VRAM \"N/A\" 표시** (`dashboard.rs`)\n\n```rust\n// 수정 후: VRAM 없으면 \"N/A\" 표시 (프로세스는 보존)\nlet vram_str = match proc.vram_used_mb() {\n    Some(mb) =\u003e format!(\"{:\u003e7} MB\", mb),\n    None =\u003e format!(\"{:\u003e10}\", \"N/A\"),\n};\n```\n\n##### 정렬 로직 개선\n\n```rust\n// Known VRAM 내림차순 → Unavailable은 뒤로 → PID 기준 안정 정렬\nentries.sort_by(|a, b| match (b.1, a.1) {\n    (Some(bv), Some(av)) =\u003e bv.cmp(\u0026av),\n    (Some(_), None) =\u003e Ordering::Less,\n    (None, Some(_)) =\u003e Ordering::Greater,\n    (None, None) =\u003e a.0.cmp(\u0026b.0),\n});\nentries.truncate(5);\n```\n\n##### 수정 파일 목록\n\n| 파일 | 변경 내용 |\n|------|-----------|\n| `src/gpu/metrics.rs` | `GpuProcessInfo.vram_used` → `Option\u003cu64\u003e`, `vram_used_mb()` → `Option\u003cu64\u003e` |\n| `src/gpu/nvml.rs` | compute + graphics 통합 수집, PID dedup, `Unavailable` → `None` 보존, 개별 에러 처리 |\n| `src/ui/dashboard.rs` | VRAM \"N/A\" 표시, \"No processes\" 메시지 업데이트 |\n\n##### 상호 검증 매트릭스\n\n| 시나리오 | 프로세스 표시 | VRAM 표시 | 검증 |\n|----------|-------------|-----------|------|\n| Compute 프로세스 + VRAM 정상 | 표시됨 | MB 값 | 기존 동작 유지 |\n| Compute 프로세스 + VRAM Unavailable | 표시됨 | \"N/A\" | 수정 1: Option\u003cu64\u003e |\n| Graphics 프로세스만 존재 | 표시됨 | MB 또는 \"N/A\" | 수정 2: graphics 추가 |\n| 양쪽 동일 PID | 1건만 표시 | 중복 없음 | HashSet dedup |\n| compute API 실패, graphics 정상 | Graphics만 표시 | MB 또는 \"N/A\" | 개별 if let Ok |\n| 양쪽 모두 실패 | \"No processes\" | — | 빈 entries |\n| 5개 초과 프로세스 | 상위 5개 (VRAM 기준) | MB 우선, N/A 후순위 | 정렬 로직 |\n| MIG 인스턴스 | 해당 MIG 프로세스 | MB 또는 \"N/A\" | MIG 핸들에서 수집 |\n\n#### MIG Top Processes 부모 디바이스 폴백 버그 분석 및 수정 (v0.3.3)\n\n##### 증상\n\nv0.3.2에서 `UsedGpuMemory::Unavailable` 프로세스 보존 및 compute+graphics 통합 수집을 적용했음에도, MIG 인스턴스에서 \"No processes\"가 계속 표시되는 현상.\n\n##### 근본 원인 분석\n\n**문제: `running_compute_processes()` / `running_graphics_processes()`가 MIG 디바이스 핸들에서 실패**\n\n```rust\n// collect_device_metrics() 내부 — MIG 핸들 전달\nif let Ok(procs) = device.running_compute_processes() {   // ← MIG 핸들에서 Err 반환\n    for p in \u0026procs { ... }\n}\nif let Ok(procs) = device.running_graphics_processes() {  // ← MIG 핸들에서 Err 반환\n    for p in \u0026procs { ... }\n}\n```\n\nNVIDIA 드라이버 535.x 등에서 `nvmlDeviceGetComputeRunningProcesses()` / `nvmlDeviceGetGraphicsRunningProcesses()`는 **MIG 디바이스 핸들**에 대해 `NVML_ERROR_NOT_SUPPORTED`를 반환한다. `if let Ok` 패턴으로 에러가 조용히 무시되어 `entries`가 항상 빈 상태 → \"No processes\" 표시.\n\n반면 **부모 GPU 디바이스 핸들**에서 같은 API를 호출하면 모든 MIG 인스턴스의 프로세스가 `gpu_instance_id` 필드와 함께 정상 반환된다.\n\n##### 수정 내용\n\n**`collect_mig_instances()`에 Phase 3 추가** (`nvml.rs`)\n\n```rust\n// === Phase 3: MIG 프로세스 부모 디바이스 폴백 ===\n// Phase 1에서 MIG 핸들로 수집 실패한 경우, 부모 GPU에서 프로세스 쿼리 후\n// gpu_instance_id로 필터링하여 각 MIG 인스턴스에 분배\n\n// 1. 부모 디바이스에서 compute + graphics 프로세스 1회 쿼리\nlet parent_procs = parent_device.running_compute_processes()\n    + parent_device.running_graphics_processes();  // PID dedup\n\n// 2. 각 MIG 인스턴스에 gpu_instance_id 매칭하여 분배\nfor (mig_handle, metrics) in \u0026mut phase1 {\n    if !metrics.top_processes.is_empty() { continue; }  // 이미 수집 성공이면 skip\n    let gi_id = get_device_info(mig_device).gpu_instance_id;\n    metrics.top_processes = parent_procs\n        .filter(|p| p.gpu_instance_id == gi_id)\n        .sort_by_vram_desc()\n        .truncate(5);\n}\n```\n\n**핵심 설계:**\n- `nvml-wrapper 0.10`의 `ProcessInfo` 구조체가 `gpu_instance_id: Option\u003cu32\u003e` 필드 제공 → MIG 인스턴스별 필터링 가능\n- Phase 1에서 이미 프로세스를 수집한 MIG 인스턴스는 건너뜀 (일부 드라이버에서는 MIG 핸들에서도 동작)\n- 부모 디바이스 쿼리는 1회만 수행 → tick당 추가 NVML IPC 최소화\n\n##### 3-phase 수집 전체 흐름\n\n```\ncollect_mig_instances():\n  Phase 1: 각 MIG 인스턴스의 기본 메트릭 수집 (VRAM, util, 프로세스 시도)\n           → MIG 핸들에서 프로세스 API 실패 시 top_processes = []\n  Phase 2: GPM 폴백 (memory_util, Hopper+ only)\n           → 모든 VRAM 이미 수집 완료 → GPM 오염 무관\n  Phase 3: 프로세스 부모 디바이스 폴백 (NEW)\n           → 부모 GPU에서 프로세스 쿼리 → gpu_instance_id로 MIG 인스턴스별 분배\n```\n\n##### 수정 파일 목록\n\n| 파일 | 변경 내용 |\n|------|-----------|\n| `src/gpu/nvml.rs` | `collect_mig_instances()`에 Phase 3 추가 — 부모 디바이스 프로세스 쿼리 + `gpu_instance_id` 필터링 + MIG 인스턴스별 분배 |\n\n##### 상호 검증 매트릭스\n\n| 시나리오 | 프로세스 표시 | VRAM 표시 | 검증 |\n|----------|-------------|-----------|------|\n| MIG 핸들 프로세스 API 성공 | Phase 1에서 직접 수집 | MB 또는 \"N/A\" | `!top_processes.is_empty()` → Phase 3 skip |\n| MIG 핸들 프로세스 API 실패 (535.x) | 부모 디바이스에서 수집 → gi_id 필터 | MB 또는 \"N/A\" | Phase 3 폴백 동작 |\n| 부모 디바이스 프로세스 API도 실패 | \"No processes\" | — | 양쪽 모두 실패 시 빈 리스트 |\n| 여러 MIG 인스턴스에 프로세스 분산 | 각 MIG별로 올바르게 분배 | MB 또는 \"N/A\" | `gpu_instance_id` 매칭으로 정확한 분배 |\n| Non-MIG GPU | Phase 3 미실행 | MB 값 | `collect_mig_instances` 자체가 호출되지 않음 |\n\n##### v0.3.2 → v0.3.3 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.2: VRAM Unavailable 보존 | 프로세스 VRAM `None`일 때 프로세스 자체는 유지 | `collect_device_metrics()` |\n| v0.3.2: compute + graphics 통합 | 양쪽 API 모두 수집, PID dedup | `collect_device_metrics()` |\n| **v0.3.3: 부모 디바이스 폴백** | **MIG 핸들 프로세스 API 실패 시 부모에서 수집 → gi_id 분배** | `collect_mig_instances()` Phase 3 |\n\nv0.3.2는 \"MIG 핸들에서 프로세스가 반환될 때 데이터 손실 방지\", v0.3.3은 \"MIG 핸들에서 프로세스 API 자체가 실패할 때 부모 디바이스로 폴백\".\n\n#### Top Processes 깜빡임 + 자원 감사 수정 (v0.3.4)\n\n##### 증상\n\nMIG 환경에서 Top Processes가 한 틱 나타났다가 사라지는 깜빡임 현상. 장기 운영 시 HashMap 프루닝 조건 버그로 orphan 엔트리 누적 가능.\n\n##### 근본 원인 분석 — 3가지 이슈\n\n**이슈 1: Top Processes carry-forward 없음**\n\n`memory_used`는 API 실패 시 이전 값을 유지하는 carry-forward가 있지만, `top_processes`에는 동일 보호가 없었다.\n\n```\nTick 1: MIG 디바이스 running_compute_processes() 성공 → 프로세스 표시\nTick 2: 같은 API 실패 → top_processes 빈 배열 → Phase 3 시도\nPhase 3: gpu_instance_id 없으면 전부 필터 → \"No processes\" 표시\nTick 3: API 다시 성공 → 프로세스 깜빡임\n```\n\n**이슈 2: Phase 3 gpu_instance_id 필터 과도하게 엄격**\n\n```rust\n// 기존: gpu_instance_id가 None이면 모든 프로세스 필터됨\n.filter(|(_, _, proc_gi)| *proc_gi == gi_id \u0026\u0026 gi_id.is_some())\n```\n\n부모 GPU의 프로세스에 `gpu_instance_id`가 설정되지 않은 드라이버에서는 Phase 3 폴백이 무용지물.\n\n**이슈 3: app.rs history HashMap 프루닝 조건 버그**\n\n```rust\n// 기존: GPU 수가 동일하면 프루닝 안 됨 → MIG 재구성 시 orphan 누적\nif self.history.len() \u003e new_metrics.len() { ... }\n```\n\n4 MIG → 다른 UUID의 4 MIG 재구성 시 old 4개 + new 4개 = 8개 엔트리 누적.\n\n##### 수정 내역 (5개 변경)\n\n**Fix 1: Top Processes carry-forward** (`app.rs`)\n\n```rust\n// NVML 프로세스 API 간헐적 실패 시 이전 틱 프로세스 유지\nif m.top_processes.is_empty() {\n    if let Some(prev) = self.metrics.iter().find(|p| p.uuid == m.uuid) {\n        if !prev.top_processes.is_empty() {\n            m.top_processes = prev.top_processes.clone();\n            m.process_count = prev.process_count;\n        }\n    }\n}\n```\n\n**Fix 2: Phase 3 gpu_instance_id 폴백 완화** (`nvml.rs`)\n\n```rust\n// 부모 프로세스 중 아무도 gpu_instance_id가 없으면 전체 프로세스 표시\nlet any_gi_available = parent_procs.iter().any(|(_, _, gi)| gi.is_some());\n\nlet entries = if any_gi_available \u0026\u0026 gi_id.is_some() {\n    // 정상 경로: gpu_instance_id 매칭 필터\n    parent_procs.filter(|p| p.gpu_instance_id == gi_id)\n} else {\n    // 폴백: 전체 프로세스 표시 (아무것도 안 보이는 것보다 나음)\n    parent_procs.all()\n};\n```\n\n**Fix 3: HashMap 프루닝 정확도 개선** (`app.rs`)\n\n```rust\n// 기존: len 비교만 → MIG 재구성 시 UUID 변경 감지 불가\n// 수정: UUID 불일치 시 항상 프루닝 + shrink_to() 추가\nif self.history.len() != new_metrics.len()\n    || self.history.keys().any(|uuid| !new_metrics.iter().any(|m| m.uuid == *uuid))\n{\n    self.history.retain(...);\n    // 방어적 capacity 축소\n    if self.history.capacity() \u003e target * 2 {\n        self.history.shrink_to(target);\n    }\n}\n```\n\n**Fix 4: proc_name_cache shrink 임계값 개선** (`nvml.rs`)\n\n```rust\n// 기존: len.max(16) * 4 → 1000 PID 사라져도 capacity 유지\n// 수정: target * 2 → 더 적극적 메모리 회수\nlet target = name_cache.len().max(16) * 2;\nif name_cache.capacity() \u003e target * 2 {\n    name_cache.shrink_to(target);\n}\n```\n\n**Fix 5: datetime 포맷 캐시** (`dashboard.rs`)\n\n```rust\n// thread_local 캐시 — 초 단위 변경 시에만 재생성\nthread_local! {\n    static TIME_CACHE: RefCell\u003c(i64, String)\u003e = RefCell::new((0, String::new()));\n}\n// 같은 초 내 반복 호출 시 캐시 반환 → 틱당 String 할당 1회 절감\n```\n\n##### 수정 파일\n\n| 파일 | 변경 내용 |\n|------|----------|\n| `src/app.rs` | Top Processes carry-forward + HashMap 프루닝 조건 수정 + shrink_to() 추가 |\n| `src/gpu/nvml.rs` | Phase 3 gpu_instance_id 폴백 완화 + proc_name_cache shrink 임계값 개선 |\n| `src/ui/dashboard.rs` | datetime 포맷 thread_local 캐시 |\n\n##### 교차 검증 매트릭스\n\n| 시나리오 | Top Processes 표시 | 자원 영향 | 검증 |\n|----------|-------------------|----------|------|\n| MIG 프로세스 API 간헐적 실패 | 이전 틱 값 유지 (carry-forward) | Clone 1회/틱 (5 프로세스) | API 실패 시에만 동작 |\n| 부모 GPU에 gpu_instance_id 없음 | 전체 부모 프로세스 표시 | 기존과 동일 | 폴백 경로 활성화 |\n| MIG 재구성 (UUID 변경, GPU 수 동일) | — | orphan 엔트리 즉시 제거 | UUID 불일치 감지 |\n| 대량 PID 소멸 (1000→10) | — | capacity 적극적 축소 | target*2 임계값 |\n| 1초 interval 장기 운영 | 정상 | RSS ~4-8MB 안정 유지 | 모든 버퍼 bounded |\n\n##### v0.3.3 → v0.3.4 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.3: 부모 디바이스 폴백 | MIG 핸들 프로세스 API 실패 → 부모에서 수집 | `collect_mig_instances()` Phase 3 |\n| **v0.3.4: carry-forward** | **Phase 3도 빈 결과 시 이전 틱 값 유지** | `app.rs:update_metrics()` |\n| **v0.3.4: gi_id 폴백 완화** | **부모 프로세스에 gi_id 없을 때 전체 표시** | `nvml.rs` Phase 3 필터 |\n| **v0.3.4: HashMap 프루닝 정확도** | **UUID 변경 감지 + shrink** | `app.rs:update_metrics()` |\n| **v0.3.4: 자원 감사 최적화** | **proc_name_cache shrink + datetime 캐시** | `nvml.rs`, `dashboard.rs` |\n\nv0.3.3은 \"프로세스 수집 폴백 경로 추가\", v0.3.4는 \"폴백 경로 개선 + 표시 안정성 + 장기 운영 자원 최적화\".\n\n#### Sparkline 방향 버그 + 장기 운영 최적화 (v0.3.5)\n\n##### 증상\n\n1. Sparkline 그래프가 최신 데이터가 아닌 **가장 오래된 데이터**를 표시하며, 시간축 방향이 반전되어 있음\n2. CPU sparkline의 f32→u64 변환 시 truncation으로 정밀도 손실 (99.7% → 99)\n3. PCIe sparkline 타이틀에 TX/RX 모두 표시하지만 그래프는 TX만 렌더링 — 사용자 혼동\n4. 프로세스명 캐시에서 매 tick `String::clone()` 발생 — 장기 운영 시 불필요한 힙 할당 누적\n5. throttle_reasons가 매 tick `String` 할당 — \"None\" 등 빈번한 단일 값에도 힙 할당\n\n##### 근본 원인 분석\n\n**Bug 1: ratatui `RenderDirection::RightToLeft` 의미 오해 (Critical)**\n\nratatui의 `RightToLeft` 방향은 `data[0]`을 **오른쪽 끝**에 배치한다:\n```\ndata = [0, 1, 2, 3, 4, 5, 6, 7, 8]\nRightToLeft → \"xxx█▇▆▅▄▃▂▁ \"\n//              data[8]←→data[0] (오른쪽 끝)\n```\n\nVecDeque에 `[oldest(0), ..., newest(N)]` 순서로 저장된 데이터를 그대로 전달하면:\n- `data[0]` (가장 오래된 값)이 오른쪽 끝(최신 위치)에 표시됨\n- `data[N]` (최신 값)이 왼쪽(과거 위치)에 표시됨\n- `max_index = min(width, data.len())` 제한으로, 300개 히스토리 중 **가장 오래된 ~80개만** 렌더되고 최신 데이터는 아예 표시되지 않음\n\n```\nVecDeque: [T0(oldest), T1, T2, ..., T299(newest)]\nSparkline: data[0..80] = [T0, T1, ..., T79]  ← 가장 오래된 80개만!\n화면:       T79 ← T78 ← ... ← T1 ← T0(오른쪽 끝)\n```\n\n**Bug 2: f32 truncation**\n\n```rust\n// Before: truncation (99.7% → 99)\nbuf.extend(src.iter().map(|\u0026v| v as u64));\n```\n\n**Bug 3: PCIe 타이틀 모호성**\n\n타이틀 `PCIe TX:12.3 RX:56.7 MB/s`이지만 sparkline은 `history.pcie_tx_kbps`만 사용 — RX 값 변화가 그래프에 반영 안 됨.\n\n##### 수정 내용 (6개 변경)\n\n**Fix 1: Sparkline 데이터 역순 변환** (`dashboard.rs`)\n\n```rust\n// Before: oldest → newest 순서 (data[0]=oldest → 오른쪽 끝)\nbuf.extend(src.iter().map(|\u0026v| v as u64));\n\n// After: newest → oldest 순서 (data[0]=newest → 오른쪽 끝)\nbuf.extend(src.iter().rev().map(|\u0026v| v as u64));\n```\n\n`.rev()` 추가로 데이터 순서 반전:\n- `data[0]` = newest → 오른쪽 끝 ✓\n- 터미널 폭(~80) 내 최신 데이터만 표시 ✓\n- 버퍼 미충전 시 오른쪽부터 채워짐 ✓\n\n**Fix 2: f32 rounding** (`dashboard.rs`)\n\n```rust\n// Before: truncation\nbuf.extend(src.iter().rev().map(|\u0026v| v as u64));\n// After: rounding\nbuf.extend(src.iter().rev().map(|\u0026v| v.round() as u64));\n```\n\n**Fix 3: PCIe 타이틀 명확화** (`dashboard.rs`)\n\n```rust\n// Before: \"PCIe TX:12.3 RX:56.7 MB/s\" — 그래프가 TX+RX인 듯 오해\n// After: \"PCIe TX:12.3 / RX:56.7 MB/s\" + 기본 타이틀 \"PCIe TX\"\n```\n\n**Fix 4: `GpuProcessInfo::name` → `Rc\u003cstr\u003e`** (`metrics.rs`, `nvml.rs`)\n\n```rust\n// Before: 매 tick String::clone() (힙 복사)\npub name: String,\nfn process_name(\u0026self, pid: u32) -\u003e String { cache.get(\u0026pid).clone() }\n\n// After: Rc\u003cstr\u003e clone = 포인터 카운트 증가만 (힙 할당 0)\npub name: Rc\u003cstr\u003e,\nfn process_name(\u0026self, pid: u32) -\u003e Rc\u003cstr\u003e { cache.get(\u0026pid).clone() }\n```\n\n**Fix 5: `throttle_reasons` → `Cow\u003c'static, str\u003e`** (`metrics.rs`, `nvml.rs`)\n\n```rust\n// Before: 매 tick String 할당 (빈번한 \"None\" 포함)\npub throttle_reasons: Option\u003cString\u003e,\nfn format_throttle_reasons(tr) -\u003e String { String::from(\"None\") }\n\n// After: 단일 플래그 fast path → Cow::Borrowed (제로 할당)\npub throttle_reasons: Option\u003cCow\u003c'static, str\u003e\u003e,\nfn format_throttle_reasons(tr) -\u003e Cow\u003c'static, str\u003e {\n    // \"None\", \"Idle\", \"SwPwrCap\", \"HW-Slow\", \"SW-Therm\", \"HW-Therm\" → Borrowed\n    // 복합 플래그만 Cow::Owned 할당\n}\n```\n\n**Fix 6: `unused import: Text` warning 제거** (`dashboard.rs`)\n\n##### 수정 파일\n\n| 파일 | 변경 내용 |\n|------|----------|\n| `src/ui/dashboard.rs` | Sparkline 데이터 `.rev()` 역순 변환, f32 rounding, PCIe 타이틀 명확화, unused import 정리 |\n| `src/gpu/metrics.rs` | `GpuProcessInfo::name` → `Rc\u003cstr\u003e`, `throttle_reasons` → `Cow\u003c'static, str\u003e` |\n| `src/gpu/nvml.rs` | `proc_name_cache` → `HashMap\u003cu32, Rc\u003cstr\u003e\u003e`, `process_name()` → `Rc\u003cstr\u003e` 반환, `format_throttle_reasons()` → `Cow\u003c'static, str\u003e` 반환 + 단일 플래그 fast path |\n\n##### 교차 검증 매트릭스\n\n| 시나리오 | Sparkline 표시 | 성능 영향 | 검증 |\n|----------|---------------|----------|------|\n| 히스토리 300개, 터미널 80칸 | 최신 80개 표시 (newest → right) | 변경 없음 | `.rev()` + `RightToLeft` |\n| 히스토리 10개, 터미널 80칸 | 오른쪽 끝부터 10개 채움 | 변경 없음 | `RightToLeft` 특성 유지 |\n| CPU 99.7% | sparkline에 100 표시 | 변경 없음 | `.round()` 적용 |\n| PCIe TX만 그래프 | 타이틀 \"PCIe TX:\" 명시 | 변경 없음 | 타이틀에서 혼동 제거 |\n| throttle \"None\" (90%+ 빈도) | 동일 표시 | **String 할당 제거** | `Cow::Borrowed` |\n| throttle \"SwPwrCap, HW-Therm\" | 동일 표시 | 기존과 동일 (Cow::Owned) | 복합 플래그 fallback |\n| 프로세스명 캐시 히트 | 동일 표시 | **String clone → Rc bump** | GPU당 5개 × tick |\n| top_processes carry-forward | 동일 표시 | **Vec\u003cGpuProcessInfo\u003e clone 비용 감소** | Rc\u003cstr\u003e 이므로 name 복사 0 |\n\n##### v0.3.4 → v0.3.5 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.4: 자원 감사 최적화 | proc_name_cache shrink + datetime 캐시 | `nvml.rs`, `dashboard.rs` |\n| **v0.3.5: Sparkline 방향 수정** | **가장 오래된 데이터가 최신으로 표시되는 치명적 버그 수정** | `dashboard.rs` 전체 sparkline |\n| **v0.3.5: 프로세스명 Rc\u003cstr\u003e** | **매 tick String 힙 복사 제거 → 포인터 카운트만** | `metrics.rs`, `nvml.rs` |\n| **v0.3.5: throttle Cow\u003c'static, str\u003e** | **빈번한 단일 값에 대한 힙 할당 제거** | `metrics.rs`, `nvml.rs` |\n\nv0.3.4는 \"장기 운영 자원 회수 최적화\", v0.3.5는 \"실시간 표시 정확성 수정 + tick당 반복 힙 할당 제거\".\n\n#### VRAM 실시간 반영 실패 + 장기 운영 자원 최적화 (v0.3.6)\n\n##### 증상\n\n- GPU 사용량이 많다가 감소했는데 VRAM 표시가 실시간으로 변경사항을 반영하지 못함\n- Top Processes 패널에 이미 종료된 프로세스의 오래된 VRAM 값이 무기한 표시됨\n- `memory_info()` 간헐 실패 시 이전의 높은 VRAM 값이 영구적으로 carry-forward됨\n\n##### 근본 원인 분석 — 3가지 연쇄 버그\n\n**버그 1: 프로세스 carry-forward 무기한 유지 (가장 핵심)**\n\n```\n위치: app.rs update_metrics()\n```\n\nGPU 사용량 감소로 프로세스가 정상 종료되면 `running_compute_processes()`가 빈 리스트를 반환한다. 그러나 코드는 \"빈 리스트 = NVML 쿼리 깜빡임(flicker)\"으로 간주하여 이전 tick의 오래된 프로세스 목록을 **만료 체크 없이 무한정 carry-forward**했다. 새 프로세스가 나타나기 전까지 죽은 프로세스의 VRAM 값이 영구 표시됨.\n\n**버그 2: VRAM carry-forward 무기한 유지**\n\n```\n위치: app.rs update_metrics()\n```\n\nMIG 환경에서 `device.memory_info()`가 GPM 상태 corruption으로 간헐적으로 실패할 때, `memory_used = None` → 이전 tick의 높은 VRAM 값을 만료 없이 복사. VRAM이 10GB→2GB로 줄어도 실패 tick에서는 10GB가 유지됨.\n\n**버그 3: Sparkline 히스토리 이중 강화**\n\n```\n위치: metrics.rs push_or_repeat()\n```\n\n`memory_used`가 `None`일 때 마지막 높은 값을 반복 기록하여 sparkline이 VRAM 감소를 반영하지 못하고 평탄한 높은 선으로 유지됨.\n\n##### 수정 내용 (6개 변경)\n\n**변경 1: 프로세스 carry-forward → PID 생존 확인**\n\n```rust\n// 이전: 무조건 이전 tick 프로세스 복사 (무기한)\nif m.top_processes.is_empty() {\n    m.top_processes = prev.top_processes.clone();\n}\n\n// 이후: /proc/{pid} 존재 여부로 살아있는 프로세스만 carry-forward\nlet alive: Vec\u003c_\u003e = prev.top_processes.iter()\n    .filter(|p| {\n        buf.clear();\n        write!(buf, \"/proc/{}\", p.pid);\n        Path::new(buf.as_str()).exists()\n    })\n    .cloned().collect();\n```\n\n- 프로세스 종료 즉시 반영 — TTL 임의값 문제 없음\n- `/proc/{pid}` stat syscall은 커널 버퍼링으로 ~1μs 수준\n\n**변경 2: VRAM carry-forward → TTL 3회 제한**\n\n```rust\n// 이전: memory_info() 실패 시 무조건 이전 값 복사 (무기한)\n// 이후: 연속 3회까지만 carry-forward, 초과 시 None → \"N/A\"\nconst VRAM_CARRY_FORWARD_TTL: u32 = 3;\n\nlet count = if let Some(c) = self.vram_fail_count.get_mut(\u0026m.uuid) {\n    *c += 1; *c\n} else {\n    self.vram_fail_count.insert(m.uuid.clone(), 1); 1\n};\nif count \u003c= VRAM_CARRY_FORWARD_TTL { /* carry forward */ }\n// else: UI shows \"N/A\"\n```\n\n- 기본 1초 interval × 3회 = 3초 tolerance (일시적 flicker 커버)\n- 성공 시 카운터 즉시 리셋\n\n**변경 3: /proc/{pid} 경로 버퍼 재사용**\n\n```rust\n// 이전: 매 PID마다 format!(\"/proc/{}\", pid) → String 힙 할당\n// 이후: App 구조체에 proc_path_buf: String 재사용\nbuf.clear();\nwrite!(buf, \"/proc/{}\", p.pid);  // 기존 버퍼 재사용, 할당 0\n```\n\n- tick당 25+ String 할당 제거 (GPU 5 × 프로세스 5)\n- 300시간 기준 ~27억 회 불필요한 할당 방지\n\n**변경 4: active_handles Vec → HashSet**\n\n```rust\n// 이전: Vec\u003cusize\u003e → cache.retain(|k, _| active_handles.contains(k))  // O(n) per entry\n// 이후: HashSet\u003cusize\u003e → contains() O(1)\nactive_handles: RefCell\u003cHashSet\u003cusize\u003e\u003e,\n```\n\n- `prune_stale_caches()` 복잡도: O(n²) → O(n)\n- MIG 128개(16 GPU × 8) 시 16,384 비교 → 128 해시 조회\n\n**변경 5: history 정리 UUID HashSet 사전 구축**\n\n```rust\n// 이전: self.history.keys().any(|uuid| !new_metrics.iter().any(...))  // O(n×m)\n// 이후: HashSet 사전 구축 → O(1) lookup\nlet uuid_set: HashSet\u003c\u0026Rc\u003cstr\u003e\u003e = new_metrics.iter().map(|m| \u0026m.uuid).collect();\nself.history.retain(|uuid, _| uuid_set.contains(uuid));\n```\n\n- 이중 중첩 `.any()` O(n×m) → HashSet O(n) 단일 순회\n\n**변경 6: vram_fail_count entry() Rc clone 회피**\n\n```rust\n// 이전: self.vram_fail_count.entry(m.uuid.clone()).or_insert(0)  // 매번 Rc clone\n// 이후: get_mut/insert 패턴 → cache hit 시 clone 0\nlet count = if let Some(c) = self.vram_fail_count.get_mut(\u0026m.uuid) {\n    *c += 1; *c\n} else {\n    self.vram_fail_count.insert(m.uuid.clone(), 1); 1\n};\n```\n\n##### 수정 파일\n\n| 파일 | 변경 | 목적 |\n|------|------|------|\n| `app.rs` | PID 생존 확인 + VRAM TTL + proc_path_buf + UUID HashSet + Rc clone 회피 | VRAM 실시간 반영 + 자원 최적화 |\n| `nvml.rs` | `active_handles` Vec→HashSet + 시그니처 변경 | prune O(n²)→O(n) |\n\n##### 교차 검증 매트릭스\n\n| 시나리오 | 검증 항목 | 기대 결과 |\n|---------|----------|----------|\n| GPU 사용량 감소 → 프로세스 종료 | Top Processes 패널 | 종료된 프로세스 즉시 사라짐 |\n| memory_info() 1-3회 연속 실패 | VRAM 게이지 | 이전 값 유지 (tolerance) |\n| memory_info() 4회+ 연속 실패 | VRAM 게이지 | \"N/A\" 표시 |\n| memory_info() 실패 후 성공 | VRAM 게이지 + 카운터 | 즉시 실제 값 반영, 카운터 리셋 |\n| MIG 재구성 반복 100회 | active_handles HashSet | prune O(n), 메모리 일정 |\n| 128 MIG 인스턴스 | history 정리 | O(n) HashSet lookup (기존 O(n×m)=16K 비교 제거) |\n| 300시간 장기 운영 | proc_path_buf | String 할당 0 (버퍼 재사용) |\n| vram_fail_count 정상 tick | Rc clone | get_mut hit → clone 0 |\n| GPU 제거 | vram_fail_count | retain()으로 함께 정리 |\n\n##### v0.3.5 → v0.3.6 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.5: Sparkline 방향 + 힙 최적화 | sparkline 정확성 + Rc/Cow 최적화 | `dashboard.rs`, `metrics.rs`, `nvml.rs` |\n| **v0.3.6: PID 생존 확인** | **프로세스 carry-forward 즉시 만료** | `app.rs` 프로세스 표시 |\n| **v0.3.6: VRAM TTL** | **memory_info() 실패 시 3회 제한** | `app.rs` VRAM 표시 |\n| **v0.3.6: active_handles HashSet** | **prune O(n²)→O(n)** | `nvml.rs` 캐시 정리 |\n| **v0.3.6: UUID HashSet** | **history 정리 O(n×m)→O(n)** | `app.rs` GPU 제거 감지 |\n\nv0.3.5는 \"sparkline 정확성 + tick당 힙 최적화\", v0.3.6은 \"VRAM 실시간 반영 수정 + carry-forward 안전성 + 알고리즘 복잡도 개선\".\n\n#### VRAM N/A 전환 + 장기 운영 메모리 최적화 (v0.3.8)\n\n##### 증상\n\n- VRAM이 처음에는 정상 표시되다가 수 초 후 **\"N/A\"로 영구 전환**됨\n- MIG 환경에서 GPM(Hopper+) 사용 시에만 발생\n- 128코어 시스템에서 CPU 코어 바 렌더링 시 tick당 ~384회 String 할당\n\n##### 근본 원인 분석 — 2가지 문제\n\n**문제 1: GPM cross-tick NVML 상태 오염 (핵심)**\n\n```\n위치: nvml.rs collect_mig_instances() Phase 2\n```\n\n`nvmlGpmMigSampleGet()` 호출이 NVML 드라이버 내부 상태를 오염시키며, 이 오염이 **다음 tick까지 지속**되어 `memory_info()` 호출이 영구 실패한다.\n\n```\nTick 1: Phase 1 memory_info() 성공 → Phase 2 GPM 호출 → 드라이버 상태 오염\nTick 2: Phase 1 memory_info() 실패 (이전 tick 오염) → carry-forward 시작\nTick 4: VRAM_CARRY_FORWARD_TTL(3) 초과 → memory_used = None → \"N/A\" 고정\n```\n\n2-phase 설계는 **같은 tick 내**에서는 VRAM을 보호하지만, **이전 tick Phase 2의 오염이 다음 tick Phase 1에 영향**을 주는 cross-tick 문제는 방어하지 못했다.\n\n**문제 2: 장기 운영 시 불필요한 per-tick 할당**\n\n| 항목 | 할당 패턴 | 영향 |\n|------|----------|------|\n| MIG display name | 매 tick `format!().into()` → 새 `Rc\u003cstr\u003e` | MIG 인스턴스 수 × tick |\n| `phase1` Vec | `Vec::new()` → 매 tick realloc | MIG 수집 시 |\n| `seen_pids` HashSet | `HashSet::new()` → 매 tick per-device alloc | 디바이스 수 × tick |\n| CPU 바 문자열 | `make_bar()` → 코어 수 × `String` 할당 | 128코어 = 128 alloc/draw |\n| 시간 문자열 | `TIME_CACHE.clone()` + `format!()` | 2 alloc/draw |\n\n##### 수정 내용 (8개 변경)\n\n**변경 1: GPM MIG Phase 2 완전 제거** (`nvml.rs`)\n\n```rust\n// 제거: Phase 2 GPM fallback 블록 전체\n// nvmlGpmMigSampleGet() 호출 → NVML 상태 오염 원천 차단\n// memory_util은 Fallback 1(process utilization)에서 수집 가능한 경우만 표시\n```\n\nGPM으로 얻는 `memory_util`(DRAM BW) 하나 때문에 핵심 메트릭인 VRAM을 잃는 트레이드오프가 맞지 않으므로, MIG 환경에서 GPM Phase 2를 완전 스킵.\n\n**변경 2: MIG display name 캐싱** (`nvml.rs`)\n\n```rust\n// Before: 매 tick 새 Rc\u003cstr\u003e 생성\nmetrics.name = format!(\"MIG {mig_idx} (GPU {gpu_index}: {})\", metrics.name).into();\n// After: DeviceInfo.mig_display_name 캐시, Rc::clone()만\nlet cached = device_cache.get(\u0026key).and_then(|i| i.mig_display_name.clone());\nmetrics.name = cached.unwrap_or_else(|| { /* format + cache + return */ });\n```\n\n**변경 3: `phase1` Vec 사전 할당** (`nvml.rs`)\n\n```rust\n// Before: Vec::new() → push마다 realloc 가능\n// After: Vec::with_capacity(max_count) → 1회 할당\n```\n\n**변경 4: PID dedup HashSet 재사용** (`nvml.rs`)\n\n```rust\n// Before: 매 tick per-device HashSet::new()\n// After: NvmlCollector.proc_seen_pids: RefCell\u003cHashSet\u003cu32\u003e\u003e 재사용\n//        + parent_procs/entries Vec::with_capacity(16)\n```\n\n**변경 5: 시간 문자열 zero-alloc 렌더링** (`dashboard.rs`)\n\n```rust\n// Before: TIME_CACHE에서 c.1.clone() + format!(\" {} \", now) → 2 alloc/draw\n// After: 렌더링을 TIME_CACHE.with 클로저 내부로 이동\n//        + write!(c.1, ...) 버퍼 재사용 + c.1.as_str() 참조 → 0 alloc/draw\n```\n\n**변경 6: CPU 바 룩업 테이블** (`dashboard.rs`)\n\n```rust\n// Before: make_bar(usage, bar_width) → 코어당 String 할당 (128코어 = 128 alloc)\n// After: BAR_TABLE thread_local! 룩업 테이블\n//        bar_width+1개 패턴 사전 빌드, bt.1[filled].as_str() 참조\n//        터미널 리사이즈 시에만 재빌드 → 0 alloc/draw (첫 draw 이후)\n```\n\n**변경 7: thread_local const 초기화** (`dashboard.rs`)\n\n```rust\n// clippy 권장: thread_local! 초기화자에 const 사용\nstatic TIME_CACHE: ... = const { RefCell::new(...) };\nstatic BAR_TABLE: ... = const { RefCell::new(...) };\n```\n\n**변경 8: entries/parent_procs 사전 할당** (`nvml.rs`)\n\n```rust\n// Before: Vec::new() → 첫 push 시 alloc\n// After: Vec::with_capacity(16) → process count에 맞는 초기 할당\n```\n\n##### 수정 파일\n\n| 파일 | 변경 | 관련 변경 |\n|------|------|----------|\n| `src/gpu/nvml.rs` | GPM Phase 2 제거 + MIG name 캐싱 + Vec/HashSet 재사용 | 변경 1, 2, 3, 4, 8 |\n| `src/ui/dashboard.rs` | 시간 문자열 zero-alloc + BAR_TABLE 룩업 + const 초기화 | 변경 5, 6, 7 |\n\n##### 교차 검증 매트릭스\n\n| 검증 항목 | 방법 | 기대 결과 |\n|----------|------|----------|\n| VRAM N/A 전환 | MIG + Hopper 환경에서 장시간 실행 | VRAM 값 유지, N/A 전환 없음 |\n| memory_util 표시 | Fallback 1(process util) 가용 시 | 정상 표시 (불가 시 N/A) |\n| MIG name 캐싱 | 2+ tick 실행 후 DeviceInfo 캐시 확인 | 첫 tick만 format, 이후 Rc::clone |\n| CPU 바 할당 | 128코어 시스템 draw | 첫 draw 후 bar String 할당 0 |\n| 시간 문자열 | draw_header per-frame | clone/format 할당 없음 |\n| cargo clippy | 경고 확인 | 신규 경고 없음 |\n\n##### v0.3.6 → v0.3.8 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.6: VRAM TTL (3회) | memory_info() 실패 시 carry-forward 제한 | `app.rs` VRAM 표시 |\n| **v0.3.8: GPM MIG 비활성화** | **cross-tick NVML 상태 오염 원천 차단** | `nvml.rs` MIG 수집 |\n| **v0.3.8: MIG name 캐싱** | **per-tick Rc\u003cstr\u003e 할당 제거** | `nvml.rs` DeviceInfo 캐시 |\n| **v0.3.8: PID dedup 재사용** | **per-tick HashSet 할당 제거** | `nvml.rs` 프로세스 수집 |\n| **v0.3.8: BAR_TABLE 룩업** | **per-draw 128+ String 할당 제거** | `dashboard.rs` CPU 코어 바 |\n| **v0.3.8: 시간 문자열 zero-alloc** | **per-draw 2 String 할당 제거** | `dashboard.rs` 헤더 |\n\nv0.3.6은 \"VRAM TTL로 정체 방지\", v0.3.8은 \"GPM 오염 원천 차단 + 장기 운영 per-tick 할당 최소화\".\n\n#### RAM 세그먼트 차트 시각적 갭 수정 (v0.3.9)\n\n##### 증상\n\n- RAM 세그먼트 차트에서 녹색(used)과 파란색(cached) 영역이 **분리되어 표시**됨\n- 녹색 비중이 줄어들어도 파란색이 녹색에 밀착되지 않고, 두 영역 사이에 빈 공간 발생\n- 특히 used 비율이 낮을 때(\u003c 20%) 갭이 두드러짐\n\n##### 근본 원인 분석\n\n```\n위치: dashboard.rs draw_ram_segmented_chart()\n```\n\nused(녹색)가 fractional block 문자(`▁▂▃▄▅▆▇`)로 끝나는 셀에서, 해당 문자는 셀의 **아래쪽 일부만** 채운다. 셀의 나머지 위쪽 부분은 배경색(검정)으로 남아 빈 공간이 된다. cached(파란색)는 그 **다음 셀**부터 `█`(전체 블록)으로 시작하므로, 녹색 fractional 셀의 빈 위쪽 공간이 시각적 갭으로 보인다.\n\n```\n수정 전 (갭 발생):\n│█│ ← cached (blue, full block)\n│▃│ ← used (green, 3/8 block) — 위쪽 5/8이 빈 배경색\n│█│ ← used (green, full block)\n\n수정 후 (밀착):\n│█│ ← cached (blue, full block)\n│▃│ ← used (green fg, blue bg) — 아래 3/8 녹색, 위 5/8 파란색\n│█│ ← used (green, full block)\n```\n\n##### 수정 내용 (1개 변경)\n\n**변경 1: fractional used 셀에 cached 배경색 적용** (`dashboard.rs`)\n\n```rust\n// Before: fg만 설정, bg는 기본(검정)\n} else if bottom_row == used_rows \u0026\u0026 used_frac \u003e 0.05 {\n    (bar_chars[(used_frac * 8.0) as usize % 8], used_color)\n\n// After: fg=used_color, bg=Color::Blue (cached 존재 시)\n} else if bottom_row == used_rows \u0026\u0026 used_frac \u003e 0.05 {\n    let bg = if has_cached { Color::Blue } else { Color::Reset };\n    (bar_chars[(used_frac * 8.0) as usize % 8], used_color, bg)\n```\n\n하나의 셀 안에서 하단(fg) = 녹색 used, 상단(bg) = 파란색 cached를 동시에 표현하여 시각적 연속성을 보장한다.\n\n##### 수정 파일\n\n| 파일 | 변경 | 관련 변경 |\n|------|------|----------|\n| `src/ui/dashboard.rs` | fractional used 셀 bg 색상 + 3-tuple 반환 구조 | 변경 1 |\n\n##### 교차 검증 매트릭스\n\n| 검증 항목 | 방법 | 기대 결과 |\n|----------|------|----------|\n| used+cached 밀착 | used 10-30% + cached 50%+ 시나리오 | 녹색-파란색 사이 갭 없음 |\n| used 0% (cached만) | cached만 존재 시 | 파란색만 하단부터 표시 |\n| cached 0% (used만) | used만 존재 시 | 녹색만 하단부터, bg=Reset |\n| 100% 사용 | used + cached = 100% | 차트 전체 채움, 빈 공간 없음 |\n| cargo clippy | 경고 확인 | 신규 경고 없음 |\n\n##### v0.3.8 → v0.3.9 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.8: GPM MIG 비활성화 + per-tick 할당 최소화 | cross-tick 오염 차단 + 장기 운영 메모리 | `nvml.rs`, `dashboard.rs` |\n| **v0.3.9: RAM 차트 fractional 셀 bg 색상** | **used-cached 경계 시각적 갭 제거** | `dashboard.rs` RAM 세그먼트 차트 |\n\nv0.3.8은 \"데이터 수집 안정성 + 할당 최적화\", v0.3.9는 \"RAM 차트 시각적 정확성 수정\".\n\n#### MIG Mem Ctrl GPM 복원 + per-tick 중복 제거 (v0.3.10)\n\n##### 증상\n\n- MIG 인스턴스에서 Mem Ctrl.(Memory Controller Utilization) 값이 **항상 \"N/A\"**로 표시됨\n- v0.3.8 이전에는 GPM을 통해 1~2초 지연 후 값이 정상 표시되었음\n- GPU Util, VRAM은 정상 동작하지만 Mem Ctrl.만 수집 경로가 없음\n\n##### 근본 원인 분석\n\n```\n위치: nvml.rs collect_mig_instances()\n```\n\nv0.3.8에서 GPM(`nvmlGpmMigSampleGet`)이 NVML 상태를 cross-tick 오염시켜 VRAM \"N/A\" 문제를 유발한다는 것을 발견하고, GPM Phase 2를 **전면 제거**했다. 그러나 이로 인해 MIG memory_util을 수집할 수 있는 모든 경로가 차단되었다:\n\n```\n수집 경로 분석 (v0.3.8~v0.3.9):\n1. utilization_rates()       → MIG에서 항상 NVML_ERROR_NOT_SUPPORTED\n2. GPM fallback (collect_device_metrics) → !is_mig 조건으로 차단\n3. GPM fallback (collect_mig_instances) → v0.3.8에서 삭제됨\n4. process utilization       → 프로세스 없거나 mem=0이면 None\n→ 결과: memory_util 수집 경로 전무 → 항상 \"N/A\"\n```\n\n**핵심 통찰**: GPM이 NVML 상태를 오염시키지만, `memory_info()`는 Phase 1에서 **이미 수집 완료**된 상태. Phase 1 이후에 GPM을 호출하면 VRAM 데이터는 안전하다.\n\n##### 수정 내용 (4개 변경)\n\n**변경 1: Phase 1.5 GPM DRAM BW Util 복원** (`nvml.rs`)\n\n```rust\n// Phase 1: 모든 MIG 인스턴스의 VRAM(memory_info()) 수집 완료\n// Phase 1.5 (신규): GPM으로 memory_util 수집 — VRAM은 이미 안전\nlet parent_info = self.get_device_info(parent_device, false);\nif parent_info.gpm_supported {\n    for (mig_handle, gi_id, metrics) in \u0026mut phase1 {\n        if metrics.memory_util.is_some() { continue; }\n        if let Some(gi_id) = gi_id {\n            let gpm_val = self.get_dram_bw_util_gpm(\n                *mig_handle, true, Some(*gi_id), Some(parent_handle),\n            );\n            if let Some(val) = gpm_val {\n                metrics.memory_util = Some(val);\n            }\n        }\n    }\n}\n// Phase 2: 프로세스 수집 (기존)\n```\n\nPhase 1 → 1.5 → 2 순서로 실행되므로, GPM 호출 시점에 `memory_info()`는 이미 완료된 상태.\n첫 tick은 `None` (delta 계산을 위한 이전 샘플 필요), 두 번째 tick부터 Mem Ctrl. 값 표시.\n\n**변경 2: `gpu_instance_id` Phase 1 캐싱으로 중복 Device::new 제거** (`nvml.rs`)\n\n```rust\n// Before: Phase 1, 1.5, 2에서 각각 Device::new() + get_device_info() = 3회/인스턴스\n// phase1: Vec\u003c(nvmlDevice_t, GpuMetrics)\u003e\nlet mig_device = Device::new(*mig_handle, \u0026self.nvml);     // Phase 1.5\nlet mig_info = self.get_device_info(\u0026mig_device, true);    // Phase 1.5\nlet mig_device = Device::new(*mig_handle, \u0026self.nvml);     // Phase 2\nlet mig_info = self.get_device_info(\u0026mig_device, true);    // Phase 2\n\n// After: Phase 1에서 gi_id 캐싱, 1.5/2에서 직접 사용 = 1회/인스턴스\n// phase1: Vec\u003c(nvmlDevice_t, Option\u003cu32\u003e, GpuMetrics)\u003e\nlet mig_info = self.get_device_info(\u0026mig_device, true);    // Phase 1에서 1회\nlet gi_id = mig_info.gpu_instance_id;                      // 캐싱\n// Phase 1.5/2에서 gi_id 직접 참조 — Device::new/get_device_info 불필요\n```\n\nMIG 7개 인스턴스 기준 tick당 HashMap lookup 14회 → 0회 절감.\n\n**변경 3: Fallback 2에서 `get_device_info` 재호출 제거** (`nvml.rs`)\n\n```rust\n// Before: Fallback 2에서 get_device_info(\u0026mig_device, true) 재호출\nif metrics.gpu_util.is_none() {\n    let mig_info = self.get_device_info(\u0026mig_device, true);  // 중복!\n    if let Some(mig_slices) = mig_info.gpu_instance_slice_count { ... }\n}\n\n// After: Phase 1 상단에서 이미 가져온 mig_info 재사용\nif metrics.gpu_util.is_none() {\n    if let Some(mig_slices) = mig_info.gpu_instance_slice_count { ... }\n}\n```\n\n**변경 4: app.rs HashSet 이중 할당 제거** (`app.rs`)\n\n```rust\n// Before: 조건 체크용 + retain용 = 2회 HashSet 생성\nif self.history.len() != new_metrics.len()\n    || {\n        let uuid_set: HashSet\u003c_\u003e = new_metrics.iter().map(|m| \u0026m.uuid).collect();  // 1회\n        self.history.keys().any(|uuid| !uuid_set.contains(uuid))\n    }\n{\n    let uuid_set: HashSet\u003c_\u003e = new_metrics.iter().map(|m| \u0026m.uuid).collect();  // 2회 (동일)\n    self.history.retain(|uuid, _| uuid_set.contains(uuid));\n}\n\n// After: 1회 생성 후 조건 체크 + retain 모두에 사용\nlet uuid_set: HashSet\u003c_\u003e = new_metrics.iter().map(|m| \u0026m.uuid).collect();  // 1회만\nif self.history.len() != uuid_set.len()\n    || self.history.keys().any(|uuid| !uuid_set.contains(uuid))\n{\n    self.history.retain(|uuid, _| uuid_set.contains(uuid));\n}\n```\n\n##### 수정 파일\n\n| 파일 | 변경 | 관련 변경 |\n|------|------|----------|\n| `src/gpu/nvml.rs` | Phase 1.5 GPM 복원 + gi_id 캐싱 + Fallback 2 중복 제거 | 변경 1, 2, 3 |\n| `src/app.rs` | HashSet 이중 할당 제거 | 변경 4 |\n\n##### 교차 검증 매트릭스\n\n| 검증 항목 | 방법 | 기대 결과 |\n|----------|------|----------|\n| Mem Ctrl. 표시 | MIG + Hopper 환경에서 2+ tick 실행 | 첫 tick \"N/A\" → 두 번째 tick부터 0-100% 표시 |\n| VRAM 안전성 | 장시간 실행 | Phase 1에서 수집 완료 후 GPM 호출 → VRAM \"N/A\" 전환 없음 |\n| GPU Util 정상 | Fallback 1/2 경로 | 기존 동작 유지 |\n| gi_id 캐싱 | Phase 1.5/2에서 Device::new 미호출 | HashMap lookup 0회 (캐시된 gi_id 직접 사용) |\n| HashSet 단일 할당 | GPU reconfig 시나리오 | 동일 HashSet 1회만 생성 |\n| Ampere 이전 GPU | GPM 미지원 환경 | Mem Ctrl. \"N/A\" 유지 (GPM 미지원 → gpm_supported=false → 스킵) |\n| cargo clippy | 경고 확인 | 신규 경고 없음 |\n\n##### 자원 누수 감사 결과\n\n| 자원 | 보호 메커니즘 | 상태 |\n|------|-------------|------|\n| MetricsHistory VecDeque | `push_ring()` + max_entries cap | ✓ bounded |\n| device_cache HashMap | `prune_stale_caches()` 매 tick | ✓ pruned |\n| proc_name_cache | 매 tick active PID만 retain + shrink_to | ✓ pruned |\n| gpm_prev_samples | stale 엔트리 prune 시 `nvmlGpmSampleFree` | ✓ freed |\n| proc_sample_buf / sample_buf | grow-only BUT `capacity \u003e floor*8` 시 shrink | ✓ bounded |\n| App history/vram_fail_count | UUID 기반 retain + shrink_to | ✓ pruned |\n\n모든 캐시/버퍼는 정상 prune되며, 장기 운영 시 unbounded growth 없음 확인.\n\n##### v0.3.9 → v0.3.10 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.9: RAM 차트 fractional 셀 bg 색상 | used-cached 경계 시각적 갭 제거 | `dashboard.rs` RAM 세그먼트 차트 |\n| **v0.3.10: Phase 1.5 GPM 복원** | **Phase 1 이후 안전한 GPM 호출로 Mem Ctrl. 수집 복원** | `nvml.rs` MIG 수집 Phase 1.5 |\n| **v0.3.10: gi_id 사전 캐싱** | **per-tick Device::new + get_device_info 중복 제거** | `nvml.rs` Phase 1 → 1.5/2 |\n| **v0.3.10: HashSet 단일 할당** | **GPU reconfig 시 이중 HashSet 생성 제거** | `app.rs` history 정리 |\n\nv0.3.9는 \"RAM 차트 시각적 정확성 수정\", v0.3.10은 \"Mem Ctrl. GPM 안전 복원 + per-tick 중복 작업 제거\".\n\n#### v0.3.11 — VRAM 추적 안정성 + GPM 자원 누수 수정 + 장기 운영 최적화\n\n##### 문제 현상\n\n1. **VRAM 추적 3초 후 멈춤**: MIG 환경에서 `memory_info()` 초반 몇 초 수집 후 \"N/A\"로 전환, 스파크라인 동결\n2. **VRAM 그래프 급격한 스케일 점프**: VRAM \"N/A\" 전환 시 `vram_max`가 1MB로 붕괴 → 기존 히스토리 렌더링 왜곡\n3. **GPM 샘플 메모리 누수**: MIG에서 `parent_handle?`/`gpu_instance_id?` 조기 리턴 시 할당된 샘플 미해제\n4. **NvmlCollector Drop double-free**: `prune_stale_caches()`에서 free 후 Drop에서 재 free\n\n##### 근본 원인 분석\n\nPhase 1.5의 `nvmlGpmMigSampleGet()`이 NVML 드라이버 상태를 오염시켜 **다음 tick**의 Phase 1 `memory_info()`가 실패. 첫 tick은 GPM 이전 상태이므로 성공하지만, 2번째 tick부터 GPM 후유증으로 연속 실패 시작 → 3초 carry-forward 만료 → 이중 TTL(app.rs + metrics.rs) 동시 만료로 VRAM 완전 멈춤.\n\n동시에 `memory_total`도 `None` → `dashboard.rs`의 `.unwrap_or(1)` → `vram_max=1MB` → 기존 수천 MB 히스토리가 스케일 왜곡.\n\n##### 수정 내용 (7개 변경)\n\n**변경 1: VRAM carry-forward TTL 3→10 확대** (`app.rs`, `metrics.rs`)\n\n```rust\n// Before: GPM 후유증 3초 내 미복구 시 데이터 소실\nconst VRAM_CARRY_FORWARD_TTL: u32 = 3;\nconst SPARKLINE_CARRY_FORWARD_TTL: u32 = 3;\n\n// After: GPM 상태 복구에 충분한 10초 여유\nconst VRAM_CARRY_FORWARD_TTL: u32 = 10;\nconst SPARKLINE_CARRY_FORWARD_TTL: u32 = 10;\n```\n\n**변경 2: `memory_total` 무제한 carry-forward (TTL 없음)** (`app.rs`)\n\n```rust\n// memory_total은 GPU당 사실상 정적값 — 별도 캐시로 무제한 복원\nlast_known_vram_total: HashMap\u003cRc\u003cstr\u003e, u64\u003e,\n\n// 매 tick: 유효값 캐시, None 시 캐시에서 복원\nif let Some(total) = m.memory_total {\n    self.last_known_vram_total.insert(m.uuid.clone(), total);\n} else if let Some(\u0026cached) = self.last_known_vram_total.get(\u0026m.uuid) {\n    m.memory_total = Some(cached);\n}\n```\n\n`vram_max`가 `unwrap_or(1)`로 붕괴하지 않아 그래프 스케일 안정.\n\n**변경 3: TTL 만료 후 push(0) — 스파크라인 동결 방지** (`metrics.rs`)\n\n```rust\n// Before: TTL 후 push 중단 → 스파크라인 동결 (정체처럼 보임)\n// After: TTL 후 0 push → 스파크라인 계속 전진 (하강 = 데이터 소실 시각 표시)\nfn push_with_ttl\u003cT: Copy + Default\u003e(...) {\n    None =\u003e {\n        if *none_count \u003c= TTL { /* carry forward */ }\n        else if !buf.is_empty() {\n            Self::push_ring(buf, T::default(), max); // 0 push\n        }\n    }\n}\n```\n\n**변경 4: GPM 샘플 누수 수정** (`nvml.rs`)\n\n```rust\n// Before: `?` 연산자로 조기 리턴 시 allocated new_sample 누수\nlet parent = parent_handle?;   // None이면 new_sample 미해제!\nlet gi_id = gpu_instance_id?;  // 동일 누수\n\n// After: match로 명시적 free 후 리턴\nlet (parent, gi_id) = match (parent_handle, gpu_instance_id) {\n    (Some(p), Some(g)) =\u003e (p, g),\n    _ =\u003e {\n        self.raw_lib.nvmlGpmSampleFree(new_sample);\n        return None;\n    }\n};\n```\n\n**변경 5: Drop double-free 수정** (`nvml.rs`)\n\n```rust\n// Before: borrow() + iterate → prune_stale_caches에서 이미 free된 포인터 재 free\nlet prev_map = self.gpm_prev_samples.borrow();\nfor \u0026sample in prev_map.values() { nvmlGpmSampleFree(sample); }\n\n// After: get_mut() + drain() → 맵 비우면서 1회만 free\nfor (_, sample) in self.gpm_prev_samples.get_mut().drain() {\n    if !sample.is_null() { nvmlGpmSampleFree(sample); }\n}\n```\n\n**변경 6: O(n²) prev 메트릭 조회 → O(1) HashMap** (`app.rs`)\n\n```rust\n// Before: 매 GPU마다 iter().find() 선형 탐색\nif let Some(prev) = self.metrics.iter().find(|p| p.uuid == m.uuid) { ... }\n\n// After: 사전 빌드 HashMap으로 O(1) 인덱스 조회\nlet prev_by_uuid: HashMap\u003c\u0026Rc\u003cstr\u003e, usize\u003e =\n    self.metrics.iter().enumerate().map(|(i, m)| (\u0026m.uuid, i)).collect();\nif let Some(\u0026idx) = prev_by_uuid.get(\u0026m.uuid) {\n    let prev = \u0026self.metrics[idx]; ...\n}\n```\n\n**변경 7: history entry API + 조건부 HashSet** (`app.rs`)\n\n```rust\n// Before: contains_key + insert + get_mut (3회 해시)\nif !self.history.contains_key(\u0026m.uuid) {\n    self.history.insert(m.uuid.clone(), MetricsHistory::new(MAX_HISTORY));\n}\nself.history.get_mut(\u0026m.uuid).unwrap().push(m);\n\n// After: entry API (1회 해시)\nself.history.entry(m.uuid.clone())\n    .or_insert_with(|| MetricsHistory::new(MAX_HISTORY))\n    .push(m);\n\n// GPU 제거 시에만 HashSet 빌드 (history.len() \u003e new_metrics.len())\nif self.history.len() \u003e new_metrics.len() { /* prune */ }\n```\n\n##### 수정 파일\n\n| 파일 | 변경 | 관련 변경 |\n|------|------|----------|\n| `src/app.rs` | TTL 10 + memory_total 캐시 + O(1) lookup + entry API + 조건부 prune | 변경 1, 2, 6, 7 |\n| `src/gpu/metrics.rs` | TTL 10 + push(0) 후 TTL 만료 + `Default` trait bound | 변경 1, 3 |\n| `src/gpu/nvml.rs` | GPM 샘플 누수 수정 + Drop drain 패턴 | 변경 4, 5 |\n\n##### 교차 검증 매트릭스\n\n| 검증 항목 | 방법 | 기대 결과 |\n|----------|------|----------|\n| VRAM 10초 이상 추적 | MIG 환경 장시간 실행 | GPM 후유증 10초 carry-forward → 대부분 복구 |\n| vram_max 스케일 안정 | memory_info 실패 시나리오 | memory_total 캐시 → unwrap_or(1) 도달 불가 |\n| 스파크라인 계속 전진 | TTL 만료 후 확인 | 0 push로 하강 표시, 동결 없음 |\n| GPM 샘플 누수 없음 | MIG + parent_handle=None 시나리오 | 조기 리턴 전 free 확인 |\n| Drop double-free 없음 | 종료 시 ASAN/valgrind | drain()으로 1회만 free |\n| O(1) lookup 성능 | GPU 8개 이상 환경 | iter().find() 대비 선형 스캔 제거 |\n| cargo clippy | 경고 확인 | 신규 경고 없음 ✓ |\n\n##### 자원 누수 감사 결과\n\n| 자원 | v0.3.10 상태 | v0.3.11 수정 |\n|------|-------------|-------------|\n| GPM 샘플 (MIG `?` 리턴) | **누수** — 할당 후 free 없이 리턴 | ✓ match 패턴으로 명시적 free |\n| GPM 샘플 (Drop) | **double-free 위험** — prune 후 재 free | ✓ drain()으로 맵 비우며 1회 free |\n| memory_total 캐시 | 없음 — None 시 vram_max=1 | ✓ last_known_vram_total 무제한 캐시 |\n| prev 메트릭 조회 | O(n²) 선형 스캔 | ✓ HashMap O(1) |\n| history HashMap | 3회 해시 per GPU | ✓ entry API 1회 해시 |\n| UUID HashSet | 매 tick 빌드 | ✓ stale 엔트리 존재 시에만 빌드 |\n\n##### v0.3.10 → v0.3.11 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.10: Phase 1.5 GPM 안전 복원 | Phase 1 이후 GPM 호출로 VRAM 보호 | `nvml.rs` MIG 수집 |\n| **v0.3.11: VRAM TTL 10초 + memory_total 캐시** | **GPM 후유증 장기 실패 시에도 추적 유지 + 스케일 안정** | `app.rs` carry-forward |\n| **v0.3.11: 스파크라인 push(0)** | **TTL 만료 후 그래프 동결 대신 하강 표시** | `metrics.rs` push_with_ttl |\n| **v0.3.11: GPM 샘플 안전성** | **메모리 누수 + double-free 제거** | `nvml.rs` get_dram_bw_util_gpm + Drop |\n| **v0.3.11: O(1) 조회 + entry API** | **GPU 수 증가 시 tick 오버헤드 선형 유지** | `app.rs` update_metrics |\n\nv0.3.10은 \"Mem Ctrl. GPM 안전 복원\", v0.3.11은 \"VRAM 추적 안정성 + GPM 자원 안전성 + 장기 운영 최적화\".\n\n---\n\n#### v0.3.12: GPM 오염 자동 감지 + VRAM 모니터링 안정성 확보\n\n##### 문제\n\n`nvmlGpmMigSampleGet()` 호출이 NVML 드라이버 상태를 cross-tick 오염시켜 `memory_info()`가 영구 실패하는 현상. Phase 1.5 GPM → 다음 tick Phase 1 memory_info 실패 → post-probe 대상 없음(`probe_idx=None`) → GPM 계속 실행 → 재오염 무한 루프.\n\n##### 수정 내용 (3개 변경)\n\n**변경 1: GPM post-probe 오염 감지** (`nvml.rs`)\n\nPhase 1.5 GPM 호출 후 Phase 1에서 memory_info 성공했던 MIG 디바이스로 재검증. 실패 시 → `gpm_disabled_parents` HashSet에 parent 등록, 이후 tick GPM 영구 비활성화.\n\n**변경 2: `gpm_disabled_parents` 자동 정리** (`nvml.rs`)\n\n`prune_stale_caches`에서 제거된 parent GPU의 disabled 플래그 자동 정리 → GPU hot-remove/MIG 재구성 시 stale 엔트리 방지.\n\n**변경 3: CLAUDE.md GPM 오염 감지 설계 기록** (`CLAUDE.md`)\n\nGPM 호출 후 post-probe → 오염 감지 → 영구 비활성화 설계 의도를 Key Design Decisions에 추가.\n\n##### 수정 파일 목록\n\n| 파일 | 변경 내용 |\n|------|-----------|\n| `src/gpu/nvml.rs` | `gpm_disabled_parents` RefCell\u003cHashSet\u003e 추가, Phase 1.5 post-probe 오염 감지 + GPM 영구 비활성화, `prune_stale_caches` disabled 정리 |\n| `CLAUDE.md` | GPM 오염 감지 설계 문서화 |\n\n---\n\n#### v0.3.13: GPM 오염 무한루프 차단 + 장기 운영 미세 최적화\n\n##### 문제\n\nv0.3.12의 post-probe는 **같은 tick 내 즉시 발현되는 오염**만 감지. GPM 오염이 지연 발현(다음 tick에서야 memory_info 실패)되면:\n\n1. Tick N: Phase 1 memory_info 성공 → GPM 실행 → post-probe 통과 (오염 미발현)\n2. Tick N+1: 전체 MIG memory_info 실패 → `probe_idx=None` → post-probe 스킵 → GPM 재실행 → 재오염\n3. 매 tick 반복 → carry-forward TTL 10초 후 VRAM N/A\n\n##### 수정 내용 (3개 변경)\n\n**변경 1: `probe_idx=None`일 때 GPM 스킵** (`nvml.rs`)\n\n전체 MIG memory_info 실패 시 GPM 자체를 스킵하여 오염 사이클 차단. NVML이 자체 복구(1~3 tick)되면 probe_idx가 Some으로 돌아오고, 그때 GPM 재시도 + post-probe가 정상 작동.\n\n```rust\n// 수정 전: probe_idx=None → GPM 실행 → 재오염 무한루프\nlet probe_idx = phase1.iter().position(|(_, _, m)| m.memory_used.is_some());\nfor (mig_handle, gi_id, metrics) in \u0026mut phase1 { ... }  // GPM 무조건 실행\nif let Some(idx) = probe_idx { ... }                       // probe 없으면 스킵\n\n// 수정 후: probe_idx=None → GPM 전체 스킵 → NVML 복구 기회 제공\nif let Some(pidx) = probe_idx {\n    for (mig_handle, gi_id, metrics) in \u0026mut phase1 { ... }  // GPM 실행\n    // post-probe (pidx 보장)\n    let (probe_handle, _, _) = \u0026phase1[pidx];\n    ...\n}\n// else: skip GPM → break corruption cycle\n```\n\n**변경 2: `proc_seen_pids` 재활용으로 매 tick HashSet 할당 제거** (`nvml.rs`)\n\n`proc_name_cache` 정리 시 새 `HashSet\u003cu32\u003e` 할당 대신, collect phase 종료 후 유휴 상태인 `proc_seen_pids` RefCell 재활용.\n\n**변경 3: `none_count` TTL 상한 적용** (`metrics.rs`)\n\n`push_with_ttl`에서 TTL 초과 후에도 `none_count`가 무한 증가하던 문제 수정. `TTL+1`에서 cap하여 영구 N/A 메트릭에서 의미 없는 증분 방지.\n\n##### 수정 파일 목록\n\n| 파일 | 변경 내용 |\n|------|-----------|\n| `src/gpu/nvml.rs` | Phase 1.5 `probe_idx=None` 시 GPM 스킵 + `proc_seen_pids` 재활용 |\n| `src/gpu/metrics.rs` | `none_count` TTL+1 cap |\n\n##### 검증 매트릭스\n\n| 검증 항목 | 방법 | 기대 결과 |\n|----------|------|----------|\n| GPM 지연 오염 시 VRAM 복구 | MIG 장시간 실행, GPM 오염 지연 발현 시나리오 | probe_idx=None → GPM 스킵 → 1~3 tick 후 NVML 복구 → VRAM 정상 |\n| proc_name_cache 정리 할당 | tick당 힙 할당 추적 | 새 HashSet 할당 0회 (proc_seen_pids 재활용) |\n| none_count 오버플로우 방지 | 영구 N/A 메트릭 장시간 | none_count ≤ TTL+1 (11) 고정 |\n| cargo clippy | 경고 확인 | 신규 경고 없음 ✓ |\n\n##### 자원 누수 감사 결과\n\n| 자원 | v0.3.12 상태 | v0.3.13 수정 |\n|------|-------------|-------------|\n| GPM 오염 무한루프 | **probe_idx=None 시 GPM 재실행 → 재오염** | ✓ GPM 스킵으로 사이클 차단 |\n| proc_name_cache 정리 HashSet | 매 tick 새 HashSet 할당 | ✓ proc_seen_pids 재활용 |\n| none_count 무한 증가 | u32 무한 증분 (실질 무해하나 불필요) | ✓ TTL+1에서 cap |\n\n##### v0.3.10 → v0.3.13 방어 계층 관계\n\n| 방어 계층 | 보호 범위 | 적용 시점 |\n|----------|----------|----------|\n| v0.3.10: Phase 1.5 GPM 2-phase 분리 | Phase 1 VRAM 수집 후 GPM 호출 → 같은 tick VRAM 보호 | `nvml.rs` MIG 수집 |\n| v0.3.11: VRAM TTL 10초 + memory_total 캐시 | GPM 후유증 장기 실패 시에도 추적 유지 + 스케일 안정 | `app.rs` carry-forward |\n| v0.3.12: post-probe 오염 감지 + GPM 영구 비활성화 | 같은 tick 즉시 발현 오염 → GPM 차단 | `nvml.rs` Phase 1.5 |\n| **v0.3.13: probe_idx=None 시 GPM 스킵** | **지연 발현 오염 (다음 tick) → 재오염 무한루프 차단** | `nvml.rs` Phase 1.5 |\n| **v0.3.13: 매 tick 할당 최적화** | **proc_seen_pids 재활용 + none_count cap** | `nvml.rs`, `metrics.rs` |\n\nv0.3.12는 \"GPM 오염 자동 감지 + 영구 비활성화\", v0.3.13은 \"GPM 지연 오염 무한루프 차단 + 장기 운영 미세 최적화\".\n\n---\n\n#### v0.3.14: MIG Mem Ctrl 근본 해결 + Startup GPM Probe + 렌더링 최적화\n\n##### 문제\n\nv0.3.13까지의 GPM 방어 계층(post-probe, 무한루프 차단)은 모두 **GPM 오염 사후 대응**. 근본 원인은 GPM 호출 자체 — Mem Ctrl을 GPM 없이 표시할 수 있으면 GPM 호출이 불필요해져 오염이 원천적으로 사라짐.\n\n##### 수정 내용 (3개 변경)\n\n**변경 1: Parent Memory Samples Mem Ctrl 폴백** (`nvml.rs`)\n\n부모 GPU의 `nvmlDeviceGetSamples(MEMORY_UTILIZATION)` raw 값(/10000)을 MIG 슬라이스 비율로 스케일링 → GPM 없이 Mem Ctrl 표시. Phase 1에서 `memory_util`을 채우므로 Phase 1.5 GPM이 자연스럽게 억제됨.\n\n**변경 2: Startup GPM Safety Probe** (`nvml.rs`)\n\n`NvmlCollector::new()`에서 MIG-enabled 부모 GPU마다 GPM 안전성 1회 검증. `nvmlGpmMigSampleGet()` 호출 후 `memory_info()` 재확인 → 실패 시 `gpm_disabled_parents`에 즉시 등록, 런타임 오염 원천 차단.\n\n**변경 3: 렌더링 최적화** (`dashboard.rs`)\n\n`selected_metrics()` 7회 중복 호출 → 1회 캐싱, Cow::Borrowed 정적 타이틀, Vec::with_capacity Span 벡터.\n\n##### 수정 파일 목록\n\n| 파일 | 변경 내용 |\n|------|-----------|\n| `src/gpu/nvml.rs` | `get_mem_util_from_samples()` 추가 + Phase 1 Mem Ctrl 폴백 + Startup GPM Probe |\n| `src/ui/dashboard.rs` | 렌더링 중복 제거 + 정적 타이틀 + Vec 사전 할당 |\n\n---\n\n#### v0.3.15: Mem Ctrl 다중 폴백 + GPM 방어적 차단 강화\n\n##### 문제\n\nv0.3.14의 Mem Ctrl 근본 해결은 `nvmlDeviceGetSamples(type=1 MEM_UTIL)`이 항상 작동한다고 가정. 그러나 일부 드라이버/아키텍처에서 이 sample type을 지원하지 않아 `parent_sampled_mem_util = None` → GPM 억제 체인이 깨짐:\n\n```\nnvmlDeviceGetSamples(type=1) 실패 → parent_sampled_mem_util = None\n  → Phase 1 Mem Ctrl 폴백 스킵 → memory_util = None (Mem Ctrl N/A)\n    → Phase 1.5 GPM 억제 실패 → GPM 실행 → NVML 오염\n      → memory_info() 연쇄 실패 → 10 tick 후 VRAM N/A\n```\n\n##### 근본 원인\n\n두 증상(Mem Ctrl 처음부터 N/A, VRAM 10초 후 N/A)은 하나의 인과 체인:\n1. `nvmlDeviceGetSamples(type=1)` 미지원 → Mem Ctrl에 값 없음\n2. `memory_util`이 None → Phase 1.5 GPM 억제 ","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpathcosmos%2Fmig-gpu-mon","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpathcosmos%2Fmig-gpu-mon","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpathcosmos%2Fmig-gpu-mon/lists"}