From fdb8415b21c3ca7ba8119c0fe489d12101c1c9a5 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Thu, 18 Jun 2026 20:59:15 -0700 Subject: [PATCH] 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. --- mem-report | 66 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/mem-report b/mem-report index e3aa3d6..9840d07 100755 --- a/mem-report +++ b/mem-report @@ -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}"