mirror of
https://github.com/samsonjs/bin.git
synced 2026-06-24 04:49:05 +00:00
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.
179 lines
5.7 KiB
Ruby
Executable file
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}"
|