WWDC Quick Look đź’“ By SwiftGGTeam
Improve memory usage and performance with Swift

Improve memory usage and performance with Swift

Watch original video

Highlight

Nate Cook walked through five rounds of optimization on a QOI image parser. With Swift 6.2’s InlineArray and Span, the final version ran 700 times faster than the first, and not a single line of unsafe code was used.


Core Content

At WWDC25, Nate Cook opened a QOI image viewer he had built himself. A small icon of a few KB opened instantly. Switching to a slightly larger bird photo, it took several seconds to appear. That is how a performance problem enters the stage: everything works fine on test data, then chokes on real data.

He did not pull out grand architecture. He fired up Instruments, found the problem with one tool, then went back to the code and changed one line. The entire session revolved around five iterations of the same QOI parser — fixing the algorithm, removing allocations, eliminating exclusivity checks, moving heap memory to the stack, and killing reference counts with Span. At each step, he first saw the symptom in Instruments (platform_memmove dominating the flame graph, nearly a million transient allocations, swift_beginAccess showing up frequently, swift_retain/swift_release each taking 7%), then used Swift 6.2’s InlineArray, RawSpan, and OutputSpan to cure it. In the end, the original quadratic complexity became linear, and combined with the memory management optimizations, the whole thing ran 700 times faster.


Detailed Content

Round 1: Algorithm error (07:01). The flame graph showed platform_memmove taking most samples. The culprit was readByte() using Data.dropFirst() and then wrapping the remaining data in Data.init, copying the entire remaining data on every single byte read. Changed to popFirst():

import Foundation

extension Data {
  /// Consume a single byte from the start of this data.
  mutating func readByte() -> UInt8? {
    guard !isEmpty else { return nil }
    return self.popFirst()
  }
}

Key points:

  • popFirst() is a Collection method. It pops one element from the front and shifts the start position by one byte, O(1).
  • Data is designed to shrink from both ends, so this does not trigger a full copy.
  • This one-line change brought parsing time from quadratic complexity back to linear.

Round 2: Eliminate intermediate allocations (12:53). The Allocations tool showed that one image triggered nearly a million short-lived allocations. The source was this chain: readEncodedPixels(...).flatMap { decodePixels(...) }.prefix(...).flatMap { $0.data(channels:) }. Every pixel created a new small array of 3 or 4 elements, then flattened it, then copied it into Data. The rewrite pre-allocated a Data of the final size, then wrote pixels one by one:

extension QOIParser {
  /// Parses an image from the given QOI data.
  func parseQOI(from input: inout Data) -> QOI? {
    guard let header = QOI.Header(parsing: &input) else { return nil }
    
    let totalBytes = header.pixelCount * Int(header.channels.rawValue)
    var pixelData = Data(repeating: 0, count: totalBytes)
    var offset = 0
    
    while offset < totalBytes {
      guard let nextPixel = parsePixel(from: &input) else { break }
      
      switch nextPixel {
      case .run(let count):
        for _ in 0..<count {
          state.previousPixel
            .write(to: &pixelData, at: &offset, channels: header.channels)
        }
      default:
        decodeSinglePixel(from: nextPixel)
          .write(to: &pixelData, at: &offset, channels: header.channels)
      }
    }
    
    return QOI(header: header, data: pixelData)
  }
}

Key points:

  • totalBytes = pixelCount * channels.rawValue: compute the final size directly from the header, no runtime growth.
  • Data(repeating: 0, count: totalBytes): allocate once, no realloc afterward.
  • The offset variable tracks the write position manually; each write(to:at:channels:) writes data in-place into the buffer.
  • After the rewrite, total allocations dropped from nearly a million to single digits, and execution time was cut by more than half.

Round 3: Eliminate exclusivity checks (starting at 16:53). Because the improvement was so large, Nate wrapped the test loop 50 times to get more samples. Filtering for swift_beginAccess, he saw that previousPixel and pixelCache triggered runtime exclusivity checks. The reason was that these two properties lived inside a class State; class properties need runtime exclusivity enforcement. Moving them directly onto the parser struct, and marking the methods mutating, made swift_beginAccess vanish completely from the flame graph.

Round 4: InlineArray replaces fixed-size arrays (19:47). pixelCache is a 64-element array that never grows. Swift 6.2’s InlineArray bakes the size into the type with value generics:

var array: InlineArray<3, Int> = [1, 2, 3]
array[0] = 4
// array == [4, 2, 3]

// Can't append or remove elements
array.append(4)
// error: Value of type 'InlineArray<3, Int>' has no member 'append'

// Can only assign to a same-sized inline array
let bigger: InlineArray<6, Int> = array
// error: Cannot assign value of type 'InlineArray<3, Int>' to type 'InlineArray<6, Int>'

Key points:

  • Type signature InlineArray<3, Int>: 3 is a value generic parameter, the size is known at compile time.
  • Does not support append/remove; assigning to an InlineArray of a different size is a compile error.
  • Elements are stored inline (on the stack or inside the struct), no separate heap allocation, no reference counting, no copy-on-write, every assignment is a true copy.
  • Fits “fixed length, in-place mutation, no sharing” scenarios; not suitable for shared/copy-on-write use cases.

