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}"