Skip to content

Add ResourceUsage to report subprocess CPU time and memory consumption - #226

Open
jakepetroules wants to merge 4 commits into
swiftlang:mainfrom
jakepetroules:eng/PR-resource-usage
Open

jakepetroules wants to merge 4 commits into
swiftlang:mainfrom
jakepetroules:eng/PR-resource-usage

Conversation

@jakepetroules

@jakepetroules jakepetroules commented Mar 17, 2026 •

Copy link
Copy Markdown
Contributor

Adds ExecutionResult.resourceUsage: the CPU time, peak memory, page faults, and context switches of every terminated subprocess, for every run overload.

Pitch: Subprocess: Reporting the CPU time and peak memory of terminated child processes

let result = try await run(.name("swiftc"), arguments: ["-O", "main.swift"], output: .discarded)
result.resourceUsage.userTime          // Duration?
result.resourceUsage.peakMemoryUsage   // Int?, bytes

Why Subprocess has to do this

The kernel hands back a child's resource accounting through the same call that reaps it, so the data exists only between the child's exit and its reap. Subprocess owns that window: monitoring and reaping happen inside run(), and the PID is gone by the time it returns. getrusage(RUSAGE_CHILDREN) is a running total over every child the process has waited for, so it can't be attributed to one child when several run concurrently. Reaping the child yourself takes it away from Subprocess, which then can't report its termination status.

API

public struct ResourceUsage: Sendable {
    public var userTime: Duration?
    public var systemTime: Duration?
    public var peakMemoryUsage: Int?            // bytes
    public var minorPageFaults: Int?
    public var majorPageFaults: Int?
    public var voluntaryContextSwitches: Int?
    public var involuntaryContextSwitches: Int?

    public init(userTime: Duration? = nil, /* ... all defaulting to nil */)
}

extension ExecutionResult {
    public var resourceUsage: ResourceUsage { get }
}
  • A property is nil when the platform doesn't report that measurement or querying it failed. Zero is a real measurement, never a placeholder.
  • ResourceUsage is deliberately not Equatable or Hashable: no two runs measure alike. Individual Duration and Int properties remain comparable.
  • ExecutionResult synthesizes Equatable and Hashable, which requires every stored property to conform. It stores the measurement in an internal wrapper whose instances all compare equal and exposes it through a computed property, so ExecutionResult's equality is unchanged by this PR and resourceUsage takes no part in it. Removing those conformances instead would be a breaking change.
  • The public initializer exists so callers can construct values, for example in tests. Values produced by Subprocess come only from the kernel.
Property Darwin, Linux, Android, FreeBSD, OpenBSD Windows
userTime, systemTime Always Unless the query fails
peakMemoryUsage Always (but see the open issue below for Linux) Unless the query fails
Page faults, context switches Always nil

Implementation

Unix. The reap takes two calls, because no interface on every supported platform reports both pieces completely:

  1. A blocking waitid(WEXITED | WNOWAIT) reads the termination status from siginfo_t, exactly as main does, without consuming the zombie. This preserves the full int the child passed to exit where the kernel keeps it (Darwin, FreeBSD).
  2. wait4 then reaps the zombie and supplies the rusage. It's the only interface every supported libc exposes that reports rusage for a specific child. Its wait status is discarded, since it holds only the low 8 bits of the exit code.

Subprocess is the only thing that reaps this child, so both calls observe the same zombie. wait4 is reached through a _subprocess_wait4 C shim, because musl declares it only under _GNU_SOURCE/_BSD_SOURCE. Wrapping the libc entry point, rather than issuing Linux's 5-argument waitid syscall, also keeps libc's struct rusage layout in play: the kernel's __kernel_old_timeval fields disagree with libc's wherever time_t is 64-bit on a 32-bit architecture.

ru_maxrss is normalized from KiB to bytes on Linux, Android, FreeBSD, and OpenBSD, saturating rather than trapping if a peak above 2 GiB is reported on a 32-bit platform.

Windows. GetProcessTimes and K32GetProcessMemoryInfo are read just before GetExitCodeProcess, while the process handle is still open. If either fails, its properties are nil rather than failing the reap, since the exit status is the caller's primary result.