Round 5: RawSpan + OutputSpan kill reference counts (26:27). The profile showed swift_retain at 7%, swift_release at 7%, and uniqueness checks at 3%, all coming from Data reference counting in a tight loop. Swift 6.2 introduced the Span family (Span, RawSpan, MutableSpan, OutputSpan, UTF8Span). The core trait is non-escapable: the compiler binds the span’s lifetime to the underlying collection, so it cannot escape the scope, and the runtime needs no retain/release.

@available(macOS 16.0, *)
extension RawSpan {
  mutating func readByte() -> UInt8? {
    guard !isEmpty else { return nil }
    
    let value = unsafeLoadUnaligned(as: UInt8.self)
    self = self._extracting(droppingFirst: 1)
    return value
  }
}

Key points:

  • RawSpan is non-escapable; the type itself refuses to be returned to an outer scope.
  • unsafeLoadUnaligned(as:) has “unsafe” in the name because loading certain types may be unsafe; loading a built-in integer like UInt8 is always safe.
  • _extracting(droppingFirst: 1) returns a new RawSpan without copying data, only moving the start position.
  • The caller gets a RawSpan via data.bytes, passes it into the parsing method, and the whole path has zero retain/release.

The final version uses Data(rawCapacity:) with OutputSpan for writing:

/// Parses an image from the given QOI data.
mutating func parseQOI(from input: inout RawSpan) -> QOI? {
  guard let header = QOI.Header(parsing: &input) else { return nil }
  
  let totalBytes = header.pixelCount * Int(header.channels.rawValue)
  
  let pixelData = Data(rawCapacity: totalBytes) { outputSpan in
    while outputSpan.count < totalBytes {
      guard let nextPixel = parsePixel(from: &input) else { break }
      
      switch nextPixel {
      case .run(let count):
        for _ in 0..<count {
          previousPixel
            .write(to: &outputSpan, channels: header.channels)
        }
        
      default:
        decodeSinglePixel(from: nextPixel)
          .write(to: &outputSpan, channels: header.channels)
        
      }
    }
  }
  
  return QOI(header: header, data: pixelData)
}

Key points:

  • Data(rawCapacity:) is a new Swift 6.2 initializer. It provides an OutputRawSpan closure for gradually filling uninitialized memory.
  • outputSpan.count replaces the hand-written offset variable from the previous version; OutputSpan tracks write progress itself.
  • previousPixel.write(to: &outputSpan, channels:) appends directly to the output span, no Data middle layer needed.
  • Combined with the pixel cache from [InlineArray](https://github.com/apple/swift-binary-parsing), swift_retain/swift_release disappear entirely from the parsing path.

At 28:51, the final comparison is given: memory management optimizations alone made parsing 6 times faster; stacked on top of the earlier algorithm fixes, the result is 700 times faster than the original, with no unsafe code. Nate also open-sourced Swift Binary Parsing; the ParserSpan inside is built on this same mechanism.


Core Takeaways

  • What to do: standardize your performance bottleneck search path as “Time Profiler finds hot spots -> Allocations shows allocation sources -> Reveal in Xcode jumps to code”.

    • Why it matters: this workflow ran five times in the session, each time pinpointing a specific line of code within 30 seconds. Turn it into muscle memory, and you will not have to guess when real data causes stutter.
    • How to start: right-click a test and choose Profile, add Time Profiler + Allocations in Instruments, turn on Invert Call Tree, and look at the first symbol that appears in your own code.
  • What to do: audit every fixed-size container in your code and swap in InlineArray where possible.

    • Why it matters: fixed-size lookup tables, caches, and ring buffers using plain Array carry triple overhead — reference counting, uniqueness checks, and heap allocation. Switching to InlineArray<N, T> locks the size at compile time and removes all runtime cost.
    • How to start: grep your project for Array(repeating:count:), [T](repeating:count:) and similar initializers. Check each one: is the size fixed? does it need sharing? If it fits, swap to InlineArray.
  • What to do: when parsing binary formats, replace Data as the intermediate layer with RawSpan/OutputSpan.

    • Why it matters: Data retain/release in a tight loop ate 14% CPU in this session. The Span family uses non-escapable to bind the lifetime to the underlying collection, so there is no retain/release, and the compiler will reject dangerous returns for you.
    • How to start: the parser entry still takes Data; inside the function, immediately call data.bytes to get a RawSpan and pass it down. For writing, use the OutputSpan from Data(rawCapacity:).
  • What to do: move “state variables” shared across methods from a class to the struct that contains them.

    • Why it matters: class property access needs runtime exclusivity checks (swift_beginAccess/swift_endAccess), which are not cheap in a loop. Struct property access is mostly enforced at compile time.
    • How to start: find inner classes inside parser/encoder objects that exist only to “group properties.” Lift those properties to the outer struct, and mark methods that read or write them mutating.
  • What to do: loop your target code 50+ times when profiling performance.

    • Why it matters: once optimized enough, a single run is too fast for Instruments to sample, and the flame graph distorts. Nate hit this in round three; only after adding a 50x loop did swift_beginAccess show its source clearly.
    • How to start: write a dedicated perf case in XCTest, wrap it in for _ in 0..<50 { ... }, and profile that case instead of the production entry point.

Comments

GitHub Issues · utterances