How I export 1.2-gigapixel images on an iPhone without running out of memory
DEV Community

How I export 1.2-gigapixel images on an iPhone without running out of memory

The Problem: "Compressed File Size" Is a Lie About Memory

Mozary lays photos out on a grid. A typical high-res export is a 200 Γ— 267 grid with each tile drawn at 150px:

  • width: 200 Γ— 150 = 30,000 px
  • height: 267 Γ— 150 = 40,050 px

That's ~1.2 gigapixels. Here's the part people underestimate: the final JPEG is only a few hundred MB to ~2 GB because JPEG is compressed. But while you're drawing, the canvas is uncompressed - 4 bytes per pixel (RGBA):

30,000 Γ— 40,050 Γ— 4 bytes β‰ˆ 4.8 GB

4.8 GB of RAM for the canvas alone. iOS caps how much memory an app may use (varies by device, but you'll get jetsammed somewhere in the few-hundred-MB-to-~2-GB range). 4.8 GB is never happening. That's the crash.

The Naive Way (What I Had Before)

My original export looked like this - and honestly, it was brute force:

// ❌ OLD: allocate the giant bitmap in RAM

// "Pre-flight": compute how many bytes the bitmap needs
let bitmapBytes = UInt64(imgWidth) * UInt64(imgHeight) * 4
let availableMemory = os_proc_available_memory()
if UInt64(Double(bitmapBytes) * 1.3) > availableMemory {
    // Not enough β†’ just give up on high-res
    throw NSError(domain: "MediaExporter", code: -10, ...)
}

// data: nil β†’ CGContext allocates the bitmap in RAM
let context = CGContext(data: nil,
                        width: imgWidth,
                        height: imgHeight,
                        bitsPerComponent: 8,
                        bytesPerRow: imgWidth * 4,
                        space: colorSpace,
                        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!

// ... draw all the tiles ...

// makeImage() duplicates the whole bitmap (another +4.8 GB)
let cgImage = context.makeImage()!

Two problems:

  • CGContext(data: nil, ...) allocates the canvas in RAM β†’ asks for 4.8 GB.
  • context.makeImage() copies the canvas again β†’ +4.8 GB at peak.

The os_proc_available_memory() check wasn't a fix - it was a polite "sorry, give up." High-res export only worked on the beefiest devices, and mid-tier sizes could sneak past the check and then crash on the makeImage() copy anyway.

The Fix: Put the Canvas on Disk, Not in RAM

The reframe that unlocked everything: If 4.8 GB won't fit in RAM, don't put it in RAM. Enter the memory-mapped file (mmap).

What mmap Actually Buys You

mmap maps a file into your address space. You get a pointer you read and write like normal memory - but the backing store is a file on disk. The magic is that the OS manages that region in pages:

  • Pages you write become "dirty" and the OS writes them back to disk as needed.
  • Once written back (clean), those pages no longer count against your app's memory footprint (the thing that gets you jetsammed).

So even if the canvas is 4.8 GB, the only thing resident in RAM is the handful of pages you're touching right now. Everything else lives on disk. You never trip the memory limit.

In one sentence: I stopped trying to fit the work in the app's own memory, and the crash disappeared.

Step 1 - Create the File and Map It

let ppc = size.effectivePixelsPerCell(columns: columns, rows: rows)
let imgWidth = columns * ppc
let imgHeight = rows * ppc
let bytesPerRow = imgWidth * 4
let totalBytes = bytesPerRow * imgHeight  // ← can be ~4.8 GB

// 1. Create a temp file
let canvasURL = FileManager.default.temporaryDirectory
    .appendingPathComponent("mozary_canvas_\(UUID().uuidString).raw")
FileManager.default.createFile(atPath: canvasURL.path, contents: nil)

// 2. Open it and grow it to the needed size (ftruncate)
let fd = open(canvasURL.path, O_RDWR)
guard fd >= 0, ftruncate(fd, off_t(totalBytes)) == 0 else {
    if fd >= 0 { close(fd) }
    try? FileManager.default.removeItem(at: canvasURL)
    throw NSError(domain: "MediaExporter", code: -11, ...)
}

// 3. Map the file into memory
let mapped = mmap(nil, totalBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)
close(fd)  // The mapping keeps the file alive, so we can close the fd
guard let canvas = mapped, canvas != MAP_FAILED else {
    try? FileManager.default.removeItem(at: canvasURL)
    throw NSError(domain: "MediaExporter", code: -12, ...)
}

// 4. ALWAYS release the mapping, on every exit path
defer {
    munmap(canvas, totalBytes)
    try? FileManager.default.removeItem(at: canvasURL)
}

ftruncate grows the file to totalBytes, but it creates a sparse file - disk is only actually consumed for the bytes you write.

Step 2 - Hand the Mapping to CGContext

CGContext lets you pass your own buffer pointer as data:. Just give it the mmap pointer:

guard let context = CGContext(data: canvas,  // ← the mmap'd file, not RAM
                              width: imgWidth,
                              height: imgHeight,
                              bitsPerComponent: 8,
                              bytesPerRow: bytesPerRow,
                              space: CGColorSpaceCreateDeviceRGB(),
                              bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
    throw NSError(...)
}

// Then just... draw. The destination is transparently disk-backed;
// the Core Graphics code doesn't change at all.
for row in 0..<rows {
    try Task.checkCancellation()
    for col in 0..<columns {
        // ... context.draw(tileCGImage, in: rect) ...
    }
}

This is the beautiful part: from Core Graphics' point of view, nothing changed. It thinks it's drawing into a plain memory buffer. The writes just quietly spill to disk.

Step 3 - Nudge Finished Rows Out to Disk

The OS will page dirty pages out on its own, but you can help it: tell it explicitly "I'm done with this region, feel free to flush it." That keeps the set of dirty (not-yet-written-back) pages small. The tool is msync:

if row % 10 == 0 {
    progress(0.2 + cellProgress * 0.65)
    // Every 40 rows, async-flush the scanlines we've already finished.
    if row % 40 == 0, row > 0 {
        let flushedBytes = (row * ppc) * bytesPerRow
        msync(canvas, flushedBytes, MS_ASYNC)  // MS_ASYNC = non-blocking
    }
}

Why (row * ppc) * bytesPerRow? A bitmap is a single contiguous byte array in memory - rows laid out top to bottom. My draw loop goes top row β†’ bottom row, so once I'm on row N, every row above it is finished and lives at the start of the buffer:

buffer: [==== finished ====][ drawing ][=== empty ===]
        ↑ canvas↑            |←── flushedBytes ──→|
  • row * ppc β†’ number of finished pixel rows (ppc = pixels per cell)
  • Γ— bytesPerRow β†’ convert rows to bytes

One nuance worth stating precisely: MS_ASYNC doesn't free memory directly. It marks those pages clean (safely written to disk). Clean pages are the ones the OS can evict for free when it needs RAM back - because an identical copy already exists on disk. So msync doesn't reclaim memory; it makes memory reclaimable, which keeps your peak footprint low.

The Other Trap: makeImage() Copies Everything

Second landmine: encoding the JPEG. Remember context.makeImage() duplicates the whole bitmap. Call it here and you've just asked for +4.8 GB - undoing everything. So skip it. Wrap the same mmap region in a CGImage directly, handing the pointer to CGDataProvider with no copy:

// Make sure the whole canvas is synced to disk
msync(canvas, totalBytes, MS_SYNC)

// Wrap the mapping in a CGImage WITHOUT copying it.
// The empty releaseData is intentional: the deferred munmap above
// owns the mapping, and it outlives this scope-local image.
guard let provider = CGDataProvider(dataInfo: nil,
                                    data: canvas,
                                    size: totalBytes,
                                    releaseData: { _, _, _ in }),  // no-op
      let cgImage = CGImage(width: imgWidth,
                            height: imgHeight,
                            bitsPerComponent: 8,
                            bitsPerPixel: 32,
                            bytesPerRow: bytesPerRow,
                            space: CGColorSpaceCreateDeviceRGB(),
                            bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
                            provider: provider,
                            decode: nil,
                            shouldInterpolate: false,
                            intent: .defaultIntent) else {
    throw NSError(...)
}

// Write JPEG straight to a file (no UIImage in between)
let tempURL = FileManager.default.temporaryDirectory
    .appendingPathComponent(UUID().uuidString)
    .appendingPathExtension("jpg")
guard let dest = CGImageDestinationCreateWithURL(tempURL as CFURL,
                                                  "public.jpeg" as CFString,
                                                  1, nil) else {
    throw NSError(...)
}
CGImageDestinationAddImage(dest, cgImage,
    [kCGImageDestinationLossyCompressionQuality: size.jpegQuality] as CFDictionary)
guard CGImageDestinationFinalize(dest) else {
    throw NSError(...)
}
return tempURL

CGImageDestinationFinalize reads from the CGImage (i.e. the mmap'd file) while it writes the JPEG. The encoder pulls data in tiles, so no giant buffer ever lands fully in RAM here either.

Result: both ends of the pipeline - the canvas input and the JPEG output - stay off the RAM budget.

Bonus Win: Decode Tiles at Draw Size, Not Full Size

One more thing that mattered. Mozary stores each tile's thumbnail as a 600px JPEG on disk. Decode all of them at full size for a 1,000-photo mosaic and:

600 Γ— 600 Γ— 4 bytes Γ— 1000 photos β‰ˆ 1.4 GB

1.4 GB just for the tile cache - but I only ever draw them at cell size (ppc, 60–150px). So decode them straight to that size with CGImageSourceCreateThumbnailAtIndex:

let thumbOptions = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceShouldCacheImmediately: true,
    kCGImageSourceCreateThumbnailWithTransform: true,
    kCGImageSourceThumbnailMaxPixelSize: ppc  // ← decode at cell size
] as CFDictionary
// CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOptions)

At 100px a tile is ~40 KB decoded. 1,000 of them fit in a few dozen MB. Never decode at a higher resolution than you'll draw - downsampling on decode is a Core Graphics rule of thumb worth tattooing somewhere.

Spending Disk Means Guarding Disk

Trading RAM for disk creates a new constraint: free disk space. During export the uncompressed canvas (4.8 GB) and the JPEG being written (~2 GB) coexist - a peak of ~7 GB. So I check the actual working space needed, not the final file size, before starting:

// The final "~2 GB" file size badly understates what export transiently needs.
let requiredBytes = size.requiredWorkingBytes(columns: columns, rows: rows)
let freeSpace = (try? FileManager.default.temporaryDirectory
    .resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
    .volumeAvailableCapacityForImportantUsage) ?? 0
if freeSpace > 0, freeSpace < requiredBytes {
    throw NSError(...)  // "needs about N GB of temporary free space"
}

func requiredWorkingBytes(columns: Int, rows: Int) -> Int64 {
    let ppc = effectivePixelsPerCell(columns: columns, rows: rows)
    let pixels = Double(columns * ppc) * Double(rows * ppc)
    // uncompressed canvas (4B/px) + JPEG being encoded + safety margin
    return Int64(pixels * 4 + pixels * estimatedBytesPerPixel) + 500_000_000
}

I actually found this the hard way. On a test device with ~2 GB free, the export failed even though my original check (which only accounted for the final file) said there was room. Testing on a nearly-full device is what surfaced it - the real working set needs the transient space, obviously in hindsight. Glad I debugged on a cramped device.

And if the app gets killed mid-export, the defer cleanup never runs and a 2 GB+ temp file is orphaned - which would then eat into the next export's disk check. So I sweep old canvas files on start:

// Sweep temp canvases orphaned by a previous crash
if let leftovers = try? FileManager.default.contentsOfDirectory(
    at: FileManager.default.temporaryDirectory,
    includingPropertiesForKeys: nil) {
    for url in leftovers where url.lastPathComponent.hasPrefix("mozary_canvas_") {
        try? FileManager.default.removeItem(at: url)
    }
}

(I also clamp cell size so no dimension exceeds JPEG's 65,535px limit, plus a few other small guards.)

Results

Before After
High-res export Rejected by pre-flight, or crashed on some devices Works
Peak RAM ~4.8 GB (canvas) + ~4.8 GB (makeImage copy) A few dozen MB, flat
Depends on output size? Yes - bigger = death No - constant footprint

Ordinary iPhones now export 1.2-gigapixel mosaics without breaking a sweat.

Takeaways

  • On iOS, drawing a big image blows up because the uncompressed canvas (4 B/px) exceeds the memory limit - the compressed file size tells you nothing about that.
  • Back the canvas with mmap so writes page out to disk and stay off your app's memory footprint.
  • For the output CGImage, avoid context.makeImage() (it copies) - wrap the mmap region with CGDataProvider for a zero-copy hand-off, then stream straight to JPEG.

Comments

No comments yet. Start the discussion.