nptop 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nptop-0.1.0/LICENSE +22 -0
- nptop-0.1.0/PKG-INFO +118 -0
- nptop-0.1.0/README.md +99 -0
- nptop-0.1.0/pyproject.toml +34 -0
- nptop-0.1.0/setup.cfg +4 -0
- nptop-0.1.0/src/nptop.egg-info/PKG-INFO +118 -0
- nptop-0.1.0/src/nptop.egg-info/SOURCES.txt +17 -0
- nptop-0.1.0/src/nptop.egg-info/dependency_links.txt +1 -0
- nptop-0.1.0/src/nptop.egg-info/entry_points.txt +3 -0
- nptop-0.1.0/src/nptop.egg-info/top_level.txt +1 -0
- nptop-0.1.0/src/nputop/__init__.py +6 -0
- nptop-0.1.0/src/nputop/__main__.py +4 -0
- nptop-0.1.0/src/nputop/backend.py +219 -0
- nptop-0.1.0/src/nputop/cli.py +183 -0
- nptop-0.1.0/src/nputop/models.py +55 -0
- nptop-0.1.0/src/nputop/render.py +174 -0
- nptop-0.1.0/tests/test_backend.py +56 -0
- nptop-0.1.0/tests/test_cli.py +18 -0
- nptop-0.1.0/tests/test_render.py +63 -0
nptop-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nputop contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
nptop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nptop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An nvitop-like interactive monitor for Ascend NPUs
|
|
5
|
+
Author: ZYM-PKU
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: npu,ascend,monitor,npu-smi,tui
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# nptop
|
|
21
|
+
|
|
22
|
+
`nptop` 是一个面向华为昇腾 NPU 的轻量级、零 Python 依赖监控工具,提供类似
|
|
23
|
+
`nvitop` 的实时设备和进程视图。它调用机器已有的 `npu-smi`,不要求安装 CANN
|
|
24
|
+
Python SDK。
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
## 功能
|
|
30
|
+
|
|
31
|
+
- 每张 NPU 独占一行,AICore 利用率与 HBM 占用率均使用彩色进度条
|
|
32
|
+
- 展示 NPU 进程、用户、宿主机 CPU/内存、NPU 显存和完整命令行
|
|
33
|
+
- 固定高度可滚动视图,启动时位于顶部,支持键盘和鼠标滚轮
|
|
34
|
+
- 交互式刷新和按显存、CPU、PID 排序
|
|
35
|
+
- 支持单次文本输出和 JSON 输出,方便脚本、巡检与监控系统集成
|
|
36
|
+
- 无第三方运行时依赖;兼容容器里的宿主 PID 信息缺失场景
|
|
37
|
+
- 对不同 `npu-smi` 版本采用表头和字段语义解析,不依赖固定表格宽度
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
机器需要已安装并可运行 `npu-smi`。
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install nptop
|
|
45
|
+
nptop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
从 Git 仓库安装:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install git+https://github.com/ZYM-PKU/nptop.git
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
开发安装:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/ZYM-PKU/nptop.git
|
|
58
|
+
cd nptop
|
|
59
|
+
python -m pip install -e .
|
|
60
|
+
nptop
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 使用
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
nptop # 交互视图,每 2 秒刷新
|
|
67
|
+
nptop -i 1 # 每秒刷新(-i 是 interval 的短参数)
|
|
68
|
+
nptop -d 0,2,3 # 只看指定 NPU
|
|
69
|
+
nptop -1 # 输出一次后退出
|
|
70
|
+
nptop --json # JSON 快照
|
|
71
|
+
nptop --sort cpu # 进程按 CPU 排序
|
|
72
|
+
nptop --raw # 输出原始 npu-smi,便于兼容性排查
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
交互模式按键:方向键或 `j`/`k` 逐行滚动,`PageUp`/`PageDown` 翻页,
|
|
76
|
+
`Home`/`g` 返回顶部,`End`/`G` 到达底部,也支持鼠标滚轮。`q` 退出,`r`
|
|
77
|
+
立即刷新,`m` 按 NPU 显存排序,`c` 按 CPU 排序,`p` 按 PID 排序。也可以使用
|
|
78
|
+
`Ctrl-C` 退出。
|
|
79
|
+
|
|
80
|
+
完整参数见:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
nptop --help
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
如果 `npu-smi` 不在 `PATH`,可以显式指定:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
NPUTOP_NPU_SMI=/usr/local/Ascend/driver/tools/npu-smi nptop
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 容器说明
|
|
93
|
+
|
|
94
|
+
容器需要能够执行 `npu-smi`,并挂载相应的昇腾设备和驱动目录。`npu-smi`
|
|
95
|
+
返回的宿主机 PID 在容器 PID 命名空间内可能不可见;这时 USER、CPU、宿主机内存
|
|
96
|
+
和完整命令会显示为 `?`/`-`,NPU 指标不受影响。
|
|
97
|
+
|
|
98
|
+
## 开发与发布
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m pip install pytest build twine
|
|
102
|
+
pytest
|
|
103
|
+
python -m build
|
|
104
|
+
twine check dist/*
|
|
105
|
+
twine upload dist/*
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
发布前请把 `pyproject.toml` 中的 `project.urls` 替换成实际 Git 仓库地址。版本号在
|
|
109
|
+
`pyproject.toml` 与 `src/nputop/__init__.py` 中维护。
|
|
110
|
+
|
|
111
|
+
## 已知范围
|
|
112
|
+
|
|
113
|
+
当前后端针对华为昇腾 `npu-smi info`。不同驱动大版本如果改变了表格语义,可用
|
|
114
|
+
`nptop --raw` 保存输出并提交 issue。项目结构允许后续增加其他 NPU 厂商后端。
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
nptop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# nptop
|
|
2
|
+
|
|
3
|
+
`nptop` 是一个面向华为昇腾 NPU 的轻量级、零 Python 依赖监控工具,提供类似
|
|
4
|
+
`nvitop` 的实时设备和进程视图。它调用机器已有的 `npu-smi`,不要求安装 CANN
|
|
5
|
+
Python SDK。
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## 功能
|
|
11
|
+
|
|
12
|
+
- 每张 NPU 独占一行,AICore 利用率与 HBM 占用率均使用彩色进度条
|
|
13
|
+
- 展示 NPU 进程、用户、宿主机 CPU/内存、NPU 显存和完整命令行
|
|
14
|
+
- 固定高度可滚动视图,启动时位于顶部,支持键盘和鼠标滚轮
|
|
15
|
+
- 交互式刷新和按显存、CPU、PID 排序
|
|
16
|
+
- 支持单次文本输出和 JSON 输出,方便脚本、巡检与监控系统集成
|
|
17
|
+
- 无第三方运行时依赖;兼容容器里的宿主 PID 信息缺失场景
|
|
18
|
+
- 对不同 `npu-smi` 版本采用表头和字段语义解析,不依赖固定表格宽度
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
机器需要已安装并可运行 `npu-smi`。
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install nptop
|
|
26
|
+
nptop
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
从 Git 仓库安装:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install git+https://github.com/ZYM-PKU/nptop.git
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
开发安装:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/ZYM-PKU/nptop.git
|
|
39
|
+
cd nptop
|
|
40
|
+
python -m pip install -e .
|
|
41
|
+
nptop
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 使用
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
nptop # 交互视图,每 2 秒刷新
|
|
48
|
+
nptop -i 1 # 每秒刷新(-i 是 interval 的短参数)
|
|
49
|
+
nptop -d 0,2,3 # 只看指定 NPU
|
|
50
|
+
nptop -1 # 输出一次后退出
|
|
51
|
+
nptop --json # JSON 快照
|
|
52
|
+
nptop --sort cpu # 进程按 CPU 排序
|
|
53
|
+
nptop --raw # 输出原始 npu-smi,便于兼容性排查
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
交互模式按键:方向键或 `j`/`k` 逐行滚动,`PageUp`/`PageDown` 翻页,
|
|
57
|
+
`Home`/`g` 返回顶部,`End`/`G` 到达底部,也支持鼠标滚轮。`q` 退出,`r`
|
|
58
|
+
立即刷新,`m` 按 NPU 显存排序,`c` 按 CPU 排序,`p` 按 PID 排序。也可以使用
|
|
59
|
+
`Ctrl-C` 退出。
|
|
60
|
+
|
|
61
|
+
完整参数见:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
nptop --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
如果 `npu-smi` 不在 `PATH`,可以显式指定:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
NPUTOP_NPU_SMI=/usr/local/Ascend/driver/tools/npu-smi nptop
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 容器说明
|
|
74
|
+
|
|
75
|
+
容器需要能够执行 `npu-smi`,并挂载相应的昇腾设备和驱动目录。`npu-smi`
|
|
76
|
+
返回的宿主机 PID 在容器 PID 命名空间内可能不可见;这时 USER、CPU、宿主机内存
|
|
77
|
+
和完整命令会显示为 `?`/`-`,NPU 指标不受影响。
|
|
78
|
+
|
|
79
|
+
## 开发与发布
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python -m pip install pytest build twine
|
|
83
|
+
pytest
|
|
84
|
+
python -m build
|
|
85
|
+
twine check dist/*
|
|
86
|
+
twine upload dist/*
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
发布前请把 `pyproject.toml` 中的 `project.urls` 替换成实际 Git 仓库地址。版本号在
|
|
90
|
+
`pyproject.toml` 与 `src/nputop/__init__.py` 中维护。
|
|
91
|
+
|
|
92
|
+
## 已知范围
|
|
93
|
+
|
|
94
|
+
当前后端针对华为昇腾 `npu-smi info`。不同驱动大版本如果改变了表格语义,可用
|
|
95
|
+
`nptop --raw` 保存输出并提交 issue。项目结构允许后续增加其他 NPU 厂商后端。
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nptop"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An nvitop-like interactive monitor for Ascend NPUs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "ZYM-PKU" }]
|
|
13
|
+
keywords = ["npu", "ascend", "monitor", "npu-smi", "tui"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: System :: Monitoring",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
nptop = "nputop.cli:main"
|
|
27
|
+
nputop = "nputop.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
addopts = "-q"
|
|
34
|
+
testpaths = ["tests"]
|
nptop-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nptop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An nvitop-like interactive monitor for Ascend NPUs
|
|
5
|
+
Author: ZYM-PKU
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: npu,ascend,monitor,npu-smi,tui
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# nptop
|
|
21
|
+
|
|
22
|
+
`nptop` 是一个面向华为昇腾 NPU 的轻量级、零 Python 依赖监控工具,提供类似
|
|
23
|
+
`nvitop` 的实时设备和进程视图。它调用机器已有的 `npu-smi`,不要求安装 CANN
|
|
24
|
+
Python SDK。
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
## 功能
|
|
30
|
+
|
|
31
|
+
- 每张 NPU 独占一行,AICore 利用率与 HBM 占用率均使用彩色进度条
|
|
32
|
+
- 展示 NPU 进程、用户、宿主机 CPU/内存、NPU 显存和完整命令行
|
|
33
|
+
- 固定高度可滚动视图,启动时位于顶部,支持键盘和鼠标滚轮
|
|
34
|
+
- 交互式刷新和按显存、CPU、PID 排序
|
|
35
|
+
- 支持单次文本输出和 JSON 输出,方便脚本、巡检与监控系统集成
|
|
36
|
+
- 无第三方运行时依赖;兼容容器里的宿主 PID 信息缺失场景
|
|
37
|
+
- 对不同 `npu-smi` 版本采用表头和字段语义解析,不依赖固定表格宽度
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
机器需要已安装并可运行 `npu-smi`。
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install nptop
|
|
45
|
+
nptop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
从 Git 仓库安装:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install git+https://github.com/ZYM-PKU/nptop.git
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
开发安装:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/ZYM-PKU/nptop.git
|
|
58
|
+
cd nptop
|
|
59
|
+
python -m pip install -e .
|
|
60
|
+
nptop
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 使用
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
nptop # 交互视图,每 2 秒刷新
|
|
67
|
+
nptop -i 1 # 每秒刷新(-i 是 interval 的短参数)
|
|
68
|
+
nptop -d 0,2,3 # 只看指定 NPU
|
|
69
|
+
nptop -1 # 输出一次后退出
|
|
70
|
+
nptop --json # JSON 快照
|
|
71
|
+
nptop --sort cpu # 进程按 CPU 排序
|
|
72
|
+
nptop --raw # 输出原始 npu-smi,便于兼容性排查
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
交互模式按键:方向键或 `j`/`k` 逐行滚动,`PageUp`/`PageDown` 翻页,
|
|
76
|
+
`Home`/`g` 返回顶部,`End`/`G` 到达底部,也支持鼠标滚轮。`q` 退出,`r`
|
|
77
|
+
立即刷新,`m` 按 NPU 显存排序,`c` 按 CPU 排序,`p` 按 PID 排序。也可以使用
|
|
78
|
+
`Ctrl-C` 退出。
|
|
79
|
+
|
|
80
|
+
完整参数见:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
nptop --help
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
如果 `npu-smi` 不在 `PATH`,可以显式指定:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
NPUTOP_NPU_SMI=/usr/local/Ascend/driver/tools/npu-smi nptop
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 容器说明
|
|
93
|
+
|
|
94
|
+
容器需要能够执行 `npu-smi`,并挂载相应的昇腾设备和驱动目录。`npu-smi`
|
|
95
|
+
返回的宿主机 PID 在容器 PID 命名空间内可能不可见;这时 USER、CPU、宿主机内存
|
|
96
|
+
和完整命令会显示为 `?`/`-`,NPU 指标不受影响。
|
|
97
|
+
|
|
98
|
+
## 开发与发布
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m pip install pytest build twine
|
|
102
|
+
pytest
|
|
103
|
+
python -m build
|
|
104
|
+
twine check dist/*
|
|
105
|
+
twine upload dist/*
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
发布前请把 `pyproject.toml` 中的 `project.urls` 替换成实际 Git 仓库地址。版本号在
|
|
109
|
+
`pyproject.toml` 与 `src/nputop/__init__.py` 中维护。
|
|
110
|
+
|
|
111
|
+
## 已知范围
|
|
112
|
+
|
|
113
|
+
当前后端针对华为昇腾 `npu-smi info`。不同驱动大版本如果改变了表格语义,可用
|
|
114
|
+
`nptop --raw` 保存输出并提交 issue。项目结构允许后续增加其他 NPU 厂商后端。
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/nptop.egg-info/PKG-INFO
|
|
5
|
+
src/nptop.egg-info/SOURCES.txt
|
|
6
|
+
src/nptop.egg-info/dependency_links.txt
|
|
7
|
+
src/nptop.egg-info/entry_points.txt
|
|
8
|
+
src/nptop.egg-info/top_level.txt
|
|
9
|
+
src/nputop/__init__.py
|
|
10
|
+
src/nputop/__main__.py
|
|
11
|
+
src/nputop/backend.py
|
|
12
|
+
src/nputop/cli.py
|
|
13
|
+
src/nputop/models.py
|
|
14
|
+
src/nputop/render.py
|
|
15
|
+
tests/test_backend.py
|
|
16
|
+
tests/test_cli.py
|
|
17
|
+
tests/test_render.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nputop
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
10
|
+
|
|
11
|
+
from .models import DeviceInfo, ProcessInfo, Snapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BackendError(RuntimeError):
|
|
15
|
+
"""The NPU metrics backend could not produce a snapshot."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _number(value: str, kind=float):
|
|
19
|
+
match = re.search(r"-?\d+(?:\.\d+)?", value)
|
|
20
|
+
return kind(float(match.group())) if match else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _memory_pairs(value: str) -> List[Tuple[int, int]]:
|
|
24
|
+
return [
|
|
25
|
+
(int(used), int(total))
|
|
26
|
+
for used, total in re.findall(r"(\d+)\s*/\s*(\d+)", value)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cells(line: str) -> List[str]:
|
|
31
|
+
if not line.lstrip().startswith("|"):
|
|
32
|
+
return []
|
|
33
|
+
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_npu_smi(output: str) -> Snapshot:
|
|
37
|
+
"""Parse the stable tabular surface of ``npu-smi info``.
|
|
38
|
+
|
|
39
|
+
Ascend releases have changed column widths and added columns over time. This
|
|
40
|
+
parser therefore keys off table headers and slash-separated memory values,
|
|
41
|
+
rather than relying on exact character offsets.
|
|
42
|
+
"""
|
|
43
|
+
version_match = re.search(r"npu-smi\s+([^\s|]+)", output, re.IGNORECASE)
|
|
44
|
+
snapshot = Snapshot(
|
|
45
|
+
driver_version=version_match.group(1) if version_match else "unknown",
|
|
46
|
+
timestamp=datetime.now(timezone.utc),
|
|
47
|
+
)
|
|
48
|
+
lines = output.splitlines()
|
|
49
|
+
device_header = next(
|
|
50
|
+
(i for i, line in enumerate(lines) if "NPU" in line and "Health" in line),
|
|
51
|
+
None,
|
|
52
|
+
)
|
|
53
|
+
process_header = next(
|
|
54
|
+
(
|
|
55
|
+
i
|
|
56
|
+
for i, line in enumerate(lines)
|
|
57
|
+
if "Process id" in line and "Process memory" in line
|
|
58
|
+
),
|
|
59
|
+
None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if device_header is not None:
|
|
63
|
+
end = process_header if process_header is not None else len(lines)
|
|
64
|
+
data_lines = []
|
|
65
|
+
for line in lines[device_header + 1 : end]:
|
|
66
|
+
cells = _cells(line)
|
|
67
|
+
if cells and not any("====" in cell or "----" in cell for cell in cells):
|
|
68
|
+
data_lines.append(cells)
|
|
69
|
+
i = 0
|
|
70
|
+
while i + 1 < len(data_lines):
|
|
71
|
+
first, second = data_lines[i], data_lines[i + 1]
|
|
72
|
+
identity = first[0].split() if first else []
|
|
73
|
+
detail_identity = second[0].split() if second else []
|
|
74
|
+
if len(identity) < 2 or not identity[0].isdigit():
|
|
75
|
+
i += 1
|
|
76
|
+
continue
|
|
77
|
+
index = int(identity[0])
|
|
78
|
+
chip = int(detail_identity[0]) if detail_identity and detail_identity[0].isdigit() else 0
|
|
79
|
+
summary = first[-1].split() if first else []
|
|
80
|
+
detail = second[-1] if second else ""
|
|
81
|
+
pairs = _memory_pairs(detail)
|
|
82
|
+
# On 910B, the final pair is HBM; on memory-only models it is the
|
|
83
|
+
# sole pair. Prefer the last pair with a non-zero capacity.
|
|
84
|
+
usable_pairs = [pair for pair in pairs if pair[1] > 0]
|
|
85
|
+
memory = usable_pairs[-1] if usable_pairs else (pairs[-1] if pairs else (None, None))
|
|
86
|
+
snapshot.devices.append(
|
|
87
|
+
DeviceInfo(
|
|
88
|
+
index=index,
|
|
89
|
+
chip=chip,
|
|
90
|
+
name=" ".join(identity[1:]),
|
|
91
|
+
health=first[1].split()[0] if len(first) > 1 and first[1] else "Unknown",
|
|
92
|
+
bus_id=second[1].split()[0] if len(second) > 1 and second[1] else "-",
|
|
93
|
+
power_w=_number(summary[0]) if len(summary) > 0 else None,
|
|
94
|
+
temperature_c=_number(summary[1]) if len(summary) > 1 else None,
|
|
95
|
+
utilization_percent=_number(detail.split()[0]) if detail.split() else None,
|
|
96
|
+
memory_used_mb=memory[0],
|
|
97
|
+
memory_total_mb=memory[1],
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
i += 2
|
|
101
|
+
|
|
102
|
+
if process_header is not None:
|
|
103
|
+
for line in lines[process_header + 1 :]:
|
|
104
|
+
cells = _cells(line)
|
|
105
|
+
if len(cells) < 4 or "No running processes" in line:
|
|
106
|
+
continue
|
|
107
|
+
identity = cells[0].split()
|
|
108
|
+
if len(identity) < 2 or not all(value.isdigit() for value in identity[:2]):
|
|
109
|
+
continue
|
|
110
|
+
pid = _number(cells[1], int)
|
|
111
|
+
if pid is None:
|
|
112
|
+
continue
|
|
113
|
+
container_pid = _number(cells[4], int) if len(cells) > 4 else None
|
|
114
|
+
snapshot.processes.append(
|
|
115
|
+
ProcessInfo(
|
|
116
|
+
device_index=int(identity[0]),
|
|
117
|
+
chip=int(identity[1]),
|
|
118
|
+
pid=pid,
|
|
119
|
+
name=cells[2] or "?",
|
|
120
|
+
npu_memory_mb=_number(cells[3], int),
|
|
121
|
+
container_pid=container_pid,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return snapshot
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ProcfsEnricher:
|
|
128
|
+
def __init__(self, proc_root: Path = Path("/proc")) -> None:
|
|
129
|
+
self.proc_root = proc_root
|
|
130
|
+
self._ticks = os.sysconf("SC_CLK_TCK")
|
|
131
|
+
self._previous = {} # pid -> process CPU ticks
|
|
132
|
+
|
|
133
|
+
def enrich(self, processes: Iterable[ProcessInfo], elapsed: Optional[float]) -> None:
|
|
134
|
+
current = {}
|
|
135
|
+
for process in processes:
|
|
136
|
+
root = self.proc_root / str(process.pid)
|
|
137
|
+
try:
|
|
138
|
+
# /proc/<pid>/stat encloses comm in parentheses; comm itself may
|
|
139
|
+
# contain spaces, so a plain split would shift utime/stime.
|
|
140
|
+
stat_text = (root / "stat").read_text(errors="replace")
|
|
141
|
+
closing_paren = stat_text.rfind(")")
|
|
142
|
+
stat = stat_text[closing_paren + 2 :].split()
|
|
143
|
+
total_ticks = int(stat[11]) + int(stat[12])
|
|
144
|
+
current[process.pid] = total_ticks
|
|
145
|
+
old = self._previous.get(process.pid)
|
|
146
|
+
if old is not None and elapsed and elapsed > 0:
|
|
147
|
+
process.cpu_percent = max(0.0, (total_ticks - old) / self._ticks / elapsed * 100)
|
|
148
|
+
except (OSError, ValueError, IndexError):
|
|
149
|
+
pass
|
|
150
|
+
try:
|
|
151
|
+
fields = {}
|
|
152
|
+
for line in (root / "status").read_text(errors="replace").splitlines():
|
|
153
|
+
if ":" in line:
|
|
154
|
+
key, value = line.split(":", 1)
|
|
155
|
+
fields[key] = value.strip()
|
|
156
|
+
uid = int(fields.get("Uid", "-1").split()[0])
|
|
157
|
+
try:
|
|
158
|
+
import pwd
|
|
159
|
+
|
|
160
|
+
process.username = pwd.getpwuid(uid).pw_name
|
|
161
|
+
except (KeyError, ImportError):
|
|
162
|
+
process.username = str(uid)
|
|
163
|
+
rss = _number(fields.get("VmRSS", ""))
|
|
164
|
+
process.host_memory_mb = rss / 1024 if rss is not None else None
|
|
165
|
+
except (OSError, ValueError, IndexError):
|
|
166
|
+
pass
|
|
167
|
+
try:
|
|
168
|
+
command = (root / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace").strip()
|
|
169
|
+
process.command = command or process.name
|
|
170
|
+
except OSError:
|
|
171
|
+
process.command = process.name
|
|
172
|
+
self._previous = current
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class NpuSmiBackend:
|
|
176
|
+
def __init__(self, executable: Optional[str] = None, timeout: float = 5.0) -> None:
|
|
177
|
+
self.executable = executable or os.environ.get("NPUTOP_NPU_SMI") or shutil.which("npu-smi")
|
|
178
|
+
self.timeout = timeout
|
|
179
|
+
self.enricher = ProcfsEnricher()
|
|
180
|
+
self._last_time = None
|
|
181
|
+
|
|
182
|
+
def raw(self) -> str:
|
|
183
|
+
if not self.executable:
|
|
184
|
+
raise BackendError("npu-smi was not found in PATH (or set NPUTOP_NPU_SMI)")
|
|
185
|
+
try:
|
|
186
|
+
result = subprocess.run(
|
|
187
|
+
[self.executable, "info"],
|
|
188
|
+
check=False,
|
|
189
|
+
stdout=subprocess.PIPE,
|
|
190
|
+
stderr=subprocess.PIPE,
|
|
191
|
+
text=True,
|
|
192
|
+
timeout=self.timeout,
|
|
193
|
+
env={**os.environ, "LC_ALL": "C"},
|
|
194
|
+
)
|
|
195
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
196
|
+
raise BackendError(f"failed to run npu-smi: {exc}") from exc
|
|
197
|
+
if result.returncode != 0:
|
|
198
|
+
detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
|
|
199
|
+
raise BackendError(f"npu-smi failed: {detail}")
|
|
200
|
+
return result.stdout
|
|
201
|
+
|
|
202
|
+
def snapshot(self) -> Snapshot:
|
|
203
|
+
now = datetime.now(timezone.utc)
|
|
204
|
+
snapshot = parse_npu_smi(self.raw())
|
|
205
|
+
if not snapshot.devices:
|
|
206
|
+
raise BackendError("npu-smi returned no recognizable NPU devices")
|
|
207
|
+
elapsed = (now - self._last_time).total_seconds() if self._last_time else None
|
|
208
|
+
self.enricher.enrich(snapshot.processes, elapsed)
|
|
209
|
+
self._last_time = now
|
|
210
|
+
return snapshot
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def select_devices(snapshot: Snapshot, indexes: Optional[Sequence[int]]) -> Snapshot:
|
|
214
|
+
if indexes is None:
|
|
215
|
+
return snapshot
|
|
216
|
+
selected = set(indexes)
|
|
217
|
+
snapshot.devices = [device for device in snapshot.devices if device.index in selected]
|
|
218
|
+
snapshot.processes = [process for process in snapshot.processes if process.device_index in selected]
|
|
219
|
+
return snapshot
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import select
|
|
7
|
+
import signal
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import termios
|
|
11
|
+
import time
|
|
12
|
+
import tty
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import Iterator, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .backend import BackendError, NpuSmiBackend, select_devices
|
|
18
|
+
from .render import render, render_screen
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _device_list(value: str):
|
|
22
|
+
try:
|
|
23
|
+
result = [int(item.strip()) for item in value.split(",") if item.strip()]
|
|
24
|
+
except ValueError as exc:
|
|
25
|
+
raise argparse.ArgumentTypeError("devices must be comma-separated integers") from exc
|
|
26
|
+
if not result or any(item < 0 for item in result):
|
|
27
|
+
raise argparse.ArgumentTypeError("at least one non-negative device index is required")
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="nptop",
|
|
34
|
+
description="An nvitop-like interactive monitor for Ascend NPUs.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("-1", "--once", action="store_true", help="print one snapshot and exit")
|
|
37
|
+
parser.add_argument("-i", "--interval", type=float, default=2.0, help="refresh interval in seconds (default: 2)")
|
|
38
|
+
parser.add_argument("-d", "--device", type=_device_list, help="device indexes, for example 0,1,4")
|
|
39
|
+
parser.add_argument("--sort", choices=("memory", "cpu", "pid"), default="memory", help="process sort key")
|
|
40
|
+
parser.add_argument("--json", action="store_true", help="emit one JSON snapshot")
|
|
41
|
+
parser.add_argument("--raw", action="store_true", help="print raw npu-smi output for diagnostics")
|
|
42
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
|
43
|
+
parser.add_argument("--timeout", type=float, default=5.0, help="npu-smi timeout in seconds")
|
|
44
|
+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def _interactive_terminal(enabled: bool) -> Iterator[None]:
|
|
50
|
+
if not enabled:
|
|
51
|
+
yield
|
|
52
|
+
return
|
|
53
|
+
fd = sys.stdin.fileno()
|
|
54
|
+
previous = termios.tcgetattr(fd)
|
|
55
|
+
try:
|
|
56
|
+
tty.setcbreak(fd)
|
|
57
|
+
# Alternate screen, hidden cursor, and SGR mouse tracking. Wheel events
|
|
58
|
+
# then move our viewport instead of the terminal's scrollback buffer.
|
|
59
|
+
sys.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h")
|
|
60
|
+
sys.stdout.flush()
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, previous)
|
|
64
|
+
sys.stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_key(timeout: float) -> Optional[str]:
|
|
69
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
70
|
+
if not ready:
|
|
71
|
+
return None
|
|
72
|
+
fd = sys.stdin.fileno()
|
|
73
|
+
sequence = os.read(fd, 64).decode(errors="ignore")
|
|
74
|
+
if not sequence.startswith("\x1b"):
|
|
75
|
+
return sequence
|
|
76
|
+
# Escape sequences arrive as a short burst in cbreak mode. Collect that
|
|
77
|
+
# burst without making a lone Escape key block the interface.
|
|
78
|
+
while sequence == "\x1b" or sequence.endswith(("[", ";")):
|
|
79
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.01)
|
|
80
|
+
if not ready:
|
|
81
|
+
break
|
|
82
|
+
sequence += os.read(fd, 64).decode(errors="ignore")
|
|
83
|
+
mapping = {
|
|
84
|
+
"\x1b[A": "up",
|
|
85
|
+
"\x1b[B": "down",
|
|
86
|
+
"\x1b[5~": "pageup",
|
|
87
|
+
"\x1b[6~": "pagedown",
|
|
88
|
+
"\x1b[H": "home",
|
|
89
|
+
"\x1b[F": "end",
|
|
90
|
+
"\x1bOH": "home",
|
|
91
|
+
"\x1bOF": "end",
|
|
92
|
+
}
|
|
93
|
+
if sequence in mapping:
|
|
94
|
+
return mapping[sequence]
|
|
95
|
+
if sequence.startswith("\x1b[<64;"):
|
|
96
|
+
return "wheelup"
|
|
97
|
+
if sequence.startswith("\x1b[<65;"):
|
|
98
|
+
return "wheeldown"
|
|
99
|
+
return "escape"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run(argv: Optional[Sequence[str]] = None) -> int:
|
|
103
|
+
args = build_parser().parse_args(argv)
|
|
104
|
+
if args.interval <= 0:
|
|
105
|
+
raise SystemExit("nptop: --interval must be greater than zero")
|
|
106
|
+
if args.timeout <= 0:
|
|
107
|
+
raise SystemExit("nptop: --timeout must be greater than zero")
|
|
108
|
+
backend = NpuSmiBackend(timeout=args.timeout)
|
|
109
|
+
if args.raw:
|
|
110
|
+
try:
|
|
111
|
+
sys.stdout.write(backend.raw())
|
|
112
|
+
return 0
|
|
113
|
+
except BackendError as exc:
|
|
114
|
+
print(f"nptop: {exc}", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
one_shot = args.once or args.json or not sys.stdout.isatty()
|
|
118
|
+
interactive = not one_shot and sys.stdin.isatty()
|
|
119
|
+
color = not args.no_color and "NO_COLOR" not in os.environ and (sys.stdout.isatty() or not one_shot)
|
|
120
|
+
sort = args.sort
|
|
121
|
+
scroll = 0
|
|
122
|
+
try:
|
|
123
|
+
with _interactive_terminal(interactive):
|
|
124
|
+
snapshot = select_devices(backend.snapshot(), args.device)
|
|
125
|
+
next_refresh = time.monotonic() + args.interval
|
|
126
|
+
while True:
|
|
127
|
+
if args.json:
|
|
128
|
+
json.dump(snapshot.to_dict(), sys.stdout, ensure_ascii=False, indent=2)
|
|
129
|
+
sys.stdout.write("\n")
|
|
130
|
+
return 0
|
|
131
|
+
terminal = shutil.get_terminal_size((100, 24))
|
|
132
|
+
if one_shot:
|
|
133
|
+
screen = render(snapshot, color=color, sort=sort, width=terminal.columns)
|
|
134
|
+
print(screen)
|
|
135
|
+
return 0
|
|
136
|
+
screen, max_scroll = render_screen(
|
|
137
|
+
snapshot,
|
|
138
|
+
color=color,
|
|
139
|
+
sort=sort,
|
|
140
|
+
width=terminal.columns,
|
|
141
|
+
height=terminal.lines,
|
|
142
|
+
scroll=scroll,
|
|
143
|
+
)
|
|
144
|
+
scroll = min(scroll, max_scroll)
|
|
145
|
+
sys.stdout.write("\x1b[H\x1b[2J" + screen)
|
|
146
|
+
sys.stdout.flush()
|
|
147
|
+
wait = max(0.0, next_refresh - time.monotonic())
|
|
148
|
+
key = _read_key(wait) if interactive else None
|
|
149
|
+
if key is None or key.lower() == "r":
|
|
150
|
+
snapshot = select_devices(backend.snapshot(), args.device)
|
|
151
|
+
next_refresh = time.monotonic() + args.interval
|
|
152
|
+
continue
|
|
153
|
+
page = max(1, terminal.lines - 5)
|
|
154
|
+
if key.lower() == "q":
|
|
155
|
+
return 0
|
|
156
|
+
if key in {"down", "j", "J", "wheeldown"}:
|
|
157
|
+
scroll = min(max_scroll, scroll + (3 if key == "wheeldown" else 1))
|
|
158
|
+
elif key in {"up", "k", "K", "wheelup"}:
|
|
159
|
+
scroll = max(0, scroll - (3 if key == "wheelup" else 1))
|
|
160
|
+
elif key in {"pagedown", " ", "f", "F"}:
|
|
161
|
+
scroll = min(max_scroll, scroll + page)
|
|
162
|
+
elif key in {"pageup", "b", "B"}:
|
|
163
|
+
scroll = max(0, scroll - page)
|
|
164
|
+
elif key in {"home", "g"}:
|
|
165
|
+
scroll = 0
|
|
166
|
+
elif key in {"end", "G"}:
|
|
167
|
+
scroll = max_scroll
|
|
168
|
+
elif key.lower() == "m":
|
|
169
|
+
sort = "memory"
|
|
170
|
+
elif key.lower() == "c":
|
|
171
|
+
sort = "cpu"
|
|
172
|
+
elif key.lower() == "p":
|
|
173
|
+
sort = "pid"
|
|
174
|
+
except BackendError as exc:
|
|
175
|
+
print(f"nptop: {exc}", file=sys.stderr)
|
|
176
|
+
return 1
|
|
177
|
+
except KeyboardInterrupt:
|
|
178
|
+
return 130
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> int:
|
|
182
|
+
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
|
183
|
+
return run()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class DeviceInfo:
|
|
10
|
+
index: int
|
|
11
|
+
chip: int = 0
|
|
12
|
+
name: str = "NPU"
|
|
13
|
+
health: str = "Unknown"
|
|
14
|
+
bus_id: str = "-"
|
|
15
|
+
power_w: Optional[float] = None
|
|
16
|
+
temperature_c: Optional[float] = None
|
|
17
|
+
utilization_percent: Optional[float] = None
|
|
18
|
+
memory_used_mb: Optional[int] = None
|
|
19
|
+
memory_total_mb: Optional[int] = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def memory_percent(self) -> Optional[float]:
|
|
23
|
+
if not self.memory_total_mb:
|
|
24
|
+
return None
|
|
25
|
+
return 100.0 * (self.memory_used_mb or 0) / self.memory_total_mb
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ProcessInfo:
|
|
30
|
+
device_index: int
|
|
31
|
+
chip: int
|
|
32
|
+
pid: int
|
|
33
|
+
name: str
|
|
34
|
+
npu_memory_mb: Optional[int] = None
|
|
35
|
+
container_pid: Optional[int] = None
|
|
36
|
+
username: str = "?"
|
|
37
|
+
cpu_percent: Optional[float] = None
|
|
38
|
+
host_memory_mb: Optional[float] = None
|
|
39
|
+
command: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Snapshot:
|
|
44
|
+
devices: List[DeviceInfo] = field(default_factory=list)
|
|
45
|
+
processes: List[ProcessInfo] = field(default_factory=list)
|
|
46
|
+
driver_version: str = "unknown"
|
|
47
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
50
|
+
result = asdict(self)
|
|
51
|
+
result["timestamp"] = self.timestamp.isoformat()
|
|
52
|
+
for device, raw in zip(self.devices, result["devices"]):
|
|
53
|
+
raw["memory_percent"] = device.memory_percent
|
|
54
|
+
return result
|
|
55
|
+
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import socket
|
|
5
|
+
from typing import Iterable, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .models import DeviceInfo, ProcessInfo, Snapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RESET = "\x1b[0m"
|
|
11
|
+
COLORS = {
|
|
12
|
+
"dim": "\x1b[2m",
|
|
13
|
+
"bold": "\x1b[1m",
|
|
14
|
+
"cyan": "\x1b[36m",
|
|
15
|
+
"green": "\x1b[32m",
|
|
16
|
+
"yellow": "\x1b[33m",
|
|
17
|
+
"red": "\x1b[31m",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _paint(text: str, color: str, enabled: bool) -> str:
|
|
22
|
+
return f"{COLORS[color]}{text}{RESET}" if enabled else text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _value(value, suffix: str = "", decimals: int = 0) -> str:
|
|
26
|
+
if value is None:
|
|
27
|
+
return "-"
|
|
28
|
+
return f"{value:.{decimals}f}{suffix}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _clip(value: str, width: int) -> str:
|
|
32
|
+
if len(value) <= width:
|
|
33
|
+
return value
|
|
34
|
+
return value[: max(0, width - 1)] + "…"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bar(percent: Optional[float], width: int, color: bool) -> str:
|
|
38
|
+
if percent is None:
|
|
39
|
+
return _paint("·" * width, "dim", color)
|
|
40
|
+
value = min(100.0, max(0.0, percent))
|
|
41
|
+
filled = round(value * width / 100)
|
|
42
|
+
text = "█" * filled + "░" * (width - filled)
|
|
43
|
+
level = "red" if value >= 90 else "yellow" if value >= 70 else "green"
|
|
44
|
+
return _paint(text, level, color)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _memory_gib(device: DeviceInfo) -> str:
|
|
48
|
+
if device.memory_used_mb is None or device.memory_total_mb is None:
|
|
49
|
+
return "-"
|
|
50
|
+
return f"{device.memory_used_mb / 1024:.1f}/{device.memory_total_mb / 1024:.0f}G"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _device_line(device: DeviceInfo, color: bool, width: int) -> str:
|
|
54
|
+
utilization = device.utilization_percent
|
|
55
|
+
memory_percent = device.memory_percent
|
|
56
|
+
health_color = "green" if device.health.upper() in {"OK", "NORMAL"} else "red"
|
|
57
|
+
health = _paint(f"{_clip(device.health, 4):<4}", health_color, color)
|
|
58
|
+
|
|
59
|
+
if width < 72:
|
|
60
|
+
# Both meters remain visible even on narrow terminals. Secondary fields
|
|
61
|
+
# are omitted before either requested progress bar is removed.
|
|
62
|
+
bar_width = max(3, min(7, (width - 31) // 2))
|
|
63
|
+
return (
|
|
64
|
+
f" #{device.index:<2} A {_bar(utilization, bar_width, color)} {_value(utilization, '%'):>4} "
|
|
65
|
+
f"M {_bar(memory_percent, bar_width, color)} {_value(memory_percent, '%'):>4} "
|
|
66
|
+
f"{_memory_gib(device):>9}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
bar_width = 12 if width >= 110 else 7 if width >= 86 else 5
|
|
70
|
+
name_width = 12 if width >= 100 else 8
|
|
71
|
+
return (
|
|
72
|
+
f" {device.index:>2} {_clip(device.name, name_width):<{name_width}} {health} "
|
|
73
|
+
f"{_value(device.temperature_c):>3}C {_value(device.power_w, '', 0):>4}W "
|
|
74
|
+
f"AIC {_bar(utilization, bar_width, color)} {_value(utilization, '%'):>4} "
|
|
75
|
+
f"MEM {_bar(memory_percent, bar_width, color)} {_value(memory_percent, '%'):>4} "
|
|
76
|
+
f"{_memory_gib(device):>9}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _sort_processes(processes: Iterable[ProcessInfo], key: str) -> List[ProcessInfo]:
|
|
81
|
+
if key == "pid":
|
|
82
|
+
return sorted(processes, key=lambda process: process.pid)
|
|
83
|
+
if key == "cpu":
|
|
84
|
+
return sorted(processes, key=lambda process: process.cpu_percent or -1, reverse=True)
|
|
85
|
+
return sorted(processes, key=lambda process: process.npu_memory_mb or -1, reverse=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _content_lines(snapshot: Snapshot, color: bool, sort: str, width: int) -> List[str]:
|
|
89
|
+
device_heading = " NPU DEVICE STAT TEMP POWER AICORE USAGE MEMORY USAGE USED/TOTAL"
|
|
90
|
+
lines = [
|
|
91
|
+
_paint(_clip(device_heading, width), "cyan", color)
|
|
92
|
+
]
|
|
93
|
+
lines.extend(_device_line(device, color, width) for device in snapshot.devices)
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append(_paint(f" Processes ({len(snapshot.processes)}) sort={sort}", "bold", color))
|
|
96
|
+
process_heading = " NPU PID USER CPU% HOST MEM NPU MEM COMMAND"
|
|
97
|
+
lines.append(_paint(_clip(process_heading, width), "cyan", color))
|
|
98
|
+
command_width = max(8, width - 62)
|
|
99
|
+
for process in _sort_processes(snapshot.processes, sort):
|
|
100
|
+
command = process.command or process.name
|
|
101
|
+
lines.append(
|
|
102
|
+
f" {process.device_index:>3} {process.pid:>8} {_clip(process.username, 12):<12} "
|
|
103
|
+
f"{_value(process.cpu_percent, '', 1):>6} "
|
|
104
|
+
f"{_value(process.host_memory_mb, 'M', 1):>9} "
|
|
105
|
+
f"{_value(process.npu_memory_mb, 'M'):>8} {_clip(command, command_width)}"
|
|
106
|
+
)
|
|
107
|
+
if not snapshot.processes:
|
|
108
|
+
lines.append(_paint(" No running NPU processes", "dim", color))
|
|
109
|
+
return lines
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _header(snapshot: Snapshot, color: bool, width: int) -> List[str]:
|
|
113
|
+
title = f"nptop {socket.gethostname()} npu-smi {snapshot.driver_version}"
|
|
114
|
+
stamp = snapshot.timestamp.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
115
|
+
if len(title) + len(stamp) + 1 > width:
|
|
116
|
+
title = _clip(title, max(8, width - len(stamp) - 1))
|
|
117
|
+
gap = max(1, width - len(title) - len(stamp))
|
|
118
|
+
return [
|
|
119
|
+
_paint(title, "bold", color) + " " * gap + _paint(stamp, "dim", color),
|
|
120
|
+
"─" * width,
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def render_screen(
|
|
125
|
+
snapshot: Snapshot,
|
|
126
|
+
*,
|
|
127
|
+
color: bool = True,
|
|
128
|
+
sort: str = "memory",
|
|
129
|
+
width: int = 100,
|
|
130
|
+
height: int = 24,
|
|
131
|
+
scroll: int = 0,
|
|
132
|
+
) -> Tuple[str, int]:
|
|
133
|
+
"""Render one fixed terminal frame and return it with its max scroll offset."""
|
|
134
|
+
width = max(40, width)
|
|
135
|
+
height = max(6, height)
|
|
136
|
+
content = _content_lines(snapshot, color, sort, width)
|
|
137
|
+
viewport_height = height - 3 # two header rows and one status/footer row
|
|
138
|
+
max_scroll = max(0, len(content) - viewport_height)
|
|
139
|
+
scroll = min(max(0, scroll), max_scroll)
|
|
140
|
+
visible = content[scroll : scroll + viewport_height]
|
|
141
|
+
visible.extend([""] * (viewport_height - len(visible)))
|
|
142
|
+
first = scroll + 1 if content else 0
|
|
143
|
+
last = min(scroll + viewport_height, len(content))
|
|
144
|
+
keys = "↑/k ↓/j scroll PgUp/PgDn Home/End r refresh m/c/p sort q quit"
|
|
145
|
+
position = f"[{first}-{last}/{len(content)}]"
|
|
146
|
+
footer_space = max(1, width - len(position) - 1)
|
|
147
|
+
visible_keys = _clip(keys, footer_space)
|
|
148
|
+
footer = _paint(visible_keys, "dim", color)
|
|
149
|
+
footer += " " * max(1, width - len(visible_keys) - len(position)) + position
|
|
150
|
+
return "\n".join(_header(snapshot, color, width) + visible + [footer]), max_scroll
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render(
|
|
154
|
+
snapshot: Snapshot,
|
|
155
|
+
*,
|
|
156
|
+
color: bool = True,
|
|
157
|
+
sort: str = "memory",
|
|
158
|
+
width: Optional[int] = None,
|
|
159
|
+
height: Optional[int] = None,
|
|
160
|
+
scroll: int = 0,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Render a full report, or a scrollable frame when ``height`` is provided."""
|
|
163
|
+
width = max(40, width or shutil.get_terminal_size((100, 24)).columns)
|
|
164
|
+
if height is not None:
|
|
165
|
+
return render_screen(
|
|
166
|
+
snapshot,
|
|
167
|
+
color=color,
|
|
168
|
+
sort=sort,
|
|
169
|
+
width=width,
|
|
170
|
+
height=height,
|
|
171
|
+
scroll=scroll,
|
|
172
|
+
)[0]
|
|
173
|
+
content = _content_lines(snapshot, color, sort, width)
|
|
174
|
+
return "\n".join(_header(snapshot, color, width) + content)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from nputop.backend import ProcfsEnricher, parse_npu_smi, select_devices
|
|
4
|
+
from nputop.models import ProcessInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
FIXTURE = Path(__file__).parent / "fixtures" / "npu_smi_910b.txt"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_parse_devices_and_processes():
|
|
11
|
+
snapshot = parse_npu_smi(FIXTURE.read_text())
|
|
12
|
+
|
|
13
|
+
assert snapshot.driver_version == "26.0.rc2"
|
|
14
|
+
assert len(snapshot.devices) == 2
|
|
15
|
+
first = snapshot.devices[0]
|
|
16
|
+
assert (first.index, first.chip, first.name) == (0, 0, "910B2C")
|
|
17
|
+
assert first.health == "OK"
|
|
18
|
+
assert first.power_w == 92.1
|
|
19
|
+
assert first.temperature_c == 32
|
|
20
|
+
assert first.utilization_percent == 17
|
|
21
|
+
assert (first.memory_used_mb, first.memory_total_mb) == (3419, 65536)
|
|
22
|
+
|
|
23
|
+
process = snapshot.processes[0]
|
|
24
|
+
assert (process.device_index, process.pid, process.name) == (0, 12345, "python3")
|
|
25
|
+
assert process.npu_memory_mb == 2048
|
|
26
|
+
assert process.container_pid == 99
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_select_devices_also_filters_processes():
|
|
30
|
+
snapshot = select_devices(parse_npu_smi(FIXTURE.read_text()), [1])
|
|
31
|
+
assert [device.index for device in snapshot.devices] == [1]
|
|
32
|
+
assert snapshot.processes == []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_procfs_handles_process_names_with_spaces(tmp_path):
|
|
36
|
+
proc = tmp_path / "42"
|
|
37
|
+
proc.mkdir()
|
|
38
|
+
# Fields after comm begin at state (field 3); utime/stime are offsets 11/12.
|
|
39
|
+
proc.joinpath("stat").write_text(
|
|
40
|
+
"42 (worker with spaces) S 0 0 0 0 0 0 0 0 0 0 100 50 0 0 0 0 0 0 0\n"
|
|
41
|
+
)
|
|
42
|
+
proc.joinpath("status").write_text("Uid:\t0 0 0 0\nVmRSS:\t2048 kB\n")
|
|
43
|
+
proc.joinpath("cmdline").write_bytes(b"python\0train.py\0")
|
|
44
|
+
process = ProcessInfo(device_index=0, chip=0, pid=42, name="worker")
|
|
45
|
+
|
|
46
|
+
enricher = ProcfsEnricher(tmp_path)
|
|
47
|
+
enricher.enrich([process], elapsed=None)
|
|
48
|
+
proc.joinpath("stat").write_text(
|
|
49
|
+
"42 (worker with spaces) S 0 0 0 0 0 0 0 0 0 0 200 100 0 0 0 0 0 0 0\n"
|
|
50
|
+
)
|
|
51
|
+
enricher.enrich([process], elapsed=1.0)
|
|
52
|
+
|
|
53
|
+
assert process.cpu_percent is not None
|
|
54
|
+
assert process.cpu_percent > 0
|
|
55
|
+
assert process.host_memory_mb == 2
|
|
56
|
+
assert process.command == "python train.py"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from nputop.cli import run
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_json_mode_with_fake_npu_smi(tmp_path, monkeypatch, capsys):
|
|
8
|
+
fixture = Path(__file__).parent / "fixtures" / "npu_smi_910b.txt"
|
|
9
|
+
executable = tmp_path / "npu-smi"
|
|
10
|
+
executable.write_text("#!/bin/sh\nexec /bin/cat \"$NPUTOP_FIXTURE\"\n")
|
|
11
|
+
executable.chmod(0o755)
|
|
12
|
+
monkeypatch.setenv("NPUTOP_NPU_SMI", str(executable))
|
|
13
|
+
monkeypatch.setenv("NPUTOP_FIXTURE", str(fixture))
|
|
14
|
+
|
|
15
|
+
assert run(["--json", "--device", "0"]) == 0
|
|
16
|
+
result = json.loads(capsys.readouterr().out)
|
|
17
|
+
assert [item["index"] for item in result["devices"]] == [0]
|
|
18
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from nputop.backend import parse_npu_smi
|
|
4
|
+
from nputop.models import DeviceInfo, ProcessInfo
|
|
5
|
+
from nputop.render import render, render_screen
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_plain_render_contains_device_and_process():
|
|
9
|
+
fixture = Path(__file__).parent / "fixtures" / "npu_smi_910b.txt"
|
|
10
|
+
output = render(parse_npu_smi(fixture.read_text()), color=False, width=100)
|
|
11
|
+
assert "nptop" in output
|
|
12
|
+
assert "910B2C" in output
|
|
13
|
+
assert "3.3/64G" in output
|
|
14
|
+
assert "AIC" in output
|
|
15
|
+
assert "MEM" in output
|
|
16
|
+
assert "█" in output
|
|
17
|
+
assert "12345" in output
|
|
18
|
+
assert "python3" in output
|
|
19
|
+
assert "\x1b[" not in output
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_sixteen_devices_start_at_top_and_processes_can_scroll():
|
|
23
|
+
fixture = Path(__file__).parent / "fixtures" / "npu_smi_910b.txt"
|
|
24
|
+
snapshot = parse_npu_smi(fixture.read_text())
|
|
25
|
+
template = snapshot.devices[0]
|
|
26
|
+
snapshot.devices = [
|
|
27
|
+
DeviceInfo(
|
|
28
|
+
index=index,
|
|
29
|
+
chip=template.chip,
|
|
30
|
+
name=template.name,
|
|
31
|
+
health=template.health,
|
|
32
|
+
power_w=template.power_w,
|
|
33
|
+
temperature_c=template.temperature_c,
|
|
34
|
+
utilization_percent=template.utilization_percent,
|
|
35
|
+
memory_used_mb=template.memory_used_mb,
|
|
36
|
+
memory_total_mb=template.memory_total_mb,
|
|
37
|
+
)
|
|
38
|
+
for index in range(16)
|
|
39
|
+
]
|
|
40
|
+
snapshot.processes = [
|
|
41
|
+
ProcessInfo(
|
|
42
|
+
device_index=index,
|
|
43
|
+
chip=0,
|
|
44
|
+
pid=1000 + index,
|
|
45
|
+
name="python",
|
|
46
|
+
npu_memory_mb=2048,
|
|
47
|
+
username="tester",
|
|
48
|
+
command=f"python train.py --rank {index}",
|
|
49
|
+
)
|
|
50
|
+
for index in range(16)
|
|
51
|
+
]
|
|
52
|
+
top, max_scroll = render_screen(snapshot, color=False, width=80, height=24, scroll=0)
|
|
53
|
+
bottom, _ = render_screen(snapshot, color=False, width=80, height=24, scroll=max_scroll)
|
|
54
|
+
|
|
55
|
+
assert len(top.splitlines()) == 24
|
|
56
|
+
assert max(map(len, top.splitlines())) <= 80
|
|
57
|
+
assert max_scroll > 0
|
|
58
|
+
for index in range(16):
|
|
59
|
+
assert f" {index:>2} 910B2C" in top
|
|
60
|
+
assert " 0 910B2C" not in bottom
|
|
61
|
+
assert "1015" in bottom
|
|
62
|
+
assert "[1-21/36]" in top
|
|
63
|
+
assert "q quit" in top
|