mirror of
https://github.com/zensical/zensical.git
synced 2026-08-23 07:36:52 +00:00
237 lines
7.3 KiB
Python
Executable File
Vendored
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())
|