proj/portage:master commit in: lib/portage/tests/emerge/, lib/_emerge/

"Matt Turner" <[email protected]>
Newsgroups gmane.linux.gentoo.cvs
Message-ID <1786149767.023f12350ae30f85fe274bf8394ab821cfcba276.mattst88@gentoo>
commit:     023f12350ae30f85fe274bf8394ab821cfcba276
Author:     Matt Turner <mattst88 <AT> gentoo <DOT> org>
AuthorDate: Sun Jun 14 18:41:17 2026 +0000
Commit:     Matt Turner <mattst88 <AT> gentoo <DOT> org>
CommitDate: Sat Aug  8 00:42:47 2026 +0000
URL:        https://gitweb.gentoo.org/proj/portage.git/commit/?id=023f1235

Report cgroup resource usage per build

The Scheduler creates a CgroupManager when FEATURES="cgroup" is enabled
and reads each build's leaf by cpv. Live accounting (cpu time, current
and peak memory, I/O) is included per task in the live observability
snapshot. On build exit a one-line summary is written to the build log
and the leaf is removed; remaining leaves and the base cgroup are
cleaned up when emerge exits.

Signed-off-by: Matt Turner <mattst88 <AT> gentoo.org>

 lib/_emerge/Scheduler.py                       | 59 +++++++++++++++++++++++---
 lib/_emerge/_observability.py                  |  5 +++
 lib/portage/tests/emerge/test_observability.py | 18 ++++++++
 3 files changed, 75 insertions(+), 7 deletions(-)

diff --git a/lib/_emerge/Scheduler.py b/lib/_emerge/Scheduler.py
index e546caadc..82bee6857 100644
--- a/lib/_emerge/Scheduler.py
+++ b/lib/_emerge/Scheduler.py
@@ -24,15 +24,17 @@ from portage.output import colorize, create_color_func, red
 bad = create_color_func("BAD")
 from portage._sets import SETPREFIX
 from portage._sets.base import InternalPackageSet
-from portage.package.ebuild.digestcheck import digestcheck
-from portage.package.ebuild.digestgen import digestgen
-from portage.package.ebuild.doebuild import _check_temp_dir, _prepare_self_update
-from portage.package.ebuild.prepare_build_dirs import prepare_build_dirs
 from portage.util import ensure_dirs, writemsg, writemsg_level
-from portage.util._async.SchedulerInterface import SchedulerInterface
+from portage.util.cgroup import CgroupManager, DEFAULT_CGROUP_ROOT
 from portage.util.futures import asyncio
+from portage.util.human_readable import bytes_to_human
 from portage.util.path import first_existing
 from portage.util.SlotObject import SlotObject
+from portage.util._async.SchedulerInterface import SchedulerInterface
+from portage.package.ebuild.digestcheck import digestcheck
+from portage.package.ebuild.digestgen import digestgen
+from portage.package.ebuild.doebuild import _check_temp_dir, _prepare_self_update
+from portage.package.ebuild.prepare_build_dirs import prepare_build_dirs
 
 import _emerge
 from _emerge._find_deep_system_runtime_deps import _find_deep_system_runtime_deps
@@ -229,6 +231,18 @@ class Scheduler(PollScheduler):
             xterm_titles=("notitles" not in settings.features)
         )
         self._observability = ObservabilityMonitor(self)
+        self._cgroup = None
+        if "cgroup" in settings.features:
+            cg = CgroupManager(
+                settings.get("PORTAGE_CGROUP_ROOT", DEFAULT_CGROUP_ROOT),
+                verbose="--verbose" in myopts,
+            )
+            if cg.setup():
+                self._cgroup = cg
+                settings.unlock()
+                settings["PORTAGE_CGROUP_ROOT"] = cg.emerge_root
+                settings.backup_changes("PORTAGE_CGROUP_ROOT")
+                settings.lock()
         self._max_load = myopts.get("--load-average")
         max_jobs = myopts.get("--jobs", 1)
         self._set_max_jobs(max_jobs)
