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:
hariel1985
2026-09-16 13:53:47 +02:00
szülő 6f60dd7ad8
commit c9cd87f221
5 fájl változott, egészen pontosan 101 új sor hozzáadva és 10 régi sor törölve

Fájl megtekintése

@@ -8,7 +8,9 @@ struct ProcessItem: Identifiable, Hashable {
let user: String let user: String
let cpuUsage: Double // Per-core: 100% = 1 core fully utilized let cpuUsage: Double // Per-core: 100% = 1 core fully utilized
let cpuUsageTotal: Double // Normalized: 100% = all cores 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 threadCount: Int32
let state: ProcessState let state: ProcessState
let icon: NSImage? let icon: NSImage?
@@ -42,7 +44,9 @@ struct ProcessItem: Identifiable, Hashable {
diskReadBytes: UInt64 = 0, diskReadBytes: UInt64 = 0,
diskWriteBytes: UInt64 = 0, diskWriteBytes: UInt64 = 0,
energyImpact: Double = 0, energyImpact: Double = 0,
executablePath: String? = nil executablePath: String? = nil,
residentMemory: Int64 = 0,
compressedMemory: Int64 = 0
) { ) {
self.id = pid self.id = pid
self.pid = pid self.pid = pid
@@ -51,6 +55,8 @@ struct ProcessItem: Identifiable, Hashable {
self.cpuUsage = cpuUsage self.cpuUsage = cpuUsage
self.cpuUsageTotal = cpuUsageTotal self.cpuUsageTotal = cpuUsageTotal
self.memoryUsage = memoryUsage self.memoryUsage = memoryUsage
self.residentMemory = residentMemory
self.compressedMemory = compressedMemory
self.threadCount = threadCount self.threadCount = threadCount
self.state = state self.state = state
self.icon = icon self.icon = icon
@@ -73,6 +79,8 @@ struct ProcessItem: Identifiable, Hashable {
lhs.cpuUsage == rhs.cpuUsage && lhs.cpuUsage == rhs.cpuUsage &&
lhs.cpuUsageTotal == rhs.cpuUsageTotal && lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
lhs.memoryUsage == rhs.memoryUsage && lhs.memoryUsage == rhs.memoryUsage &&
lhs.residentMemory == rhs.residentMemory &&
lhs.compressedMemory == rhs.compressedMemory &&
lhs.threadCount == rhs.threadCount && lhs.threadCount == rhs.threadCount &&
lhs.state == rhs.state && lhs.state == rhs.state &&
lhs.diskReadRate == rhs.diskReadRate && lhs.diskReadRate == rhs.diskReadRate &&

Fájl megtekintése

@@ -106,6 +106,8 @@ final class ProcessMonitor {
) )
detail = DetailedSample( detail = DetailedSample(
memory: rusageData.memory, memory: rusageData.memory,
resident: rusageData.resident,
compressed: fetchCompressedMemory(pid: pid) ?? 0,
threads: taskInfo.pti_threadnum, threads: taskInfo.pti_threadnum,
cpu: cpu, cpu: cpu,
diskReadRate: extra.readRate, diskReadRate: extra.readRate,
@@ -153,7 +155,9 @@ final class ProcessMonitor {
diskReadBytes: detail.diskReadBytes, diskReadBytes: detail.diskReadBytes,
diskWriteBytes: detail.diskWriteBytes, diskWriteBytes: detail.diskWriteBytes,
energyImpact: detail.energy, 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). /// The expensive per-process readings (rusage + task info + derived rates).
private struct DetailedSample { private struct DetailedSample {
var memory: Int64 var memory: Int64
var resident: Int64
var compressed: Int64
var threads: Int32 var threads: Int32
var cpu: Double var cpu: Double
var diskReadRate: Double var diskReadRate: Double
@@ -242,12 +248,14 @@ final class ProcessMonitor {
var diskWriteBytes: UInt64 var diskWriteBytes: UInt64
var energy: Double 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) diskReadBytes: 0, diskWriteBytes: 0, energy: 0)
} }
private struct RusageData { private struct RusageData {
var memory: Int64 = 0 var memory: Int64 = 0
var resident: Int64 = 0
var userTime: UInt64 = 0 var userTime: UInt64 = 0
var systemTime: UInt64 = 0 var systemTime: UInt64 = 0
var diskRead: UInt64 = 0 var diskRead: UInt64 = 0
@@ -266,6 +274,7 @@ final class ProcessMonitor {
if result == 0 { if result == 0 {
return RusageData( return RusageData(
memory: Int64(rusage.ri_phys_footprint), memory: Int64(rusage.ri_phys_footprint),
resident: Int64(rusage.ri_resident_size),
userTime: rusage.ri_user_time, userTime: rusage.ri_user_time,
systemTime: rusage.ri_system_time, systemTime: rusage.ri_system_time,
diskRead: rusage.ri_diskio_bytesread, diskRead: rusage.ri_diskio_bytesread,
@@ -280,6 +289,7 @@ final class ProcessMonitor {
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize { if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
return RusageData( return RusageData(
memory: Int64(taskInfo.pti_resident_size), memory: Int64(taskInfo.pti_resident_size),
resident: Int64(taskInfo.pti_resident_size),
userTime: taskInfo.pti_total_user, userTime: taskInfo.pti_total_user,
systemTime: taskInfo.pti_total_system systemTime: taskInfo.pti_total_system
) )
@@ -491,6 +501,29 @@ func fetchProcessArguments(pid: pid_t) -> [String] {
return ProcArgs.parse(Array(buffer.prefix(size)))?.args ?? [] 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, …). /// Counts open file descriptors for a process (files, sockets, pipes, …).
func fetchOpenFileCount(pid: pid_t) -> Int? { func fetchOpenFileCount(pid: pid_t) -> Int? {
let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)

Fájl megtekintése

@@ -76,6 +76,8 @@ struct ProcessInspectorSheet: View {
DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal)) DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal))
DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact)) DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact))
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage)) 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: "Threads", value: "\(process.threadCount)")
DetailRow(label: "Open files", value: openFiles.map(String.init) ?? "…") DetailRow(label: "Open files", value: openFiles.map(String.init) ?? "…")
DetailRow(label: "Disk read/s", value: process.diskReadRate > 0 ? formatBytesPerSecond(process.diskReadRate) : "—") DetailRow(label: "Disk read/s", value: process.diskReadRate > 0 ? formatBytesPerSecond(process.diskReadRate) : "—")

Fájl megtekintése

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
enum ProcessSortColumn: String { 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 { struct ProcessView: View {
@@ -60,6 +60,10 @@ struct ProcessView: View {
comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame) comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame)
case .memory: case .memory:
comparison = lhs.memoryUsage < rhs.memoryUsage ? .orderedAscending : (lhs.memoryUsage > rhs.memoryUsage ? .orderedDescending : .orderedSame) 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: case .threads:
comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame) comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame)
case .user: case .user:
@@ -87,6 +91,34 @@ struct ProcessView: View {
return monitor.processes.first { $0.pid == pid } 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Summary bar - separate view to avoid re-rendering table // Summary bar - separate view to avoid re-rendering table
@@ -140,11 +172,7 @@ struct ProcessView: View {
} }
.width(60) .width(60)
TableColumn("Memory", value: \.memoryUsage) { process in memoryColumns
Text(formatBytes(process.memoryUsage))
.monospacedDigit()
}
.width(80)
TableColumn("Disk I/O", value: \.diskTotalRate) { process in TableColumn("Disk I/O", value: \.diskTotalRate) { process in
if process.diskTotalRate > 0 { if process.diskTotalRate > 0 {
@@ -246,6 +274,10 @@ struct ProcessView: View {
sortColumn = .energy sortColumn = .energy
} else if keyPathString.contains("diskTotalRate") { } else if keyPathString.contains("diskTotalRate") {
sortColumn = .disk sortColumn = .disk
} else if keyPathString.contains("residentMemory") {
sortColumn = .resident
} else if keyPathString.contains("compressedMemory") {
sortColumn = .compressed
} else if keyPathString.contains("memoryUsage") { } else if keyPathString.contains("memoryUsage") {
sortColumn = .memory sortColumn = .memory
} else if keyPathString.contains("name") { } else if keyPathString.contains("name") {

Fájl megtekintése

@@ -107,6 +107,22 @@ final class ProcessDataTests: XCTestCase {
let zero = own.filter { $0.memoryUsage == 0 }.count let zero = own.filter { $0.memoryUsage == 0 }.count
XCTAssertLessThan(zero, max(3, own.count / 10), XCTAssertLessThan(zero, max(3, own.count / 10),
"fetch \(fetch): \(zero) of \(own.count) own processes reported 0 memory") "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))
}
} }