bin/mem-report
Sami Samhuri fdb8415b21 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.
2026-06-18 20:59:25 -07:00

179 lines
5.7 KiB
Ruby
Executable file

#!/usr/bin/env ruby
# frozen_string_literal: true
require "json"
require "optparse"
require "open3"
options = { group_top: 20, json: false, color: true }
OptionParser.new do |opts|
opts.banner = "Usage: mem-report [options]"
opts.on("--group-top N", Integer, "Number of top groups to show (default: 20)") { options[:group_top] = it }
opts.on("--json", "Output as JSON") { options[:json] = true }
opts.on("--no-color", "Disable colored output") { options[:color] = false }
end.parse!
def run(cmd) = `#{cmd}`.strip
def human_bytes(mb)
if mb >= 1024
format("%.1f GB", mb / 1024.0)
else
format("%.0f MB", mb)
end
end
# --- System memory summary ---
page_size = run("sysctl -n hw.pagesize").to_i
phys_mem_bytes = run("sysctl -n hw.memsize").to_i
phys_mem_mb = phys_mem_bytes / 1024.0 / 1024.0
vm_stat_output = run("vm_stat")
vm = {}
vm_stat_output.each_line do |line|
case line
when /Pages free:\s+([\d.]+)/ then vm[:free] = $1.to_i
when /Pages active:\s+([\d.]+)/ then vm[:active] = $1.to_i
when /Pages inactive:\s+([\d.]+)/ then vm[:inactive] = $1.to_i
when /Pages speculative:\s+([\d.]+)/ then vm[:speculative] = $1.to_i
when /Pages wired down:\s+([\d.]+)/ then vm[:wired] = $1.to_i
when /Pages occupied by compressor:\s+([\d.]+)/ then vm[:compressor] = $1.to_i
when /File-backed pages:\s+([\d.]+)/ then vm[:file_backed] = $1.to_i
when /Pages purgeable:\s+([\d.]+)/ then vm[:purgeable] = $1.to_i
end
end
to_mb = ->(pages) { (pages * page_size) / 1024.0 / 1024.0 }
used_mb = to_mb.call((vm[:active] || 0) + (vm[:wired] || 0) + (vm[:compressor] || 0) + (vm[:inactive] || 0))
cache_mb = to_mb.call((vm[:file_backed] || 0) + (vm[:purgeable] || 0))
app_mb = [used_mb - cache_mb, 0].max
system_summary = {
physical_memory: phys_mem_mb.round(0),
used_memory: used_mb.round(0),
filesystem_cache: cache_mb.round(0),
app_memory: app_mb.round(0),
free_memory: to_mb.call((vm[:free] || 0) + (vm[:speculative] || 0)).round(0),
}
# --- 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.
def parse_mem_mb(str)
m = str.match(/\A([\d.]+)([KMGB]?)/)
return nil unless m
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
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 ---
GROUP_RULES = [
[/com\.apple\.WebKit|Safari/i, "Safari / WebKit"],
[/Simulator|CoreSimulator|simctl/i, "Simulator"],
[/Xcode|SourceKit|clang|swift-frontend|IBAgent|XCBBuildService|devicectl/i, "Xcode Toolchain"],
[/OrbStack|orbctl|orb-/i, "OrbStack"],
[/Google Chrome|chrome_/i, "Google Chrome"],
[/Arc|arc\./i, "Arc Browser"],
[/Firefox|firefox/i, "Firefox"],
[/Slack/i, "Slack"],
[/Discord/i, "Discord"],
[/Spotify/i, "Spotify"],
[/docker|Docker/i, "Docker"],
[/Terminal|iTerm|Alacritty|kitty|ghostty|WezTerm/i, "Terminal"],
[/claude/i, "Claude"],
[/WindowServer/, "WindowServer"],
[/kernel_task/, "kernel_task"],
]
def group_name(command)
GROUP_RULES.each do |pattern, name|
return name if command.match?(pattern)
end
File.basename(command).sub(/\.\w+$/, "")
end
groups = Hash.new { |h, k| h[k] = { mem_mb: 0.0, count: 0 } }
processes.each do |proc|
name = group_name(proc[:command])
groups[name][:mem_mb] += proc[:mem_mb]
groups[name][:count] += 1
end
top_groups = groups.sort_by { -it[1][:mem_mb] }.first(options[:group_top])
# --- Output ---
if options[:json]
data = {
system: system_summary.transform_values(&:to_i),
top_groups: top_groups.map do |name, info|
{ group: name, footprint_mb: info[:mem_mb].round(1), process_count: info[:count] }
end,
note: "Physical footprint (matches Activity Monitor); shared pages are charged once.",
}
puts JSON.pretty_generate(data)
exit
end
bold = options[:color] ? "\e[1m" : ""
dim = options[:color] ? "\e[2m" : ""
cyan = options[:color] ? "\e[36m" : ""
reset = options[:color] ? "\e[0m" : ""
puts "#{bold}=== System Memory ==#{reset}"
puts
puts " Physical memory: #{cyan}#{human_bytes(system_summary[:physical_memory])}#{reset}"
puts " Used memory: #{human_bytes(system_summary[:used_memory])}"
puts " Filesystem cache: #{human_bytes(system_summary[:filesystem_cache])}"
puts " App memory: #{cyan}#{human_bytes(system_summary[:app_memory])}#{reset}"
puts " Free memory: #{human_bytes(system_summary[:free_memory])}"
puts
puts "#{bold}=== Top #{options[:group_top]} Grouped Programs ==#{reset}"
puts
puts " #{dim}%-28s %10s %8s#{reset}" % ["GROUP", "MEM", "PROCS"]
top_groups.each do |name, info|
puts " %-28s %10s %8d" % [name, human_bytes(info[:mem_mb]), info[:count]]
end
puts
puts "#{dim}Note: physical footprint (matches Activity Monitor); shared pages charged once.#{reset}"