Commits

  1. Add ResourceUsage — the original change, reaping with wait4 alone.
  2. Read the termination status with waitid, not wait4 — fixes a regression in (1), reported by Jonathan Grynspan on the pitch thread: wait4 truncated exit codes to 8 bits, so a child exiting 0x12345 was reported as .exited(0x45) on Darwin and FreeBSD. Adds a regression test.
  3. Reshape ResourceUsage in response to pitch feedback — drops the raw rusage member in favor of typed properties, renames maxRSS to peakMemoryUsage, makes properties optional vars with a public initializer, and removes Equatable/Hashable from ResourceUsage.
  4. Keep the allocation test's buffer resident long enough to be sampled — FreeBSD samples RSS on clock ticks.

Commit 1 on its own contains the exit-status regression, so this should be squash-merged or have 1 and 2 squashed before merging.

Open issue: peak memory on Linux

Since #365, Subprocess spawns with posix_spawn on Linux, which glibc implements with clone(CLONE_VM | CLONE_VFORK); musl does the same. At exec, the kernel folds the high-water RSS of the address space being replaced into the child's maxrss (exec_mm_put_old in fs/exec.c). Because that address space is the parent's, every child's ru_maxrss is at least the parent's peak RSS. CI shows it: a 1 KiB and a 32 MiB dd both report the test runner's ~80 MB. CPU times, page faults, and context switches are unaffected.

testResourceUsagePeakMemoryUsageScalesWithAllocation currently fails on Linux because of this, and should keep failing until it's resolved. Options are being discussed on the pitch thread.

Testing

  • macOS: full suite passes locally (Swift 6.4).
  • testExitCodeWiderThanEightBits (Darwin, FreeBSD): runs perl -e 'exit 0x1234' and expects .exited(0x1234); fails without commit 2. A shell can't be used because it truncates its own exit status to 8 bits.
  • Unit tests cover rusage conversion, including KiB scaling, the initializer defaults, and ExecutionResult equality ignoring resource usage.
  • Integration tests cover CPU time being reported, CPU time rather than wall time being charged, peak memory scaling with allocation, and usage being reported for a signaled child.

@jakepetroules jakepetroules added enhancement New feature or request API Change labels Mar 17, 2026
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch 2 times, most recently from a8f9745 to 66ae4c3 Compare March 17, 2026 17:39
Comment thread Sources/Subprocess/Result.swift Resolved
@iCharlesHu iCharlesHu added this to the Post 1.0 - Feature Release milestone Mar 19, 2026
Comment thread Sources/Subprocess/Result.swift Resolved
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch 2 times, most recently from a357701 to ed3aa46 Compare April 13, 2026 19:02
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch from ed3aa46 to 9630762 Compare May 13, 2026 17:05
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch from 9630762 to d173b7d Compare May 21, 2026 07:19
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch from d173b7d to 006121c Compare June 3, 2026 23:15
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch from 006121c to 578e47a Compare June 11, 2026 22:17
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch 4 times, most recently from abd8399 to 0211f39 Compare August 9, 2026 02:49
@jakepetroules
jakepetroules force-pushed the eng/PR-resource-usage branch from 0211f39 to a7cdef9 Compare October 2, 2026 00:52
Introduce a public ResourceUsage struct exposing userTime and systemTime
(as Duration) and maxRSS (peak resident set size, in bytes) for every
terminated subprocess, surfaced as ExecutionResult.resourceUsage.

Collecting this is something only Subprocess can do: the data has to be
read after the child has become a zombie but before its pid is reaped,
and Subprocess owns the reap. Doing it for an arbitrary pid from outside
is notoriously hard to do portably without also reaping the pid. User
time, system time, and maxRSS are the most commonly used getrusage
metrics; on Unix the raw rusage struct is also exposed as
ResourceUsage.rusage for everything else, and on Windows callers can use
DuplicateHandle to get a process handle that outlives Subprocess's
ownership of the process and query further metrics from it once it is
known to have terminated.

Unix: the child is now reaped with wait4 on every platform, which is the
only interface every supported libc exposes that reports rusage
alongside the wait status. It is reached through a _subprocess_wait4 C
shim because musl declares wait4 only under _GNU_SOURCE/_BSD_SOURCE, so
it isn't reliably visible through every Swift overlay. Wrapping the libc
entry point, rather than issuing the Linux 5-argument waitid syscall,
also keeps libc's struct rusage layout in play: the kernel's
__kernel_old_timeval fields disagree with libc's wherever time_t is
64-bit on a 32-bit architecture. TerminationStatus is now decoded from
the wait status rather than siginfo_t, and the unused non-blocking
reap() is removed. maxRSS is normalized from KiB to bytes on Linux,
Android, FreeBSD, and OpenBSD, saturating rather than trapping if a
peak above 2 GiB is reported on a 32-bit platform.

