Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions src/con_duct/_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@
lgr = logging.getLogger("con-duct")


# Decimal (SI) byte units, single source of truth for byte humanization in
# duct. Used by SummaryFormatter.naturalsize for run-summary output and by
# the plot axis formatter for tick labels, so a "kB" means the same thing
# in both places. A future opt-in could add a 1024-base + IEC-suffix
# variant (KiB/MiB/GiB/...) and let callers pick.
# Decimal (SI) byte units shared by SummaryFormatter.naturalsize (run
# summary) and the plot axis formatter, so "kB" means the same thing in
# both places.
FILESIZE_UNITS: list[tuple[str, int]] = [
("B", 1),
("kB", 1000**1),
Expand Down
29 changes: 11 additions & 18 deletions src/con_duct/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,34 +79,27 @@ def pdcpu_from_pcpu(
"""Delta-corrected %CPU between two ps samples of the same pid.

Inverts the procps identity ``pcpu = cputime / etime * 100`` to
recover cputime at each sample, takes the cputime delta, and
divides by the elapsed interval. The ``/100`` and ``*100``
cancel, so the result is in the same units as ``pcpu``.
recover cputime at each sample, deltas it, and divides by the
elapsed interval. Result is in the same units as ``pcpu``.

Linux-only: assumes ``pcpu`` is the cumulative ``cputime/etime``
ratio. Invalid on Darwin (decayed EWMA).

Identity is the caller's responsibility: invoke ``is_same_pid``
first. If the inputs satisfy that precondition, ``Δetime`` is
positive and the math is well-defined.
Identity is the caller's responsibility -- invoke ``is_same_pid``
first.

:param prev_pcpu: %CPU from the earlier sample.
:param prev_etimes: elapsed seconds at the earlier sample.
:param curr_pcpu: %CPU from the later sample.
:param curr_etimes: elapsed seconds at the later sample.
:returns: delta-corrected %CPU over the interval, or ``None``
in two "no measurement" cases:

- ``Δetime <= 0`` -- defensive guard for callers that
skipped ``is_same_pid``; covers sub-quantum and obvious
pid-reuse.
- Computed ``pdcpu < 0`` -- aggregation-timing artifact.
When duct's per-pid ``pcpu`` is max-across-samples while
``etime`` is from the last sample, a spike-then-idle
pattern earlier in the run inflates ``prev_pcpu *
prev_etimes`` enough that the cputime "delta" goes
negative. The pid is the same; the math is just noisy.
A small minus dip is honestly null, not zero.
when no measurement is recoverable:

- ``Δetime <= 0`` -- defensive guard; covers sub-quantum and
obvious pid-reuse for callers that skipped ``is_same_pid``.
- Computed ``pdcpu < 0`` -- aggregation-timing artifact from
duct's max-pcpu / end-etime mismatch on spike-then-idle
patterns. Honestly null, not zero.
"""
interval = curr_etimes - prev_etimes
if interval <= 0:
Expand Down
31 changes: 7 additions & 24 deletions src/con_duct/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,6 @@ def _envelopes(
omitted entirely rather than reported as zero -- a missing measurement
is not the same as zero load.

The grid is the union of (kept pids') elapsed values; the alternative
of "every entry timestamp regardless of who appeared" would only add
grid points that are missing measurements anyway.

:returns: ``(grid_xs, max_ys, sum_ys)`` aligned, sorted by ``grid_xs``.
"""
grid: Dict[float, List[float]] = {}
Expand Down Expand Up @@ -337,22 +333,12 @@ def matplotlib_plot(args: argparse.Namespace) -> int:
alpha=0.4,
)

# Envelopes: max-across-pids (lower bound) solid, upper bound dashed.
# If some pid was at 50%, the total was at least 50% -- max-of-pids is
# a true lower bound on the concurrent total in both metrics.
#
# Upper bounds: for both rss and (in ps-pcpu mode) cpu, we use duct's
# per-record ``totals[field]`` -- the peak concurrent value observed
# at any single sub-sample within the report interval. This is a
# tight upper bound under "observed samples only" framing, and avoids
# the phantom-coexistence inflation of summing per-pid peaks (pids
# whose peaks fell in different sub-samples within the interval would
# otherwise both contribute their peak).
#
# In ps-cpu-timepoint mode there is no per-record ``totals.pdcpu`` --
# pdcpu is computed at plot time -- so we fall back to summing per-pid
# pdcpu. The negative-pdcpu clamp filters the worst aggregation-timing
# artifacts; remaining looseness is a known, accepted caveat.
# Solid = max-across-pids (lower bound on the concurrent total).
# Dashed = upper bound: per-record ``totals[field]`` for rss and
# ps-pcpu cpu; in ps-cpu-timepoint mode there is no
# ``totals.pdcpu`` (computed at plot time), so fall back to
# sum-across-pids. See docs/resource-statistics.md for the
# phantom-coexistence rationale.
pcpu_xs, pcpu_max, pcpu_sum = _envelopes(pid_series, "cpu")
if pcpu_xs:
ax.plot( # type: ignore[call-arg]
Expand Down Expand Up @@ -389,10 +375,7 @@ def matplotlib_plot(args: argparse.Namespace) -> int:
ax.set_ylabel(f"{args.cpu} (%)")
ax2.set_ylabel("rss")
if pid_series:
# Two legends, color-agnostic linestyle key on the right and metric
# color key on the left. Linestyle entries are listed in the order
# a viewer's eye scans the chart: upper bound (the high dashed line),
# lower bound (the solid line below it), per-pid (dotted cloud).
# Two legends: linestyle key on the right, metric color key on the left.
style_handles = [
Line2D(
[0],
Expand Down
Loading