memtaskman 0.1.0__py3-none-any.whl
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.
- memtaskman/__init__.py +3 -0
- memtaskman/__main__.py +3 -0
- memtaskman/app.py +505 -0
- memtaskman/cli.py +135 -0
- memtaskman/collector.py +383 -0
- memtaskman/diff.py +58 -0
- memtaskman/fmt.py +73 -0
- memtaskman/linux.py +198 -0
- memtaskman/model.py +136 -0
- memtaskman/report.py +85 -0
- memtaskman/runtime.py +224 -0
- memtaskman/win32.py +390 -0
- memtaskman-0.1.0.dist-info/METADATA +186 -0
- memtaskman-0.1.0.dist-info/RECORD +17 -0
- memtaskman-0.1.0.dist-info/WHEEL +4 -0
- memtaskman-0.1.0.dist-info/entry_points.txt +3 -0
- memtaskman-0.1.0.dist-info/licenses/LICENSE +21 -0
memtaskman/collector.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Sampling: turns OS sources into Snapshots, tracks rates and runtime identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
from memtaskman import runtime as rt
|
|
13
|
+
from memtaskman.model import (
|
|
14
|
+
CATEGORY_ORDER,
|
|
15
|
+
HINT,
|
|
16
|
+
Inspection,
|
|
17
|
+
ProcessMem,
|
|
18
|
+
Snapshot,
|
|
19
|
+
SystemMemory,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
WINDOWS = sys.platform == "win32"
|
|
23
|
+
LINUX = sys.platform.startswith("linux")
|
|
24
|
+
|
|
25
|
+
if WINDOWS:
|
|
26
|
+
from memtaskman import win32 as native
|
|
27
|
+
elif LINUX:
|
|
28
|
+
from memtaskman import linux as native
|
|
29
|
+
else: # macOS & others: psutil only
|
|
30
|
+
native = None
|
|
31
|
+
|
|
32
|
+
GROWTH_ALPHA = 0.35 # EMA weight for the private-bytes growth rate
|
|
33
|
+
RUNTIME_TTL = 45.0 # seconds before re-checking a process's loaded modules
|
|
34
|
+
PSS_TTL = 2.0 # smaps_rollup makes the kernel walk page tables; refresh visible rows at most this often
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def private_label() -> str:
|
|
38
|
+
if WINDOWS:
|
|
39
|
+
return "Private" # private commit charge (Task Manager "Commit size")
|
|
40
|
+
if LINUX:
|
|
41
|
+
return "Anon" # resident anonymous memory (RssAnon)
|
|
42
|
+
return "Private"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _Track:
|
|
47
|
+
t: float
|
|
48
|
+
private: int
|
|
49
|
+
faults: int | None
|
|
50
|
+
growth: float | None = None
|
|
51
|
+
faults_ps: float | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _RuntimeEntry:
|
|
56
|
+
info: rt.RuntimeInfo | None
|
|
57
|
+
cmdline: list[str] = field(default_factory=list)
|
|
58
|
+
checked: float = 0.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Collector:
|
|
62
|
+
def __init__(self, system: bool = True):
|
|
63
|
+
self.want_system = system
|
|
64
|
+
self._pdh = native.PdhQuery() if WINDOWS and system else None
|
|
65
|
+
self._tracks: dict[tuple[int, float], _Track] = {}
|
|
66
|
+
self._runtimes: dict[tuple[int, float], _RuntimeEntry] = {}
|
|
67
|
+
self._pss: dict[tuple[int, float], tuple[float, int, int]] = {} # key -> (checked, pss, uss)
|
|
68
|
+
self._vm_prev: tuple[float, dict[str, int]] | None = None
|
|
69
|
+
self._first = True
|
|
70
|
+
self._lock = threading.RLock() # sampling and inspection run on different threads
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------ snapshot
|
|
73
|
+
def sample(self) -> Snapshot:
|
|
74
|
+
with self._lock:
|
|
75
|
+
return self._sample()
|
|
76
|
+
|
|
77
|
+
def _sample(self) -> Snapshot:
|
|
78
|
+
now = time.time()
|
|
79
|
+
procs = self._processes(now)
|
|
80
|
+
system = self._system(now, procs) if self.want_system else None
|
|
81
|
+
for p in procs:
|
|
82
|
+
entry = self._runtimes.get(p.key)
|
|
83
|
+
if entry and entry.info:
|
|
84
|
+
p.runtime = entry.info.name
|
|
85
|
+
p.runtime_detail = entry.info.detail
|
|
86
|
+
cached = self._pss.get(p.key)
|
|
87
|
+
if cached:
|
|
88
|
+
_t, p.pss, p.uss = cached
|
|
89
|
+
return Snapshot(now, sys.platform, system, procs, private_label())
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------ processes
|
|
92
|
+
def _processes(self, now: float) -> list[ProcessMem]:
|
|
93
|
+
out: list[ProcessMem] = []
|
|
94
|
+
seen = set()
|
|
95
|
+
for proc in psutil.process_iter(["pid", "name", "ppid", "create_time", "memory_info"]):
|
|
96
|
+
info = proc.info
|
|
97
|
+
mi = info.get("memory_info")
|
|
98
|
+
if mi is None:
|
|
99
|
+
continue
|
|
100
|
+
pid = info["pid"]
|
|
101
|
+
ct = info.get("create_time") or 0.0
|
|
102
|
+
p = ProcessMem(
|
|
103
|
+
pid=pid,
|
|
104
|
+
name=info.get("name") or f"pid {pid}",
|
|
105
|
+
ppid=info.get("ppid") or 0,
|
|
106
|
+
create_time=ct,
|
|
107
|
+
resident=mi.rss,
|
|
108
|
+
private=None,
|
|
109
|
+
)
|
|
110
|
+
faults = None
|
|
111
|
+
if WINDOWS:
|
|
112
|
+
p.private = getattr(mi, "private", None)
|
|
113
|
+
p.peak = getattr(mi, "peak_wset", None)
|
|
114
|
+
p.paged_pool = getattr(mi, "paged_pool", None)
|
|
115
|
+
p.nonpaged_pool = getattr(mi, "nonpaged_pool", None)
|
|
116
|
+
faults = getattr(mi, "num_page_faults", None)
|
|
117
|
+
elif LINUX:
|
|
118
|
+
st = native.proc_status(pid)
|
|
119
|
+
if "RssAnon" in st:
|
|
120
|
+
p.private = st["RssAnon"]
|
|
121
|
+
p.shared = st.get("RssFile", 0) + st.get("RssShmem", 0)
|
|
122
|
+
p.swapped = st.get("VmSwap")
|
|
123
|
+
p.peak = st.get("VmHWM")
|
|
124
|
+
faults = native.proc_faults(pid)
|
|
125
|
+
self._rates(p, faults, now)
|
|
126
|
+
seen.add(p.key)
|
|
127
|
+
out.append(p)
|
|
128
|
+
for k in list(self._tracks):
|
|
129
|
+
if k not in seen:
|
|
130
|
+
del self._tracks[k]
|
|
131
|
+
for cache in (self._runtimes, self._pss):
|
|
132
|
+
for k in list(cache):
|
|
133
|
+
if k not in seen:
|
|
134
|
+
del cache[k]
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
def _rates(self, p: ProcessMem, faults: int | None, now: float) -> None:
|
|
138
|
+
basis = p.private if p.private is not None else p.resident
|
|
139
|
+
prev = self._tracks.get(p.key)
|
|
140
|
+
if prev is None:
|
|
141
|
+
self._tracks[p.key] = _Track(now, basis, faults)
|
|
142
|
+
return
|
|
143
|
+
dt = now - prev.t
|
|
144
|
+
if dt <= 0.05: # two samples back to back: reuse the last rates
|
|
145
|
+
p.growth_ps, p.faults_ps = prev.growth, prev.faults_ps
|
|
146
|
+
return
|
|
147
|
+
inst = (basis - prev.private) / dt
|
|
148
|
+
prev.growth = inst if prev.growth is None else GROWTH_ALPHA * inst + (1 - GROWTH_ALPHA) * prev.growth
|
|
149
|
+
p.growth_ps = prev.growth
|
|
150
|
+
if faults is not None and prev.faults is not None:
|
|
151
|
+
p.faults_ps = prev.faults_ps = max(0.0, (faults - prev.faults) / dt)
|
|
152
|
+
prev.t, prev.private, prev.faults = now, basis, faults
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------ system
|
|
155
|
+
def _system(self, now: float, procs: list[ProcessMem]) -> SystemMemory:
|
|
156
|
+
vm = psutil.virtual_memory()
|
|
157
|
+
sm = SystemMemory(total=vm.total, available=vm.available, used=vm.total - vm.available)
|
|
158
|
+
try:
|
|
159
|
+
sw = psutil.swap_memory()
|
|
160
|
+
sm.swap_total, sm.swap_used = sw.total, sw.used
|
|
161
|
+
except Exception: # noqa: BLE001 - psutil raises varied errors on locked-down hosts
|
|
162
|
+
pass
|
|
163
|
+
if WINDOWS:
|
|
164
|
+
self._system_windows(sm, procs)
|
|
165
|
+
elif LINUX:
|
|
166
|
+
self._system_linux(sm, now)
|
|
167
|
+
else:
|
|
168
|
+
sm.free = getattr(vm, "free", None)
|
|
169
|
+
sm.cached = getattr(vm, "inactive", None)
|
|
170
|
+
sm.extra = {k: getattr(vm, k) for k in ("active", "inactive", "wired") if hasattr(vm, k)}
|
|
171
|
+
return sm
|
|
172
|
+
|
|
173
|
+
def _system_windows(self, sm: SystemMemory, procs: list[ProcessMem]) -> None:
|
|
174
|
+
sm.source = "PDH + GetPerformanceInfo"
|
|
175
|
+
perf = native.performance_info() or {}
|
|
176
|
+
c = self._pdh.sample() if self._pdh else {}
|
|
177
|
+
if c:
|
|
178
|
+
sb = [c.get(k) for k in ("standby_core", "standby_normal", "standby_reserve")]
|
|
179
|
+
if any(v is not None for v in sb):
|
|
180
|
+
sm.standby = int(sum(v or 0 for v in sb))
|
|
181
|
+
if "modified" in c:
|
|
182
|
+
sm.modified = int(c["modified"])
|
|
183
|
+
if "free_zero" in c:
|
|
184
|
+
sm.free = int(c["free_zero"])
|
|
185
|
+
if "cache" in c:
|
|
186
|
+
sm.cached = int(c["cache"])
|
|
187
|
+
if "pool_paged" in c:
|
|
188
|
+
sm.kernel_paged = int(c["pool_paged"])
|
|
189
|
+
if "pool_nonpaged" in c:
|
|
190
|
+
sm.kernel_nonpaged = int(c["pool_nonpaged"])
|
|
191
|
+
if not self._first:
|
|
192
|
+
sm.page_faults_ps = c.get("page_faults_ps")
|
|
193
|
+
sm.hard_faults_ps = c.get("pages_input_ps")
|
|
194
|
+
for key, label in (("driver_resident", "Driver resident"), ("code_resident", "System code resident")):
|
|
195
|
+
if key in c:
|
|
196
|
+
sm.extra[label] = int(c[key])
|
|
197
|
+
if perf:
|
|
198
|
+
sm.commit_used = perf["commit_total"]
|
|
199
|
+
sm.commit_limit = perf["commit_limit"]
|
|
200
|
+
sm.extra["Commit peak"] = perf["commit_peak"]
|
|
201
|
+
if sm.kernel_paged is None:
|
|
202
|
+
sm.kernel_paged = perf["kernel_paged"]
|
|
203
|
+
sm.kernel_nonpaged = perf["kernel_nonpaged"]
|
|
204
|
+
if sm.cached is None:
|
|
205
|
+
sm.cached = perf["system_cache"]
|
|
206
|
+
comp = next((p for p in procs if p.name.lower() in ("memory compression", "memcompression")), None)
|
|
207
|
+
if comp is not None:
|
|
208
|
+
sm.compressed = comp.resident
|
|
209
|
+
self._first = False
|
|
210
|
+
|
|
211
|
+
def _system_linux(self, sm: SystemMemory, now: float) -> None:
|
|
212
|
+
sm.source = "/proc/meminfo + /proc/vmstat"
|
|
213
|
+
mi = native.meminfo()
|
|
214
|
+
sm.free = mi.get("MemFree")
|
|
215
|
+
sm.cached = mi.get("Cached", 0) + mi.get("Buffers", 0) if "Cached" in mi else None
|
|
216
|
+
sm.compressed = mi.get("Zswap")
|
|
217
|
+
sm.commit_used = mi.get("Committed_AS")
|
|
218
|
+
sm.commit_limit = mi.get("CommitLimit")
|
|
219
|
+
sm.kernel_paged = mi.get("SReclaimable")
|
|
220
|
+
sm.kernel_nonpaged = mi.get("SUnreclaim")
|
|
221
|
+
sm.modified = mi.get("Dirty")
|
|
222
|
+
for k, label in (("Shmem", "Shmem / tmpfs"), ("AnonPages", "Anonymous"), ("PageTables", "Page tables"),
|
|
223
|
+
("KernelStack", "Kernel stacks"), ("AnonHugePages", "Transparent hugepages"),
|
|
224
|
+
("Zswapped", "Zswapped (uncompressed)")):
|
|
225
|
+
if k in mi:
|
|
226
|
+
sm.extra[label] = mi[k]
|
|
227
|
+
vs = native.vmstat()
|
|
228
|
+
if self._vm_prev and vs:
|
|
229
|
+
t0, prev = self._vm_prev
|
|
230
|
+
dt = now - t0
|
|
231
|
+
if dt > 0 and "pgfault" in vs and "pgfault" in prev:
|
|
232
|
+
sm.page_faults_ps = (vs["pgfault"] - prev["pgfault"]) / dt
|
|
233
|
+
sm.hard_faults_ps = (vs.get("pgmajfault", 0) - prev.get("pgmajfault", 0)) / dt
|
|
234
|
+
self._vm_prev = (now, vs)
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------ runtimes
|
|
237
|
+
def resolve_runtimes(self, procs: list[ProcessMem], *, time_budget: float = 0.25) -> int:
|
|
238
|
+
"""Identify runtimes for processes in the given priority order, within a time budget."""
|
|
239
|
+
with self._lock:
|
|
240
|
+
return self._resolve(procs, time_budget)
|
|
241
|
+
|
|
242
|
+
def _resolve(self, procs: list[ProcessMem], time_budget: float) -> int:
|
|
243
|
+
deadline = time.perf_counter() + time_budget
|
|
244
|
+
now = time.time()
|
|
245
|
+
done = 0
|
|
246
|
+
for p in procs:
|
|
247
|
+
entry = self._runtimes.get(p.key)
|
|
248
|
+
if entry and now - entry.checked < RUNTIME_TTL:
|
|
249
|
+
continue
|
|
250
|
+
if time.perf_counter() > deadline:
|
|
251
|
+
break
|
|
252
|
+
self._runtimes[p.key] = self._identify(p, now)
|
|
253
|
+
info = self._runtimes[p.key].info
|
|
254
|
+
p.runtime = info.name if info else None
|
|
255
|
+
p.runtime_detail = info.detail if info else None
|
|
256
|
+
done += 1
|
|
257
|
+
return done
|
|
258
|
+
|
|
259
|
+
def refresh_pss(self, procs: list[ProcessMem], *, time_budget: float = 0.2) -> int:
|
|
260
|
+
"""Linux only: read PSS/USS from smaps_rollup for processes in priority order."""
|
|
261
|
+
if not LINUX:
|
|
262
|
+
return 0
|
|
263
|
+
with self._lock:
|
|
264
|
+
deadline = time.perf_counter() + time_budget
|
|
265
|
+
now = time.time()
|
|
266
|
+
done = 0
|
|
267
|
+
for p in procs:
|
|
268
|
+
cached = self._pss.get(p.key)
|
|
269
|
+
if cached and now - cached[0] < PSS_TTL:
|
|
270
|
+
p.pss, p.uss = cached[1], cached[2]
|
|
271
|
+
continue
|
|
272
|
+
if time.perf_counter() > deadline:
|
|
273
|
+
break
|
|
274
|
+
r = native.rollup(p.pid)
|
|
275
|
+
if r is None:
|
|
276
|
+
continue # other users' processes: unreadable without root, left as None
|
|
277
|
+
p.pss, p.uss = r
|
|
278
|
+
self._pss[p.key] = (now, r[0], r[1])
|
|
279
|
+
done += 1
|
|
280
|
+
return done
|
|
281
|
+
|
|
282
|
+
def _identify(self, p: ProcessMem, now: float) -> _RuntimeEntry:
|
|
283
|
+
mods: list[str] = []
|
|
284
|
+
if native is not None and p.pid > 4:
|
|
285
|
+
try:
|
|
286
|
+
mods = (native.module_names(p.pid) if WINDOWS else native.mapped_paths(p.pid)) or []
|
|
287
|
+
except OSError:
|
|
288
|
+
mods = []
|
|
289
|
+
try:
|
|
290
|
+
cmd = psutil.Process(p.pid).cmdline()
|
|
291
|
+
except (psutil.Error, OSError):
|
|
292
|
+
cmd = []
|
|
293
|
+
exe = p.name
|
|
294
|
+
return _RuntimeEntry(rt.detect(exe, mods, cmd), cmd, now)
|
|
295
|
+
|
|
296
|
+
def runtime_of(self, key: tuple[int, float]) -> tuple[rt.RuntimeInfo | None, list[str]]:
|
|
297
|
+
e = self._runtimes.get(key)
|
|
298
|
+
return (e.info, e.cmdline) if e else (None, [])
|
|
299
|
+
|
|
300
|
+
# ------------------------------------------------------------------ inspector
|
|
301
|
+
def inspect(self, p: ProcessMem) -> Inspection:
|
|
302
|
+
if native is None:
|
|
303
|
+
ins = Inspection(p.pid, p.name, error="address-space inspection is implemented for Windows and Linux")
|
|
304
|
+
else:
|
|
305
|
+
try:
|
|
306
|
+
ins = native.inspect(p.pid, p.name)
|
|
307
|
+
except Exception as e: # noqa: BLE001 - a racing process exit can surface many error types
|
|
308
|
+
ins = Inspection(p.pid, p.name, error=f"{type(e).__name__}: {e}")
|
|
309
|
+
ins.categories.sort(key=lambda c: CATEGORY_ORDER.index(c.name) if c.name in CATEGORY_ORDER else 99)
|
|
310
|
+
with self._lock:
|
|
311
|
+
if p.key not in self._runtimes or time.time() - self._runtimes[p.key].checked > RUNTIME_TTL:
|
|
312
|
+
self._runtimes[p.key] = self._identify(p, time.time())
|
|
313
|
+
info, cmd = self.runtime_of(p.key)
|
|
314
|
+
ins.cmdline = cmd
|
|
315
|
+
if info:
|
|
316
|
+
ins.evidence = [e for e in info.evidence if e[1]] + ins.evidence
|
|
317
|
+
elif not ins.error:
|
|
318
|
+
ins.evidence.insert(0, (HINT, "no known runtime recognised"))
|
|
319
|
+
return ins
|
|
320
|
+
|
|
321
|
+
def close(self) -> None:
|
|
322
|
+
if self._pdh:
|
|
323
|
+
self._pdh.close()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------- aggregation
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def group_by_app(procs: list[ProcessMem]) -> list[ProcessMem]:
|
|
330
|
+
"""Sum processes sharing an executable name (e.g. every chrome.exe).
|
|
331
|
+
|
|
332
|
+
Resident totals double count pages shared between the members, so they are an
|
|
333
|
+
upper bound; private totals do not overlap.
|
|
334
|
+
"""
|
|
335
|
+
groups: dict[str, ProcessMem] = {}
|
|
336
|
+
for p in procs:
|
|
337
|
+
k = p.name.lower()
|
|
338
|
+
g = groups.get(k)
|
|
339
|
+
if g is None:
|
|
340
|
+
groups[k] = ProcessMem(
|
|
341
|
+
pid=p.pid, name=p.name, ppid=0, create_time=0.0, resident=p.resident, private=p.private,
|
|
342
|
+
shared=p.shared, swapped=p.swapped, pss=p.pss, uss=p.uss, faults_ps=p.faults_ps, growth_ps=p.growth_ps,
|
|
343
|
+
paged_pool=p.paged_pool, nonpaged_pool=p.nonpaged_pool,
|
|
344
|
+
runtime=p.runtime, runtime_detail=None, count=1,
|
|
345
|
+
)
|
|
346
|
+
continue
|
|
347
|
+
g.count += 1
|
|
348
|
+
g.resident += p.resident
|
|
349
|
+
g.private = _add(g.private, p.private)
|
|
350
|
+
g.shared = _add(g.shared, p.shared)
|
|
351
|
+
g.swapped = _add(g.swapped, p.swapped)
|
|
352
|
+
g.pss = _add(g.pss, p.pss)
|
|
353
|
+
g.uss = _add(g.uss, p.uss)
|
|
354
|
+
g.faults_ps = _add(g.faults_ps, p.faults_ps)
|
|
355
|
+
g.growth_ps = _add(g.growth_ps, p.growth_ps)
|
|
356
|
+
g.paged_pool = _add(g.paged_pool, p.paged_pool)
|
|
357
|
+
g.nonpaged_pool = _add(g.nonpaged_pool, p.nonpaged_pool)
|
|
358
|
+
g.runtime = g.runtime or p.runtime
|
|
359
|
+
g.pid = min(g.pid, p.pid)
|
|
360
|
+
return list(groups.values())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _add(a, b):
|
|
364
|
+
if a is None:
|
|
365
|
+
return b
|
|
366
|
+
if b is None:
|
|
367
|
+
return a
|
|
368
|
+
return a + b
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
SORTS = {
|
|
372
|
+
"resident": lambda p: p.resident,
|
|
373
|
+
"private": lambda p: p.private or 0,
|
|
374
|
+
"pss": lambda p: p.pss or 0,
|
|
375
|
+
"growth": lambda p: p.growth_ps or 0,
|
|
376
|
+
"faults": lambda p: p.faults_ps or 0,
|
|
377
|
+
"name": lambda p: p.name.lower(),
|
|
378
|
+
"pid": lambda p: p.pid,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def sort_procs(procs: list[ProcessMem], key: str) -> list[ProcessMem]:
|
|
383
|
+
return sorted(procs, key=SORTS[key], reverse=key not in ("name", "pid"))
|
memtaskman/diff.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Compare two --json snapshots: what grew, what appeared, what went away."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from memtaskman.fmt import fmt_bytes
|
|
8
|
+
|
|
9
|
+
_SYSTEM_FIELDS = ["used", "available", "standby", "modified", "cached", "compressed", "commit_used",
|
|
10
|
+
"swap_used", "kernel_paged", "kernel_nonpaged"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _signed(n: int) -> str:
|
|
14
|
+
return ("+" if n > 0 else "") + fmt_bytes(n)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def diff_snapshots(a: dict, b: dict, *, top: int = 20) -> str:
|
|
18
|
+
out = []
|
|
19
|
+
dt = b.get("time", 0) - a.get("time", 0)
|
|
20
|
+
out.append(f"Δt = {dt:.0f}s")
|
|
21
|
+
sa, sb = a.get("system") or {}, b.get("system") or {}
|
|
22
|
+
rows = []
|
|
23
|
+
for f in _SYSTEM_FIELDS:
|
|
24
|
+
va, vb = sa.get(f), sb.get(f)
|
|
25
|
+
if va is not None and vb is not None and va != vb:
|
|
26
|
+
rows.append(f" {f:<16} {fmt_bytes(va):>9} -> {fmt_bytes(vb):>9} ({_signed(vb - va)})")
|
|
27
|
+
if rows:
|
|
28
|
+
out.append("System:")
|
|
29
|
+
out += rows
|
|
30
|
+
|
|
31
|
+
def by_name(snap: dict) -> dict[str, dict]:
|
|
32
|
+
agg: dict[str, dict] = defaultdict(lambda: {"resident": 0, "private": 0, "count": 0})
|
|
33
|
+
for p in snap.get("processes", []):
|
|
34
|
+
g = agg[p["name"].lower()]
|
|
35
|
+
g["name"] = p["name"]
|
|
36
|
+
g["resident"] += p.get("resident") or 0
|
|
37
|
+
g["private"] += p.get("private") or 0
|
|
38
|
+
g["count"] += 1
|
|
39
|
+
return agg
|
|
40
|
+
|
|
41
|
+
ga, gb = by_name(a), by_name(b)
|
|
42
|
+
changes = []
|
|
43
|
+
for k in set(ga) | set(gb):
|
|
44
|
+
pa, pb = ga.get(k), gb.get(k)
|
|
45
|
+
name = (pb or pa)["name"]
|
|
46
|
+
ra, rb = (pa or {}).get("resident", 0), (pb or {}).get("resident", 0)
|
|
47
|
+
qa, qb = (pa or {}).get("private", 0), (pb or {}).get("private", 0)
|
|
48
|
+
tag = "new" if pa is None else "gone" if pb is None else ""
|
|
49
|
+
changes.append((abs(qb - qa) + abs(rb - ra), name, ra, rb, qa, qb, tag,
|
|
50
|
+
(pa or {}).get("count", 0), (pb or {}).get("count", 0)))
|
|
51
|
+
changes.sort(reverse=True)
|
|
52
|
+
out.append(f"Processes (grouped by name, top {top} by change):")
|
|
53
|
+
out.append(f" {'Name':<28} {'Resident Δ':>11} {'Private Δ':>11} {'now':>9} count")
|
|
54
|
+
for _w, name, ra, rb, qa, qb, tag, ca, cb in changes[:top]:
|
|
55
|
+
if ra == rb and qa == qb and not tag:
|
|
56
|
+
continue
|
|
57
|
+
out.append(f" {name[:28]:<28} {_signed(rb - ra):>11} {_signed(qb - qa):>11} {fmt_bytes(rb):>9} {ca}->{cb} {tag}")
|
|
58
|
+
return "\n".join(out)
|
memtaskman/fmt.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Human-readable formatting helpers (pure, no I/O)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_UNITS = ["B", "K", "M", "G", "T", "P"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fmt_bytes(n: float | int | None, *, long: bool = False) -> str:
|
|
9
|
+
"""Format a byte count compactly using binary units: 0, 512B, 1.5K, 12M, 3.40G."""
|
|
10
|
+
if n is None:
|
|
11
|
+
return "-"
|
|
12
|
+
neg = n < 0
|
|
13
|
+
v = float(abs(n))
|
|
14
|
+
i = 0
|
|
15
|
+
while v >= 1024 and i < len(_UNITS) - 1:
|
|
16
|
+
v /= 1024
|
|
17
|
+
i += 1
|
|
18
|
+
if i == 0:
|
|
19
|
+
s = f"{int(v)}B"
|
|
20
|
+
elif v >= 100:
|
|
21
|
+
s = f"{v:.0f}{_UNITS[i]}"
|
|
22
|
+
elif v >= 10:
|
|
23
|
+
s = f"{v:.1f}{_UNITS[i]}"
|
|
24
|
+
else:
|
|
25
|
+
s = f"{v:.2f}{_UNITS[i]}"
|
|
26
|
+
if long and i > 0:
|
|
27
|
+
s += "iB"
|
|
28
|
+
return ("-" if neg else "") + s
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fmt_rate(bps: float | None) -> str:
|
|
32
|
+
"""Format a signed byte rate, e.g. +1.20M/s. Near-zero rates render as a dot."""
|
|
33
|
+
if bps is None:
|
|
34
|
+
return "-"
|
|
35
|
+
if abs(bps) < 1024:
|
|
36
|
+
return "0"
|
|
37
|
+
sign = "+" if bps > 0 else "-"
|
|
38
|
+
return f"{sign}{fmt_bytes(abs(bps))}/s"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fmt_count_rate(n: float | None) -> str:
|
|
42
|
+
if n is None:
|
|
43
|
+
return "-"
|
|
44
|
+
if n < 1:
|
|
45
|
+
return "0"
|
|
46
|
+
if n >= 1_000_000:
|
|
47
|
+
return f"{n / 1_000_000:.1f}M"
|
|
48
|
+
if n >= 10_000:
|
|
49
|
+
return f"{n / 1000:.0f}k"
|
|
50
|
+
if n >= 1000:
|
|
51
|
+
return f"{n / 1000:.1f}k"
|
|
52
|
+
return f"{n:.0f}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pct(part: float | None, whole: float | None) -> float | None:
|
|
56
|
+
if part is None or not whole:
|
|
57
|
+
return None
|
|
58
|
+
return 100.0 * part / whole
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_size(text: str) -> int | None:
|
|
62
|
+
"""Parse sizes as used on command lines: 4g, 512m, 2048k, 1024 (bytes), 4GB, 1.5G."""
|
|
63
|
+
t = text.strip().lower().rstrip("b")
|
|
64
|
+
if not t:
|
|
65
|
+
return None
|
|
66
|
+
mult = 1
|
|
67
|
+
if t[-1] in "kmgt":
|
|
68
|
+
mult = 1024 ** ("kmgt".index(t[-1]) + 1)
|
|
69
|
+
t = t[:-1]
|
|
70
|
+
try:
|
|
71
|
+
return int(float(t) * mult)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return None
|