Skip to content

Commit ea28d93

Browse files
authored
feat(uv): monitor wheel build memory (#1174)
Large source-built wheels can exhaust memory before their action logs show enough evidence to size a scheduler reservation. Add an explicit `monitor_memory` package override that samples approximate Linux procfs RSS for the PEP 517 frontend and its descendants. Emit the first sample, 256 MiB high-water crossings, and a final peak. Preserve earlier samples across procfs races and identify a direct SIGKILL as a possible OOM. Keep diagnostics independent from `resource_set`. The monitor inherits the Bazel process group and never installs signal handlers or kills child processes, so Bazel retains cancellation ownership. Only opted-in generated build tools carry the monitor dependency. Apply monitoring only to generated sdist actions. Selecting a compatible prebuilt wheel produces no report; prebuilt-only packages and complete custom BUILD replacements reject the override.
1 parent af14133 commit ea28d93

11 files changed

Lines changed: 416 additions & 7 deletions

File tree

docs/uv-patching.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,40 @@ package that resolves to a prebuilt wheel (no source build) fails the build
126126
rather than silently dropping the reservation — force a source build with
127127
`[tool.uv] no-binary-package` if you need the reservation to apply.
128128

129+
### Monitoring wheel build memory
130+
131+
Set `monitor_memory` to report the memory observed while building a wheel from
132+
an sdist:
133+
134+
```starlark
135+
uv.override_package(
136+
lock = "//:uv.lock",
137+
name = "native-package",
138+
monitor_memory = True,
139+
)
140+
```
141+
142+
On Linux, rules_py reports the first sample, each 256 MiB high-water crossing,
143+
and the final peak. Reports are flushed as the build runs, so an earlier
144+
high-water mark can remain in the action log when an OOM kills the build.
145+
146+
The measurement is a best-effort sum of `/proc` RSS for the build process and
147+
its descendants. It can double-count shared pages and miss short-lived
148+
processes. On other platforms it is reported as unavailable.
149+
150+
`monitor_memory` is diagnostic only. It neither limits memory nor reserves
151+
scheduler capacity, and can be enabled independently from `resource_set`.
152+
153+
Monitoring runs only when the source-build target is selected. A package with
154+
both an sdist and a compatible wheel produces no report when the wheel is
155+
selected; use `[tool.uv] no-binary-package` to force the monitored source build.
156+
A package with no sdist rejects the override.
157+
158+
A custom sdist configure tool that returns complete `build_file_content` owns
159+
the wheel action itself. Such content must add its own monitoring; combining
160+
that replacement with `monitor_memory` is rejected rather than silently
161+
dropping the diagnostic.
162+
129163
### Full replacement
130164

131165
To replace a package entirely with a custom target (existing functionality):

e2e/BUILD.bazel

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ write_source_files(
128128
# rule expands env keys correctly given an explicit fixture.
129129
"snapshots/sdist_build.uv_sdist_jdk_build.jpype1.BUILD.bazel": "@sdist_build__uv_sdist_jdk_build__jpype1__1_7_1//:BUILD.bazel",
130130

131+
# @sdist_build for cowsay with uv.override_package(monitor_memory)
132+
# ----------------------------------------------------------------
133+
# Covers the override_package -> repo-rule -> BUILD-template plumbing
134+
# for a monitor-only source build. The runtime
135+
# //cases/uv-sdist-fallback:test proves that the generated build tool
136+
# can load the conditional monitor dependency and complete the wheel.
137+
"snapshots/sdist_build.uv_sdist_fallback.cowsay.BUILD.bazel": "@sdist_build__uv_sdist_fallback__cowsay__6_0//:BUILD.bazel",
138+
131139
# @sdist_build for python-geohash with uv.override_package(resource_set)
132140
# ----------------------------------------------------------------------
133141
# Covers the override_package -> repo-rule -> BUILD-template plumbing

e2e/cases/uv-sdist-fallback/setup.MODULE.bazel

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,13 @@ uv.project(
1616
lock = "//cases/uv-sdist-fallback:uv.lock",
1717
pyproject = "//cases/uv-sdist-fallback:pyproject.toml",
1818
)
19-
use_repo(uv, "pypi_sdist_fallback")
19+
uv.override_package(
20+
name = "cowsay",
21+
lock = "//cases/uv-sdist-fallback:uv.lock",
22+
monitor_memory = True,
23+
)
24+
use_repo(
25+
uv,
26+
"pypi_sdist_fallback",
27+
"sdist_build__uv_sdist_fallback__cowsay__6_0",
28+
)

e2e/snapshots/sdist_build.uv_sdist_fallback.cowsay.BUILD.bazel

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

uv/private/extension/defs.bzl

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,14 +254,15 @@ def _parse_projects(module_ctx, hub_specs):
254254
override.extra_data or
255255
override.toolchains or
256256
override.env or
257+
override.monitor_memory or
257258
override.resource_set != "default"
258259
)
259260

260261
if has_target and has_modifications:
261262
fail("uv.override_package() for '{}': `target` is mutually exclusive with modification attributes. Use `target` for full replacement OR build, patch, and data attributes for modifications, not both.".format(override.name))
262263

263264
if not has_target and not has_modifications:
264-
fail("uv.override_package() for '{}': must specify either `target` for full replacement or at least one modification attribute (pre_build_patches, post_install_patches, extra_deps, extra_data, toolchains, env, resource_set).".format(override.name))
265+
fail("uv.override_package() for '{}': must specify either `target` for full replacement or at least one modification attribute (pre_build_patches, post_install_patches, extra_deps, extra_data, toolchains, env, monitor_memory, resource_set).".format(override.name))
265266

266267
package_overrides[override_key] = override
267268

@@ -412,10 +413,12 @@ def _parse_projects(module_ctx, hub_specs):
412413
# they don't replace them. Empty == no augmentation.
413414
extra_toolchains = []
414415
extra_env = {}
416+
monitor_memory = False
415417
resource_set = "default"
416418
if pkg_override:
417419
extra_toolchains = [str(t) for t in pkg_override.toolchains]
418420
extra_env = pkg_override.env
421+
monitor_memory = pkg_override.monitor_memory
419422
resource_set = pkg_override.resource_set
420423

421424
sbuild_specs[sbuild_id] = struct(
@@ -429,6 +432,7 @@ def _parse_projects(module_ctx, hub_specs):
429432
configure_command = project.unstable_configure_command,
430433
extra_toolchains = extra_toolchains,
431434
extra_env = extra_env,
435+
monitor_memory = monitor_memory,
432436
resource_set = resource_set,
433437
)
434438

@@ -448,6 +452,8 @@ def _parse_projects(module_ctx, hub_specs):
448452

449453
if pkg_override and pkg_override.resource_set != "default" and not has_sbuild:
450454
fail("uv.override_package() for '{}': `resource_set` reserves resources for the sdist wheel-build action, but this package resolves to a prebuilt wheel (there is no sdist build to reserve for). Remove `resource_set`, or force a source build via `[tool.uv] no-binary-package`.".format(pkg_override.name))
455+
if pkg_override and pkg_override.monitor_memory and not has_sbuild:
456+
fail("uv.override_package() for '{}': `monitor_memory` observes the sdist wheel-build action, but this package resolves to a prebuilt wheel. Remove `monitor_memory`, or force a source build via `[tool.uv] no-binary-package`.".format(pkg_override.name))
451457

452458
# uv can emit multiple lock records for the same package/version
453459
# (e.g. resolution-marker forks), each carrying a different
@@ -657,6 +663,8 @@ def _uv_impl(module_ctx):
657663
sbuild_kwargs["extra_toolchains"] = sbuild_cfg.extra_toolchains
658664
if sbuild_cfg.extra_env:
659665
sbuild_kwargs["extra_env"] = sbuild_cfg.extra_env
666+
if sbuild_cfg.monitor_memory:
667+
sbuild_kwargs["monitor_memory"] = True
660668
if sbuild_cfg.resource_set != "default":
661669
sbuild_kwargs["resource_set"] = sbuild_cfg.resource_set
662670
sdist_build(**sbuild_kwargs)
@@ -755,6 +763,10 @@ _override_package_tag = tag_class(
755763
# Full replacement: provide a target that substitutes for the package entirely.
756764
# Mutually exclusive with patch/exclude attributes.
757765
"target": attr.label(mandatory = False),
766+
"monitor_memory": attr.bool(
767+
default = False,
768+
doc = "Report approximate Linux process-tree RSS while building this package's wheel.",
769+
),
758770

759771
# Per-package local execution resources for the wheel build action.
760772
# Uses bazel-lib's predefined resource_set vocabulary (the same enum

uv/private/pep517_whl/BUILD.bazel

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ load("@bazel_skylib//rules:write_file.bzl", "write_file")
44
load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
55
load("@tar.bzl//tar:mtree.bzl", "mtree_mutate", "mtree_spec")
66
load("@tar.bzl//tar:tar.bzl", "tar_rule")
7-
load("//py:defs.bzl", "py_binary")
7+
load("//py:defs.bzl", "py_binary", "py_library", "py_test")
88
load(":rule.bzl", "pep517_native_whl", "pep517_whl")
99
load(":test.bzl", "hostile_python_env_target", "pep517_native_whl_toolchain_env_test")
1010

@@ -26,6 +26,19 @@ bzl_library(
2626
],
2727
)
2828

29+
py_library(
30+
name = "memory_monitor",
31+
srcs = ["memory_monitor.py"],
32+
visibility = ["//visibility:public"],
33+
)
34+
35+
py_test(
36+
name = "memory_monitor_test",
37+
srcs = ["memory_monitor_test.py"],
38+
main = "memory_monitor_test.py",
39+
deps = [":memory_monitor"],
40+
)
41+
2942
py_binary(
3043
name = "__build_helper",
3144
testonly = True,

uv/private/pep517_whl/build_helper.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ def _legacy_metadata_conflicts_with_pyproject(worktree):
187187
PARSER = ArgumentParser()
188188
PARSER.add_argument("srcarchive")
189189
PARSER.add_argument("outdir")
190+
PARSER.add_argument("--monitor-memory", action="store_true")
190191
PARSER.add_argument("--validate-anyarch", action="store_true")
191192
PARSER.add_argument("--patch-strip", type=int, default=0, help="Strip count for patch (-p)")
192193
PARSER.add_argument("--patch", action="append", default=[], dest="patches", help="Patch file to apply (repeatable)")
@@ -279,7 +280,20 @@ def _legacy_metadata_conflicts_with_pyproject(worktree):
279280

280281
with TemporaryFile(mode="w+") as build_log:
281282
try:
282-
run(cmd, cwd=t, env=build_env, stdout=build_log, stderr=STDOUT, check=True)
283+
if opts.monitor_memory:
284+
# Generated build tools include this dependency only when the
285+
# corresponding wheel opts into monitoring.
286+
from uv.private.pep517_whl.memory_monitor import run_with_memory_monitor
287+
288+
run_with_memory_monitor(
289+
cmd,
290+
cwd=t,
291+
env=build_env,
292+
stdout=build_log,
293+
wheel=path.basename(opts.srcarchive),
294+
)
295+
else:
296+
run(cmd, cwd=t, env=build_env, stdout=build_log, stderr=STDOUT, check=True)
283297
except CalledProcessError:
284298
build_log.seek(0)
285299
output = build_log.read()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Report approximate Linux RSS while a wheel build runs."""
2+
3+
import os
4+
import subprocess
5+
import sys
6+
import time
7+
8+
_MIB = 1024 * 1024
9+
_REPORT_STEP_BYTES = 256 * _MIB
10+
_SAMPLE_INTERVAL_SECONDS = 0.25
11+
12+
13+
def _process_tree_rss_bytes(root_pid, page_size, proc_root="/proc"):
14+
"""Return the sampled RSS sum for root_pid and its descendants."""
15+
# statm reports resident memory in pages:
16+
# https://man7.org/linux/man-pages/man5/proc_pid_statm.5.html
17+
# Each task's children file lists that task's immediate children:
18+
# https://docs.kernel.org/filesystems/proc.html#proc-pid-task-tid-children-information-about-task-children
19+
pending = [root_pid]
20+
seen = set()
21+
total = 0
22+
sampled = False
23+
24+
while pending:
25+
pid = pending.pop()
26+
if pid in seen:
27+
continue
28+
seen.add(pid)
29+
process_dir = os.path.join(proc_root, str(pid))
30+
31+
try:
32+
with open(os.path.join(process_dir, "statm")) as statm:
33+
total += int(statm.read().split()[1]) * page_size
34+
sampled = True
35+
except (FileNotFoundError, PermissionError, ProcessLookupError, ValueError, IndexError):
36+
pass
37+
38+
try:
39+
task_ids = os.listdir(os.path.join(process_dir, "task"))
40+
except (FileNotFoundError, PermissionError, ProcessLookupError):
41+
continue
42+
43+
for task_id in task_ids:
44+
try:
45+
with open(os.path.join(process_dir, "task", task_id, "children")) as children:
46+
pending.extend(int(child) for child in children.read().split())
47+
except (FileNotFoundError, PermissionError, ProcessLookupError, ValueError):
48+
continue
49+
50+
return total if sampled else None
51+
52+
53+
def _report_memory(wheel, state, peak, current=None, returncode=None):
54+
if peak is None:
55+
details = "unavailable"
56+
elif current is None:
57+
details = "peak={:.1f} MiB".format(peak / _MIB)
58+
else:
59+
details = "current={:.1f} MiB, peak={:.1f} MiB".format(
60+
current / _MIB,
61+
peak / _MIB,
62+
)
63+
if returncode is not None:
64+
details += ", return code={}".format(returncode)
65+
if returncode == -9:
66+
details += " (SIGKILL; possible OOM)"
67+
print(
68+
"rules_py wheel build memory for {} {}: {} (approximate aggregate RSS)".format(
69+
wheel,
70+
state,
71+
details,
72+
),
73+
file=sys.stderr,
74+
flush=True,
75+
)
76+
77+
78+
def run_with_memory_monitor(cmd, *, cwd, env, stdout, wheel):
79+
"""Run cmd while reporting best-effort process-tree RSS."""
80+
try:
81+
can_sample = sys.platform.startswith("linux") and os.path.isdir("/proc")
82+
page_size = os.sysconf("SC_PAGE_SIZE") if can_sample else None
83+
except (OSError, ValueError):
84+
can_sample = False
85+
page_size = None
86+
87+
process = subprocess.Popen(
88+
cmd,
89+
cwd=cwd,
90+
env=env,
91+
stdout=stdout,
92+
stderr=subprocess.STDOUT,
93+
)
94+
peak = None
95+
next_report = 0
96+
97+
while True:
98+
if can_sample:
99+
try:
100+
current = _process_tree_rss_bytes(process.pid, page_size)
101+
except (OSError, ValueError):
102+
current = None
103+
if current is not None:
104+
peak = current if peak is None else max(peak, current)
105+
if peak >= next_report:
106+
_report_memory(wheel, "running", peak, current=current)
107+
next_report = (peak // _REPORT_STEP_BYTES + 1) * _REPORT_STEP_BYTES
108+
109+
returncode = process.poll()
110+
if returncode is not None:
111+
break
112+
time.sleep(_SAMPLE_INTERVAL_SECONDS)
113+
114+
_report_memory(wheel, "finished", peak, returncode=returncode)
115+
if returncode:
116+
raise subprocess.CalledProcessError(returncode, cmd)

0 commit comments

Comments
 (0)