Skip to content

07 - Monitor PID

Attribute system energy to a specific process tree, using cgroup_v2 where available and psutil cpu_percent as a fallback. Demonstrates the two-orders-of-magnitude difference these backends can produce on a busy multi-core host.

What you learn

  • Monitor(pid=..., attribution_mode="auto") PID attribution.
  • The attribution_backend field on every sample.
  • Why cgroup_v2 is dramatically more accurate than cpu_percent under contention.

Prerequisites

  • pip install codegreen psutil
  • Linux with cgroup_v2 unified hierarchy for the high-accuracy backend.

Source

examples/07_monitor_pid/main.py

"""07 Per-PID energy attribution: cgroup_v2 (preferred) vs cpu_percent.

Spawns a CPU-burn worker, monitors it with both backends, and prints the
attributed-joules delta. cgroup_v2 reads `cpu.stat:usage_usec` directly;
cpu_percent uses psutil sampling. On a single-PID workload the difference
is typically <10%; under heavy multi-tenant contention cgroup_v2 is
significantly more accurate.
"""
import json
import subprocess
import sys
import time


def _spawn_burn(seconds: float):
    code = (
        f"import time\n"
        f"t=time.time()+{seconds}\n"
        f"x=0\n"
        f"while time.time()<t:\n"
        f"    x=sum(i*i for i in range(50_000))\n"
    )
    return subprocess.Popen([sys.executable, "-c", code])


def _measure(pid: int, duration: float, mode: str) -> dict:
    import codegreen
    m = codegreen.Monitor(interval_ms=200, duration_s=duration, pid=pid,
                          attribution_mode=mode).start()
    m._thread.join()
    return m.stop()


def main() -> int:
    p = _spawn_burn(4.0)
    time.sleep(0.2)
    cg = _measure(p.pid, 3.0, "auto")
    p.wait(timeout=5)
    p = _spawn_burn(4.0)
    time.sleep(0.2)
    cp = _measure(p.pid, 3.0, "cpu_percent")
    p.wait(timeout=5)
    delta_pct = 100.0 * (cg["proc_attributed_joules"] - cp["proc_attributed_joules"]) / max(cp["proc_attributed_joules"], 1e-6)
    print(json.dumps({
        "cgroup_backend": cg["attribution_backend"],
        "cgroup_attributed_j": cg["proc_attributed_joules"],
        "cgroup_total_j": cg["total_joules"],
        "cpu_percent_attributed_j": cp["proc_attributed_joules"],
        "cpu_percent_total_j": cp["total_joules"],
        "delta_pct_vs_cpu_percent": round(delta_pct, 2),
    }, indent=2))
    print(f"OK: monitor_pid; backend={cg['attribution_backend']}; "
          f"cgroup_j={cg['proc_attributed_joules']:.3f}; cpu_pct_j={cp['proc_attributed_joules']:.3f}; "
          f"delta_pct={delta_pct:.2f}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Run

python examples/07_monitor_pid/main.py

Equivalent CLI

# Auto: cgroup_v2 if available, then cgroup_v1, then cpu_percent
codegreen monitor --pid 1234 -d 30 -o pid.jsonl

# Force a specific backend (useful for benchmarking)
codegreen monitor --pid 1234 --attribution-mode cgroup_v2
codegreen monitor --pid 1234 --attribution-mode cpu_percent

Expected output

OK: monitor_pid; backend=cgroup_v2; cgroup_j=12.421; cpu_pct_j=0.197; delta_pct=6213.71

Why it matters

System RAPL is per-socket, not per-process. To attribute a slice of that system energy to your service, you need a per-process CPU usage counter. cgroup_v2's cpu.stat is monotonic, kernel-attested, and race-free; psutil cpu_percent is a sampled estimate that normalises against full system capacity (so a single-threaded burner on a 64-core host appears as 1.5% of the system instead of 100% of one core).

Always check attribution_backend

If the header says cpu_percent, treat the per-PID joules as a lower-bound estimate -- the real number can be 100x higher on a many-core idle host.

See also: User guide: Continuous Monitoring, 08 - Monitor socket.