// Measures what dictation apps cost on a Mac: memory (the "Memory" column of // Activity Monitor, i.e. physical footprint) and CPU, summed over every // process that belongs to each app, sampled over time. For comparisons such // as Vokalu against another app; run the same scenario for each. // // swift dev/bench/resources.swift --app Vokalu --app "Wispr Flow" --seconds 120 --label idle // // A process belongs to an app when its executable is inside the app's bundle // (Electron helpers) or macOS lists the app as responsible for it (the WebKit // helpers of a native web view, which live outside the bundle), the same // grouping Activity Monitor uses. CPU is in % of one core. Writes a CSV of // every sample next to the summary (dev/bench/out/, git-ignored). import Darwin import Foundation var apps: [String] = [] var seconds = 60.0 var interval = 1.0 var label = "run" var args = CommandLine.arguments.dropFirst().makeIterator() while let a = args.next() { switch a { case "--app": apps.append(args.next() ?? "") case "--seconds": seconds = Double(args.next() ?? "") ?? seconds case "--interval": interval = Double(args.next() ?? "") ?? interval case "--label": label = args.next() ?? label default: print("unknown option \(a)"); exit(2) } } if apps.isEmpty { print("usage: swift dev/bench/resources.swift --app [--app ] [--seconds 60] [--interval 1] [--label idle]"); exit(2) } typealias ResponsibleFn = @convention(c) (Int32) -> Int32 let responsible: ResponsibleFn? = dlsym(dlopen(nil, RTLD_NOW), "responsibility_get_pid_responsible_for_pid").map { unsafeBitCast($0, to: ResponsibleFn.self) } func allPids() -> [Int32] { var pids = [Int32](repeating: 0, count: 8192) let n = proc_listallpids(&pids, Int32(pids.count * MemoryLayout.size)) return Array(pids.prefix(Int(max(n, 0)))).filter { $0 > 0 } } func path(_ pid: Int32) -> String { var buf = [CChar](repeating: 0, count: 4096) return proc_pidpath(pid, &buf, UInt32(buf.count)) > 0 ? String(cString: buf) : "" } func bundle(of app: String) -> String? { for dir in ["/Applications", "\(NSHomeDirectory())/Applications"] { let p = "\(dir)/\(app).app" if FileManager.default.fileExists(atPath: p) { return p + "/" } } return nil } /// Every process of the app right now, with a short name for each. func processes(in bundlePath: String) -> [Int32: String] { var mine: [Int32: String] = [:] let pids = allPids() let paths = Dictionary(uniqueKeysWithValues: pids.map { ($0, path($0)) }) for (pid, p) in paths where p.hasPrefix(bundlePath) { mine[pid] = (p as NSString).lastPathComponent } if let responsible { for (pid, p) in paths where mine[pid] == nil { let owner = responsible(pid) if owner != pid, mine[owner] != nil { mine[pid] = (p as NSString).lastPathComponent } } } return mine } struct Usage { var footprint: UInt64; var cpuNs: UInt64 } var timebase = mach_timebase_info_data_t() mach_timebase_info(&timebase) func usage(_ pid: Int32) -> Usage? { var info = rusage_info_v4() let ok = withUnsafeMutablePointer(to: &info) { $0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { proc_pid_rusage(pid, RUSAGE_INFO_V4, $0) } } guard ok == 0 else { return nil } // CPU times are in Mach time units (not nanoseconds on Apple silicon). let cpu = (info.ri_user_time + info.ri_system_time) * UInt64(timebase.numer) / UInt64(timebase.denom) return Usage(footprint: info.ri_phys_footprint, cpuNs: cpu) } func du(_ path: String) -> UInt64 { var total: UInt64 = 0 if let e = FileManager.default.enumerator(at: URL(fileURLWithPath: path), includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) { for case let url as URL in e { total += UInt64((try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize) ?? 0) } } return total } let mb = { (b: Double) in String(format: "%.0f MB", b / 1_048_576) } var bundles: [String: String] = [:] for app in apps { guard let b = bundle(of: app) else { print("\(app): not found in /Applications"); exit(1) } if processes(in: b).isEmpty { print("\(app): not running. Open it first."); exit(1) } bundles[app] = b } let outDir = URL(fileURLWithPath: CommandLine.arguments[0]).deletingLastPathComponent().appendingPathComponent("out") try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") var csv = "time_s,app,processes,memory_mb,cpu_percent\n" print("Measuring \(apps.joined(separator: " and ")) for \(Int(seconds)) s (\(label))...") var last: [String: [Int32: UInt64]] = [:] var samples: [String: [(mem: Double, cpu: Double, count: Int)]] = [:] var peakBreakdown: [String: [(String, UInt64)]] = [:] var peakMem: [String: UInt64] = [:] let start = Date() var t0 = start while true { for app in apps { let procs = processes(in: bundles[app]!) var mem: UInt64 = 0 var cpuDelta: UInt64 = 0 var now: [Int32: UInt64] = [:] var breakdown: [(String, UInt64)] = [] for (pid, name) in procs { guard let u = usage(pid) else { continue } mem += u.footprint now[pid] = u.cpuNs breakdown.append((name, u.footprint)) if let before = last[app]?[pid], u.cpuNs >= before { cpuDelta += u.cpuNs - before } } let elapsedNs = Date().timeIntervalSince(t0) * 1e9 if last[app] != nil { let cpu = Double(cpuDelta) / elapsedNs * 100 samples[app, default: []].append((Double(mem), cpu, procs.count)) csv += String(format: "%.1f,%@,%d,%.1f,%.2f\n", Date().timeIntervalSince(start), app, procs.count, Double(mem) / 1_048_576, cpu) } if mem > peakMem[app, default: 0] { peakMem[app] = mem; peakBreakdown[app] = breakdown.sorted { $0.1 > $1.1 } } last[app] = now } t0 = Date() if Date().timeIntervalSince(start) >= seconds { break } Thread.sleep(forTimeInterval: interval) } var report = "Resource use, \(label), \(Int(seconds)) s, \(Host.current().localizedName ?? "Mac"), macOS \(ProcessInfo.processInfo.operatingSystemVersionString)\n\n" for app in apps { let s = samples[app] ?? [] guard !s.isEmpty else { continue } let avgMem = s.map(\.mem).reduce(0, +) / Double(s.count) let avgCpu = s.map(\.cpu).reduce(0, +) / Double(s.count) report += "\(app)\n" report += " memory: average \(mb(avgMem)), peak \(mb(Double(peakMem[app] ?? 0)))\n" report += String(format: " CPU: average %.1f%%, peak %.1f%% (of one core)\n", avgCpu, s.map(\.cpu).max() ?? 0) report += " processes: \(s.map(\.count).max() ?? 0)\n" report += " on disk: \(mb(Double(du(bundles[app]!))))\n" report += " at peak: " + (peakBreakdown[app] ?? []).prefix(6).map { "\($0.0) \(mb(Double($0.1)))" }.joined(separator: ", ") + "\n\n" } print(report, terminator: "") let base = outDir.appendingPathComponent("\(stamp)-\(label)") try? csv.write(to: base.appendingPathExtension("csv"), atomically: true, encoding: .utf8) try? report.write(to: base.appendingPathExtension("txt"), atomically: true, encoding: .utf8) print("Saved: \(base.path).txt and .csv")