Files
zensical/scripts/memory_profile.py
2026-08-13 17:36:19 +02:00

237 lines
7.3 KiB
Python
Executable File
Vendored

#!/usr/bin/env python
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
"""Measure peak memory for a command using the Linux proc filesystem."""
from __future__ import annotations
import argparse
import json
import statistics
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
@dataclass
class Memory:
"""Memory counters reported by Linux, in KiB."""
rss_kib: int = 0
hwm_kib: int = 0
anonymous_kib: int = 0
file_kib: int = 0
swap_kib: int = 0
pss_kib: int = 0
private_kib: int = 0
def include(self, sample: Memory) -> None:
"""Update every counter to its observed maximum."""
for name in self.__dataclass_fields__:
setattr(self, name, max(getattr(self, name), getattr(sample, name)))
@dataclass
class Run:
"""One measured command invocation."""
elapsed_seconds: float
exit_code: int
samples: int
peak: Memory
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--cwd",
type=Path,
help="working directory for the measured command",
)
parser.add_argument(
"--interval-ms",
type=float,
default=5.0,
help="sampling interval in milliseconds (default: 5)",
)
parser.add_argument(
"--repeat",
type=int,
default=1,
help="number of command invocations (default: 1)",
)
parser.add_argument(
"--json",
type=Path,
help="also write the complete result as JSON",
)
parser.add_argument(
"command",
nargs=argparse.REMAINDER,
help="command to measure, conventionally preceded by --",
)
return parser.parse_args()
def read_key_values(path: Path) -> dict[str, int]:
"""Read KiB counters from a proc status-like file."""
values: dict[str, int] = {}
try:
contents = path.read_text(encoding="ascii")
except (FileNotFoundError, PermissionError, ProcessLookupError):
return values
for line in contents.splitlines():
name, separator, value = line.partition(":")
if not separator:
continue
fields = value.split()
if fields and fields[0].isdigit():
values[name] = int(fields[0])
return values
def sample_process(pid: int) -> Memory:
"""Read current and high-water memory counters for one process."""
status = read_key_values(Path("/proc") / str(pid) / "status")
rollup = read_key_values(Path("/proc") / str(pid) / "smaps_rollup")
return Memory(
rss_kib=status.get("VmRSS", 0),
hwm_kib=status.get("VmHWM", 0),
anonymous_kib=status.get("RssAnon", 0),
file_kib=status.get("RssFile", 0),
swap_kib=status.get("VmSwap", 0),
pss_kib=rollup.get("Pss", 0),
private_kib=(
rollup.get("Private_Clean", 0) + rollup.get("Private_Dirty", 0)
),
)
def measure(
command: Sequence[str], *, cwd: Path | None, interval: float
) -> Run:
"""Run a command and sample its process memory until completion."""
started = time.monotonic()
process = subprocess.Popen(command, cwd=cwd)
peak = Memory()
samples = 0
while process.poll() is None:
peak.include(sample_process(process.pid))
samples += 1
time.sleep(interval)
# VmHWM survives short spikes between samples and is retained until exit.
# This last read can still succeed briefly while the process is a zombie.
peak.include(sample_process(process.pid))
elapsed = time.monotonic() - started
return Run(elapsed, process.returncode, samples, peak)
def mebibytes(kibibytes: float) -> str:
"""Format a KiB measurement as MiB."""
return f"{kibibytes / 1024:.2f} MiB"
def print_run(index: int, run: Run) -> None:
"""Print one compact human-readable result."""
print(
f"run {index}: peak RSS {mebibytes(run.peak.hwm_kib)}, "
f"peak PSS {mebibytes(run.peak.pss_kib)}, "
f"peak private {mebibytes(run.peak.private_kib)}, "
f"elapsed {run.elapsed_seconds:.3f}s, exit {run.exit_code}"
)
def main() -> int:
"""Measure the requested command."""
args = parse_args()
command = args.command
if command[:1] == ["--"]:
command = command[1:]
if not command:
raise SystemExit("a command is required after --")
if args.repeat < 1:
raise SystemExit("--repeat must be at least 1")
if args.interval_ms <= 0:
raise SystemExit("--interval-ms must be greater than zero")
if not Path("/proc/self/status").exists():
raise SystemExit("memory_profile.py currently requires Linux /proc")
runs: list[Run] = []
for index in range(1, args.repeat + 1):
run = measure(
command,
cwd=args.cwd,
interval=args.interval_ms / 1000,
)
runs.append(run)
print_run(index, run)
if run.exit_code != 0:
break
summary = {
"command": command,
"cwd": str(args.cwd.resolve()) if args.cwd else None,
"runs": [asdict(run) for run in runs],
"median": {
"peak_rss_kib": statistics.median(run.peak.hwm_kib for run in runs),
"peak_pss_kib": statistics.median(run.peak.pss_kib for run in runs),
"peak_private_kib": statistics.median(
run.peak.private_kib for run in runs
),
"elapsed_seconds": statistics.median(
run.elapsed_seconds for run in runs
),
},
}
if len(runs) > 1:
print(
f"median: peak RSS {mebibytes(summary['median']['peak_rss_kib'])}, "
f"peak PSS {mebibytes(summary['median']['peak_pss_kib'])}, "
"peak private "
f"{mebibytes(summary['median']['peak_private_kib'])}, "
f"elapsed {summary['median']['elapsed_seconds']:.3f}s"
)
if args.json:
args.json.write_text(
json.dumps(summary, indent=2) + "\n",
encoding="utf-8",
)
return max(run.exit_code for run in runs)
if __name__ == "__main__":
sys.exit(main())