Show RAM and compressed memory per process
Adds two columns to the Processes table (and rows to the inspector) next to Memory, which is the physical footprint: - RAM: pages currently in physical memory (ri_resident_size, from the proc_pid_rusage call that was already being made). - Compressed: the original size of the process's pages held by the compressor, via task_name_for_pid + TASK_VM_INFO. This is also the closest per-process swap figure: macOS pages out compressor segments, not processes, and those stay counted here. Activity Monitor's "Compressed Memory" and top's CMPRS report the same value. Both need no privileges for the user's own processes; other users' and system processes report 0, as the Memory column already did. Values match top/ps within sampling noise; a full refresh of ~650 processes including the extra call takes ~5 ms. Memory, RAM and Compressed live in one @TableColumnBuilder property: Table takes at most 10 columns per block, and inlining them also exceeded the type checker's time limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
let user: String
|
||||
let cpuUsage: Double // Per-core: 100% = 1 core fully utilized
|
||||
let cpuUsageTotal: Double // Normalized: 100% = all cores fully utilized
|
||||
let memoryUsage: Int64
|
||||
let memoryUsage: Int64 // physical footprint (Activity Monitor's "Memory")
|
||||
let residentMemory: Int64 // pages currently in physical RAM, shared ones included
|
||||
let compressedMemory: Int64 // original size of pages in the compressor — in RAM or swapped out
|
||||
let threadCount: Int32
|
||||
let state: ProcessState
|
||||
let icon: NSImage?
|
||||
@@ -42,7 +44,9 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
diskReadBytes: UInt64 = 0,
|
||||
diskWriteBytes: UInt64 = 0,
|
||||
energyImpact: Double = 0,
|
||||
executablePath: String? = nil
|
||||
executablePath: String? = nil,
|
||||
residentMemory: Int64 = 0,
|
||||
compressedMemory: Int64 = 0
|
||||
) {
|
||||
self.id = pid
|
||||
self.pid = pid
|
||||
@@ -51,6 +55,8 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
self.cpuUsage = cpuUsage
|
||||
self.cpuUsageTotal = cpuUsageTotal
|
||||
self.memoryUsage = memoryUsage
|
||||
self.residentMemory = residentMemory
|
||||
self.compressedMemory = compressedMemory
|
||||
self.threadCount = threadCount
|
||||
self.state = state
|
||||
self.icon = icon
|
||||
@@ -73,6 +79,8 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
lhs.cpuUsage == rhs.cpuUsage &&
|
||||
lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
|
||||
lhs.memoryUsage == rhs.memoryUsage &&
|
||||
lhs.residentMemory == rhs.residentMemory &&
|
||||
lhs.compressedMemory == rhs.compressedMemory &&
|
||||
lhs.threadCount == rhs.threadCount &&
|
||||
lhs.state == rhs.state &&
|
||||
lhs.diskReadRate == rhs.diskReadRate &&
|
||||
|
||||
@@ -106,6 +106,8 @@ final class ProcessMonitor {
|
||||
)
|
||||
detail = DetailedSample(
|
||||
memory: rusageData.memory,
|
||||
resident: rusageData.resident,
|
||||
compressed: fetchCompressedMemory(pid: pid) ?? 0,
|
||||
threads: taskInfo.pti_threadnum,
|
||||
cpu: cpu,
|
||||
diskReadRate: extra.readRate,
|
||||
@@ -153,7 +155,9 @@ final class ProcessMonitor {
|
||||
diskReadBytes: detail.diskReadBytes,
|
||||
diskWriteBytes: detail.diskWriteBytes,
|
||||
energyImpact: detail.energy,
|
||||
executablePath: pathCache[pid]
|
||||
executablePath: pathCache[pid],
|
||||
residentMemory: detail.resident,
|
||||
compressedMemory: detail.compressed
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,6 +238,8 @@ final class ProcessMonitor {
|
||||
/// The expensive per-process readings (rusage + task info + derived rates).
|
||||
private struct DetailedSample {
|
||||
var memory: Int64
|
||||
var resident: Int64
|
||||
var compressed: Int64
|
||||
var threads: Int32
|
||||
var cpu: Double
|
||||
var diskReadRate: Double
|
||||
@@ -242,12 +248,14 @@ final class ProcessMonitor {
|
||||
var diskWriteBytes: UInt64
|
||||
var energy: Double
|
||||
|
||||
static let zero = DetailedSample(memory: 0, threads: 0, cpu: 0, diskReadRate: 0, diskWriteRate: 0,
|
||||
static let zero = DetailedSample(memory: 0, resident: 0, compressed: 0, threads: 0, cpu: 0,
|
||||
diskReadRate: 0, diskWriteRate: 0,
|
||||
diskReadBytes: 0, diskWriteBytes: 0, energy: 0)
|
||||
}
|
||||
|
||||
private struct RusageData {
|
||||
var memory: Int64 = 0
|
||||
var resident: Int64 = 0
|
||||
var userTime: UInt64 = 0
|
||||
var systemTime: UInt64 = 0
|
||||
var diskRead: UInt64 = 0
|
||||
@@ -266,6 +274,7 @@ final class ProcessMonitor {
|
||||
if result == 0 {
|
||||
return RusageData(
|
||||
memory: Int64(rusage.ri_phys_footprint),
|
||||
resident: Int64(rusage.ri_resident_size),
|
||||
userTime: rusage.ri_user_time,
|
||||
systemTime: rusage.ri_system_time,
|
||||
diskRead: rusage.ri_diskio_bytesread,
|
||||
@@ -280,6 +289,7 @@ final class ProcessMonitor {
|
||||
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
|
||||
return RusageData(
|
||||
memory: Int64(taskInfo.pti_resident_size),
|
||||
resident: Int64(taskInfo.pti_resident_size),
|
||||
userTime: taskInfo.pti_total_user,
|
||||
systemTime: taskInfo.pti_total_system
|
||||
)
|
||||
@@ -491,6 +501,29 @@ func fetchProcessArguments(pid: pid_t) -> [String] {
|
||||
return ProcArgs.parse(Array(buffer.prefix(size)))?.args ?? []
|
||||
}
|
||||
|
||||
/// Memory of a process currently held by the compressor, at its original
|
||||
/// (uncompressed) size. macOS does not track swap per process: when compressor
|
||||
/// segments are paged out to disk they stay counted here, so this is the
|
||||
/// closest per-process "swapped" figure that exists — Activity Monitor's
|
||||
/// "Compressed Memory" and `top`'s CMPRS show the same value.
|
||||
///
|
||||
/// Needs only a task *name* port, which the kernel hands out for processes of
|
||||
/// the same user; returns `nil` for other users' and system processes.
|
||||
func fetchCompressedMemory(pid: pid_t) -> Int64? {
|
||||
var port: mach_port_name_t = 0
|
||||
guard task_name_for_pid(mach_task_self_, pid, &port) == KERN_SUCCESS else { return nil }
|
||||
defer { mach_port_deallocate(mach_task_self_, port) }
|
||||
|
||||
var info = task_vm_info_data_t()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.stride / MemoryLayout<natural_t>.stride)
|
||||
let result = withUnsafeMutablePointer(to: &info) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||
task_info(port, task_flavor_t(TASK_VM_INFO), $0, &count)
|
||||
}
|
||||
}
|
||||
return result == KERN_SUCCESS ? Int64(info.compressed) : nil
|
||||
}
|
||||
|
||||
/// Counts open file descriptors for a process (files, sockets, pipes, …).
|
||||
func fetchOpenFileCount(pid: pid_t) -> Int? {
|
||||
let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
|
||||
|
||||
@@ -76,6 +76,8 @@ struct ProcessInspectorSheet: View {
|
||||
DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal))
|
||||
DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact))
|
||||
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage))
|
||||
DetailRow(label: "RAM", value: formatBytes(process.residentMemory))
|
||||
DetailRow(label: "Compressed", value: process.compressedMemory > 0 ? formatBytes(process.compressedMemory) : "—")
|
||||
DetailRow(label: "Threads", value: "\(process.threadCount)")
|
||||
DetailRow(label: "Open files", value: openFiles.map(String.init) ?? "…")
|
||||
DetailRow(label: "Disk read/s", value: process.diskReadRate > 0 ? formatBytesPerSecond(process.diskReadRate) : "—")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ProcessSortColumn: String {
|
||||
case name, pid, cpu, cpuTotal, memory, threads, user, state, energy, disk
|
||||
case name, pid, cpu, cpuTotal, memory, resident, compressed, threads, user, state, energy, disk
|
||||
}
|
||||
|
||||
struct ProcessView: View {
|
||||
@@ -60,6 +60,10 @@ struct ProcessView: View {
|
||||
comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame)
|
||||
case .memory:
|
||||
comparison = lhs.memoryUsage < rhs.memoryUsage ? .orderedAscending : (lhs.memoryUsage > rhs.memoryUsage ? .orderedDescending : .orderedSame)
|
||||
case .resident:
|
||||
comparison = lhs.residentMemory < rhs.residentMemory ? .orderedAscending : (lhs.residentMemory > rhs.residentMemory ? .orderedDescending : .orderedSame)
|
||||
case .compressed:
|
||||
comparison = lhs.compressedMemory < rhs.compressedMemory ? .orderedAscending : (lhs.compressedMemory > rhs.compressedMemory ? .orderedDescending : .orderedSame)
|
||||
case .threads:
|
||||
comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame)
|
||||
case .user:
|
||||
@@ -87,6 +91,34 @@ struct ProcessView: View {
|
||||
return monitor.processes.first { $0.pid == pid }
|
||||
}
|
||||
|
||||
/// Memory, RAM and Compressed as a single builder element: Table accepts at
|
||||
/// most 10 columns per block, and inlining them also exceeds the type
|
||||
/// checker's time limit for the table expression.
|
||||
@TableColumnBuilder<ProcessItem, KeyPathComparator<ProcessItem>>
|
||||
private var memoryColumns: some TableColumnContent<ProcessItem, KeyPathComparator<ProcessItem>> {
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("RAM", value: \.residentMemory) { process in
|
||||
Text(formatBytes(process.residentMemory))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("Compressed", value: \.compressedMemory) { process in
|
||||
if process.compressedMemory > 0 {
|
||||
Text(formatBytes(process.compressedMemory))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text("—").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.width(85)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Summary bar - separate view to avoid re-rendering table
|
||||
@@ -140,11 +172,7 @@ struct ProcessView: View {
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
memoryColumns
|
||||
|
||||
TableColumn("Disk I/O", value: \.diskTotalRate) { process in
|
||||
if process.diskTotalRate > 0 {
|
||||
@@ -246,6 +274,10 @@ struct ProcessView: View {
|
||||
sortColumn = .energy
|
||||
} else if keyPathString.contains("diskTotalRate") {
|
||||
sortColumn = .disk
|
||||
} else if keyPathString.contains("residentMemory") {
|
||||
sortColumn = .resident
|
||||
} else if keyPathString.contains("compressedMemory") {
|
||||
sortColumn = .compressed
|
||||
} else if keyPathString.contains("memoryUsage") {
|
||||
sortColumn = .memory
|
||||
} else if keyPathString.contains("name") {
|
||||
|
||||
@@ -107,6 +107,22 @@ final class ProcessDataTests: XCTestCase {
|
||||
let zero = own.filter { $0.memoryUsage == 0 }.count
|
||||
XCTAssertLessThan(zero, max(3, own.count / 10),
|
||||
"fetch \(fetch): \(zero) of \(own.count) own processes reported 0 memory")
|
||||
let zeroRAM = own.filter { $0.residentMemory == 0 }.count
|
||||
XCTAssertLessThan(zeroRAM, max(3, own.count / 10),
|
||||
"fetch \(fetch): \(zeroRAM) of \(own.count) own processes reported 0 RAM")
|
||||
}
|
||||
}
|
||||
|
||||
func testOwnProcessReportsResidentMemory() {
|
||||
let item = ProcessMonitor().fetchProcesses().first { $0.pid == getpid() }
|
||||
XCTAssertNotNil(item)
|
||||
XCTAssertGreaterThan(item?.residentMemory ?? 0, 0)
|
||||
XCTAssertGreaterThan(item?.memoryUsage ?? 0, 0)
|
||||
}
|
||||
|
||||
/// A task name port is granted for our own processes only; launchd runs as root.
|
||||
func testCompressedMemoryReadableForOwnProcessesOnly() {
|
||||
XCTAssertNotNil(fetchCompressedMemory(pid: getpid()))
|
||||
XCTAssertNil(fetchCompressedMemory(pid: 1))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user