@@ -405,6 +419,36 @@ class Scheduler(PollScheduler):
         """
         self._observability.note_phase(cpv, phase)
 
+    def _cgroup_finish(self, build):
+        """Log the final cgroup resource summary for a build and remove it."""
+        if self._cgroup is None:
+            return
+        cpv = build.pkg.cpv
+        stats = self._cgroup.read_stats(cpv)
+        if stats:
+            parts = []
+            if "cpu_usec" in stats:
+                parts.append(f"cpu {stats['cpu_usec'] / 1e6:.1f}s")
+            if "mem_peak" in stats:
+                parts.append(f"peak-mem {bytes_to_human(stats['mem_peak'])}")
+            if "mem_swap_peak" in stats and stats["mem_swap_peak"]:
+                parts.append(f"peak-swap {bytes_to_human(stats['mem_swap_peak'])}")
+            if "mem_zswap_current" in stats and stats["mem_zswap_current"]:
+                parts.append(f"zswap {bytes_to_human(stats['mem_zswap_current'])}")
+            if "io_read_bytes" in stats:
+                parts.append(
+                    "io {} / {} r/w".format(
+                        bytes_to_human(stats["io_read_bytes"]),
+                        bytes_to_human(stats.get("io_write_bytes", 0)),
+                    )
+                )
+            if parts:
+                msg = f"=== Resource usage for {cpv}: {', '.join(parts)}"
+                log_path = build.settings.get("PORTAGE_LOG_FILE")
+                self._sched_iface.output(f"{msg}\n", log_path=log_path)
+                self._logger.log(f" {msg}")
+        self._cgroup.destroy(cpv)
+
     def _init_graph(self, graph_config):
         """
         Initialization structures used for dependency calculations
@@ -1576,6 +1620,7 @@ class Scheduler(PollScheduler):
     def _build_exit(self, build):
         self._running_tasks.pop(id(build), None)
         self._observability.note_task_finished(build)
+        self._cgroup_finish(build)
         self._release_job_token(id(build))
         if build.returncode == os.EX_OK and self._terminated_tasks:
             # We've been interrupted, so we won't
@@ -1737,6 +1782,8 @@ class Scheduler(PollScheduler):
         finally:
             self._main_loop_cleanup()
             self._observability.close()
+            if self._cgroup is not None:
+                self._cgroup.close()
             portage.locks._quiet = False
             portage.elog.remove_listener(self._elog_listener)
             if display_callback.handle is not None:
@@ -1955,8 +2002,6 @@ class Scheduler(PollScheduler):
 
                         if actual_free_bytes < required_free_bytes:
                             if not self._warned_tmpdir_free_space:
-                                from portage.util.human_readable import bytes_to_human
-
                                 actual_free_bytes_hr = bytes_to_human(actual_free_bytes)
                                 required_free_bytes_hr = bytes_to_human(
                                     required_free_bytes

diff --git a/lib/_emerge/_observability.py b/lib/_emerge/_observability.py
index bf1c99aef..ff75405a0 100644
--- a/lib/_emerge/_observability.py
+++ b/lib/_emerge/_observability.py
@@ -70,6 +70,7 @@ def build_snapshot(monitor):
     scheduler = monitor._scheduler
     now = time.time()
 
+    cgroup = getattr(scheduler, "_cgroup", None)
     merge_wait_ids = {id(t) for t in getattr(scheduler, "_merge_wait_queue", ())}
 
     tasks = []
@@ -114,6 +115,10 @@ def build_snapshot(monitor):
             "start_time": start,
             "elapsed": elapsed,
         }
+        if cgroup is not None:
+            res = cgroup.read_stats(str(pkg.cpv))
+            if res:
+                entry["resources"] = res
         tasks.append(entry)
 
     tasks.sort(key=lambda t: (t["start_time"] is None, t["start_time"] or 0))

diff --git a/lib/portage/tests/emerge/test_observability.py b/lib/portage/tests/emerge/test_observability.py
index a21f80730..152fbe7b5 100644
--- a/lib/portage/tests/emerge/test_observability.py
+++ b/lib/portage/tests/emerge/test_observability.py
@@ -62,6 +62,7 @@ def _make_scheduler(features=("observability",), eprefix="", tasks=None):
         _task_queues=SimpleNamespace(merge=[]),
         _status_display=SimpleNamespace(curval=1, maxval=5),
         _event_loop=None,
+        _cgroup=None,
     )
 
 
@@ -93,6 +94,23 @@ class ObservabilitySnapshotTestCase(TestCase):
         self.assertEqual(by_cpv["dev-libs/foo-1.2"]["kind"], "build")
         self.assertEqual(by_cpv["sys-apps/bar-3"]["kind"], "merge")
 
+    def test_snapshot_includes_cgroup_resources(self):
+        build = EbuildBuild(_Pkg("dev-libs/foo-1.2"), pid=7)
+        sched = _make_scheduler(tasks=[build])
+
+        class _Cg:
+            def read_stats(self, cpv):
+                return {"cpu_usec": 2_000_000, "mem_peak": 1234} if cpv else None
+
+        sched._cgroup = _Cg()
+        monitor = ObservabilityMonitor(sched)
+        monitor.note_task_started(build)
+
+        snap = build_snapshot(monitor)
+        res = snap["tasks"][0]["resources"]
+        self.assertEqual(res["cpu_usec"], 2_000_000)
+        self.assertEqual(res["mem_peak"], 1234)
+
     def test_snapshot_marks_merge_wait(self):
         # A merge sitting in the merge-wait queue is reported as waiting, with
         # its phase surfaced as "merge-wait".
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.