Windows: GetProcessTimes provides CPU time (converted from FILETIME
100ns units) and K32GetProcessMemoryInfo provides PeakWorkingSetSize,
both read while the process handle is still open. If either query
fails, its fields are left zero rather than failing the reap, since the
exit status is the caller's primary result.

ResourceUsage is Hashable, but equality and hashing consider only
userTime, systemTime, and maxRSS: rusage is a C struct that conforms to
neither protocol, and retroactively conforming a type this package
doesn't own would collide with anyone else doing the same.

Result.swift deliberately doesn't import _SubprocessCShims. On some
platforms (notably glibc) that would attribute rusage to the shims
module rather than the libc overlay, and ResourceUsage.rusage could then
not be public without publicly re-exporting an underscored module. The
conversions that need C struct members in scope therefore live in
Subprocess+Unix.swift and Subprocess+Windows.swift, next to the reaping
code that calls them.
Switching the reap to wait4 truncated exit codes to 8 bits. A wait-family
wait status holds only the low 8 bits of the value a child passes to exit,
but Darwin and FreeBSD preserve the full int and report it through waitid's
siginfo, so a child exiting 0x12345 was reported as .exited(0x45) where main
reports .exited(0x12345). Reported by Jonathan Grynspan on the pitch thread.

Reap in two steps instead. A blocking waitid(WEXITED | WNOWAIT) reads the
termination status from siginfo, exactly as main did, without consuming the
zombie; wait4 then reaps it and supplies the rusage. Subprocess is the only
thing that reaps this child, so both calls observe the same zombie. This
restores TerminationStatus.init(_ siginfo:) from main, and drops the
wait-status decoding that only wait4 needed.

On Linux, where the kernel keeps only 8 bits regardless, this costs one
extra non-blocking system call per reap. FreeBSD's wait6 could return both
in one call, but that is an optimization that can come later.

The regression test runs Perl, because a shell truncates its own exit
status to 8 bits; Perl keeps 16, which is enough to tell the two apart.
Respond to the review on the pitch thread:

- Drop the raw `rusage` member (Franz Busch). Exposing a C struct put a
  platform-conditional member in the public API, and it was the only reason
  ResourceUsage needed hand-written equality and Result.swift needed to avoid
  importing _SubprocessCShims. The rusage fields that mean the same thing
  on every Unix platform become typed properties instead: minor and major
  page faults, and voluntary and involuntary context switches. Block I/O is
  left out because Linux derives ru_inblock/ru_oublock from bytes in
  512-byte units while the BSDs count operations.

- Rename maxRSS to peakMemoryUsage (Franz Busch). It's still bytes: peak
  resident set size on Unix, peak working set size on Windows.

- Make every property optional, and nil rather than zero when a platform
  doesn't report a measurement or querying it fails (Franz Busch). As
  BlueSparrow pointed out, zero can be a real measurement. On Windows that
  covers a failed GetProcessTimes or K32GetProcessMemoryInfo, and the page
  fault and context switch properties, which have no direct equivalent
  there.

- Make the properties `var` and add a public memberwise initializer whose
  parameters default to nil (Franz Busch), so callers can construct values,
  for example in tests, and fields can be added later without breaking them.

- Drop Equatable and Hashable from ResourceUsage (Jordan Rose, Steve Canon).
  No two runs measure alike, so equality over a whole measurement has no
  use; individual Duration and Int fields remain comparable.

ExecutionResult synthesizes Equatable and Hashable, which requires every
stored property to conform. Rather than give that up, which would be a
breaking change, it stores the measurement in an internal
_ExcludedFromEquality wrapper whose instances all compare equal, and exposes
it through a computed `resourceUsage`. ExecutionResult's equality is
therefore exactly what it was before this change: resource usage describes
how an execution ran, not what it produced (Tony Allevato). ExecutionOutcome
does the same for its internal copy.

Reading resourceUsage through a computed property consumes the whole
noncopyable ExecutionOutcome, so API.swift now reads it before `value`.
FreeBSD samples resident memory on clock ticks rather than tracking a
high-water mark on every fault. dd copying its 32 MiB block once exits
before enough ticks land, and CI measured 15.7 MB against the 16 MiB the
test requires. Copy the block 64 times instead, which keeps the buffer
resident for on the order of 100 ms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

API Change enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants