Use physical footprint instead of RSS in mem-report

Summing ps RSS across processes overcounts shared memory: shared
read-only pages like the dyld shared cache and frameworks get charged
to every process that maps them. With 300+ iOS simulator daemons all
sharing those pages, the Simulator group reported ~34 GB when its real
cost is ~5 GB.

Switch the per-process metric to physical footprint (top's MEM column,
what Activity Monitor reports), joined with ps on PID for full command
names since top truncates them. Shared pages are now charged once and
the grouped sums stay under the system app-memory total.
This commit is contained in:
Sami Samhuri 2026-06-18 20:59:15 -07:00
parent ed252fb3cd
commit fdb8415b21

View file

@ -59,18 +59,50 @@ system_summary = {
free_memory: to_mb.call((vm[:free] || 0) + (vm[:speculative] || 0)).round(0),
}
# --- Per-process memory (via ps) ---
# --- Per-process memory (physical footprint via top, joined with ps) ---
#
# We use physical footprint (top's MEM column) rather than ps RSS. RSS counts
# every resident page, including shared read-only pages like the dyld shared
# cache and frameworks — so summing it across many processes that share those
# pages (e.g. 300+ iOS simulator daemons) overcounts wildly. Footprint charges
# shared pages once and matches what Activity Monitor reports. top truncates
# command names though, so we pull full paths from ps and join on PID.
ps_output = run("ps -axo pid=,user=,rss=,comm=")
processes = ps_output.each_line.filter_map do |line|
parts = line.strip.split(/\s+/, 4)
next if parts.size < 4
def parse_mem_mb(str)
m = str.match(/\A([\d.]+)([KMGB]?)/)
return nil unless m
pid, user, rss_kb, comm = parts
rss_mb = rss_kb.to_f / 1024.0
next if rss_mb < 0.1
val = m[1].to_f
case m[2]
when "G" then val * 1024
when "M" then val
when "K" then val / 1024.0
else val / 1024.0 / 1024.0 # bytes
end
end
{ pid: pid.to_i, user:, rss_mb:, command: comm }
footprints = {}
run("top -l 1 -stats pid,mem").each_line do |line|
parts = line.strip.split(/\s+/)
next unless parts.size == 2 && parts[0].match?(/\A\d+\z/)
mb = parse_mem_mb(parts[1])
footprints[parts[0].to_i] = mb if mb
end
commands = {}
run("ps -axo pid=,comm=").each_line do |line|
pid, comm = line.strip.split(/\s+/, 2)
commands[pid.to_i] = comm if pid && comm
end
processes = footprints.filter_map do |pid, mem_mb|
next if mem_mb < 0.1
command = commands[pid]
next unless command
{ pid:, mem_mb:, command: }
end
# --- Grouped memory ---
@ -100,14 +132,14 @@ def group_name(command)
File.basename(command).sub(/\.\w+$/, "")
end
groups = Hash.new { |h, k| h[k] = { rss_mb: 0.0, count: 0 } }
groups = Hash.new { |h, k| h[k] = { mem_mb: 0.0, count: 0 } }
processes.each do |proc|
name = group_name(proc[:command])
groups[name][:rss_mb] += proc[:rss_mb]
groups[name][:mem_mb] += proc[:mem_mb]
groups[name][:count] += 1
end
top_groups = groups.sort_by { -it[1][:rss_mb] }.first(options[:group_top])
top_groups = groups.sort_by { -it[1][:mem_mb] }.first(options[:group_top])
# --- Output ---
@ -115,9 +147,9 @@ if options[:json]
data = {
system: system_summary.transform_values(&:to_i),
top_groups: top_groups.map do |name, info|
{ group: name, rss_mb: info[:rss_mb].round(1), process_count: info[:count] }
{ group: name, footprint_mb: info[:mem_mb].round(1), process_count: info[:count] }
end,
note: "RSS values and group sums can overcount shared memory (shared libraries, frameworks, mmap regions).",
note: "Physical footprint (matches Activity Monitor); shared pages are charged once.",
}
puts JSON.pretty_generate(data)
exit
@ -139,9 +171,9 @@ puts
puts "#{bold}=== Top #{options[:group_top]} Grouped Programs ==#{reset}"
puts
puts " #{dim}%-28s %10s %8s#{reset}" % ["GROUP", "RSS", "PROCS"]
puts " #{dim}%-28s %10s %8s#{reset}" % ["GROUP", "MEM", "PROCS"]
top_groups.each do |name, info|
puts " %-28s %10s %8d" % [name, human_bytes(info[:rss_mb]), info[:count]]
puts " %-28s %10s %8d" % [name, human_bytes(info[:mem_mb]), info[:count]]
end
puts
puts "#{dim}Note: RSS and group sums can overcount shared memory (shared libs, frameworks, mmap).#{reset}"
puts "#{dim}Note: physical footprint (matches Activity Monitor); shared pages charged once.#{reset}"