What counts toward an app’s memory limit: why is reserved memory free, a mapped file cheap, and the same file read into a buffer expensive?
Predict first
How much does each way of holding 200 MB change the footprint?
import Foundation
import Darwin
func footprintMB() -> Int {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size)
_ = withUnsafeMutablePointer(to: &info) { $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) } }
return Int(info.phys_footprint / 1_048_576)
}
func touch(_ p: UnsafeMutableRawPointer, _ size: Int) { var i = 0; while i < size { p.storeBytes(of: 1, toByteOffset: i, as: UInt8.self); i += 4096 } }
func touchRead(_ p: UnsafeRawPointer, _ size: Int) -> Int { var s = 0; var i = 0; while i < size { s &+= Int(p.load(fromByteOffset: i, as: UInt8.self)); i += 4096 }; return s }
let size = 200 * 1_048_576
let base = footprintMB()
let anon = mmap(nil, size, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0)!
print("reserved 200 MB, untouched: +\(footprintMB() - base) MB")
touch(anon, size)
print("every page written: +\(footprintMB() - base) MB")
munmap(anon, size)
print("after munmap: +\(footprintMB() - base) MB")
let path = NSTemporaryDirectory() + "big-\(getpid()).bin"
FileManager.default.createFile(atPath: path, contents: Data((0..<size).lazy.map { UInt8(truncatingIfNeeded: $0 &* 31) }))
let b2 = footprintMB()
let fd = open(path, O_RDONLY)
let mapped = mmap(nil, size, PROT_READ, MAP_PRIVATE, fd, 0)!
_ = touchRead(mapped, size)
print("file mapped read-only, every page read: +\(footprintMB() - b2) MB")
munmap(mapped, size)
let b3 = footprintMB()
let buf = mmap(nil, size, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0)!
_ = read(fd, buf, size)
print("same file read(2) into memory: +\(footprintMB() - b3) MB")
munmap(buf, size); close(fd)
try? FileManager.default.removeItem(atPath: path)
- reserved 0 / written +200 / munmap 0 / mapped file read 0 / read(2) copy +200
- Every step adds 200 MB
- Mapping adds 200 MB but read(2) adds 0
Mental model. Renting a warehouse (reserved) costs nothing until you fill it; borrowing library books (clean, mapped) can be returned any time; photocopying them into your own binders (dirty) uses your shelf space.
The limit that gets an app killed is about dirty memory (plus compressed memory), what the system cannot simply discard or reload from disk (Q162). In the check, on macOS, reserving 200 MB of anonymous memory with mmap added 0 MB to the process footprint; writing one byte per page made it +200 MB; unmapping returned it to zero. A 200 MB file mapped read-only and read page by page added 0 MB, because those pages are clean and file-backed: the system can drop them and read them again. Reading the same file into an anonymous buffer with read(2) added +200 MB, because the copy is dirty.
So for big read-mostly data (a database file, a bundled index, a large asset) prefer memory mapping (Data(contentsOf:options: .alwaysMapped), mmap, SQLite’s mmap setting) over loading it, and avoid keeping decoded copies. Two cautions. Mapped pages still occupy RAM until reclaimed, and touching them is a disk read the first time (a hang if on the main thread); and a file on a volume that can disappear can fault your process. Also note that free does not always give pages back: large malloc blocks may stay resident for reuse, which is why this check uses mmap/munmap for exact accounting. What was not run: this is macOS accounting through task_vm_info; iOS applies a similar dirty-memory model but the limits, compression and jetsam behaviour were not measured on a device.
Only dirty pages count; reserved and clean file-backed pages do not.
Common traps
- Equating virtual size with the memory limit. Reserved and clean-mapped memory do not count the way dirty pages do.
- Loading a large read-only file into
Datawhen it could be mapped. It becomes dirty and counts.
They might follow up with
- How do I see the number? Xcode’s memory gauge and VM Tracker show dirty, swapped and resident; in code,
os_proc_available_memory()on iOS reports the headroom. - What about compressed memory? The system compresses cold dirty pages; they still count, at a reduced size.
Related questions
- 21.9.2 A 2 MB JPEG costs how much memory once it is shown, and what does downsampling at decode time change?
- 21.5.2 How do you reduce disk and database cost: batching writes, transactions, atomic writes, memory mapping and…
- 3.7.5 What is memory footprint, and what gets an iOS app killed?
A 2 MB JPEG costs how much memory once it is shown, and what does… →