Swift: WMO (Whole Module Optimizaton)

틀틀보·2025년 11월 29일

Swift

목록 보기
17/19

Swift 컴파일러가 모듈 전체를 하나의 단위로 보고 최적화를 수행하는 기술

WMO (Whole Module Optimization)

하나의 모듈 내의 소스 파일들을 한 번에 분석하여 최적화 하는 기술

동작

함수 인라인

  • 다른 파일에 정의된 함수의 코드를 호출 부에 직접 복사하여 함수 호출 오버헤드를 제거

  • 호출된 함수의 주소를 따라가지 않고 바로 그 자리에서 코드 실행

제네릭 특수화

  • 제네릭 함수가 특정 타입으로 사용될 때, 해당 타입에 최적화된 코드 생성

가상 함수 제거

  • 상속되지 않거나 오버라이딩되지 않은 메서드를 파악하여, 동적 디스패치를 정적 디스패치로 변경

  • ex) 프로토콜을 따르나, 구현체가 1개 밖에 없는 경우


예시

// File 1
public protocol Calculator {
    func add(_ a: Int, _ b: Int) -> Int
}

public final class MathProcessor {
    private let calculator: Calculator

    public init(calculator: Calculator) {
        self.calculator = calculator
    }

    public func process(x: Int, y: Int) -> Int {
        // WMO가 없다면 컴파일러는 이 시점에 calculator.add가
        // 실제로 어떤 코드를 실행하는지 알기 어려움(동적 디스패치).
        return calculator.add(x, y)
    }
}
-------------------------------------------------
// File 2

/// Calculator의 실제 구현체
internal final class StandardCalculator: Calculator {
    
    // @inlinable이 없어도 WMO 환경에서는 다른 파일에서 인라인화 가능성이 생김
    func add(_ a: Int, _ b: Int) -> Int {
        return a + b
    }
}
  • WMO가 아닌 환경에서는 컴파일러가 다른 파일에 있는 구현체를 모름.

  • 그러므로, process 메서드 내 calculator.add는 동적 디스패치로 처리됨. (어떤 구현체가 들어올 지 모르기 때문)

  • WMO 환경에서 컴파일러는 두 파일을 동시에 봄.

  • 들어올 Calulator 구현체가 무조건 1개인 걸 컴파일러가 알게 됨.

  • 컴파일러는 가상 함수 제거를 수행하고, add 메서드 코드를 process 메서드 내로 복사 진행

  • private / fileprivate 사용으로 파일 내부에서만 타입이 쓰이는 게 100% 보장되면 최적화 가능

프로토콜을 따르는 구현체가 2개일 경우

public protocol Calculator {
    func add(_ a: Int, _ b: Int) -> Int
}

// 구현체 1
final class BasicCalculator: Calculator {
    func add(_ a: Int, _ b: Int) -> Int { return a + b }
}

// 구현체 2 
final class ScientificCalculator: Calculator {
    func add(_ a: Int, _ b: Int) -> Int { return a + b + 100 } 
}
  • 이제 구현체가 2개임을 WMO가 확인 했기에 런타임에 외부에서 어떤 구현체를 받을 지 알 수 없게 됨.

  • 그러므로, WMO는 동적 디스패치를 유지

  • 근데 WMO는 모듈 내 모든 코드를 알지 않나요?

    • 물론 알고는 있으나, 대비 해야함.
    • 1 구현체를 주입 받아 코드 복사로 해당 함수에 박아버리면 다른 구현체가 들어올 시에 크래시 발생
    • 그러므로 안전하게 가는 방식 선택
    • 추가적으로 Existential Container에 구현체가 들어가는 순간 WMO는 무엇이 들어있는지 알 수는 있으나, 보수적으로 판단하여 냅둠.

제네릭을 사용한 WMO 성능 올리기

// 제네릭 T를 사용하여 구체적인 타입 정보를 보존
final class OptimizedMathService<T: Calculator> {
    private let calculator: T
    
    init(calculator: T) {
        self.calculator = calculator
    }
    
    func calculate(x: Int, y: Int) -> Int {
        // 컴파일러는 이 제네릭 클래스를 사용하는 코드를 발견하면,
        // 해당 타입에 맞춰 별도의 코드를 생성
        // 따라서 여기서는 다시 '직접 호출' 및 '인라인화'가 가능
        return calculator.add(x, y)
    }
}
  • 제네릭을 사용하면 해당 구현체가 생성될 때 타입이 고정된 별도의 코드가 생성

  • 그러므로 WMO는 고정된 타입을 확인하고 제네릭으로 고정된 타입이 들어간 복사된 구현체 코드의 calculate 메서드 내에 고정된 타입의 add 메서드를 하드코딩


Swift에서의 WMO 옵션

Xcode에서 WMO가 어떤 수준으로 적용할 지를 정하는 옵션

-Onone

  • WMO 적용 X

  • 최적화 X

  • 디버깅 정보 보존

  • 그에 따른 빌드 속도 ⬆️

  • Debug 환경에서 사용 추천

-O

  • 공격적인 inline 수행

  • 코드 크기가 커지더라도 실행 속도를 최우선

  • default

  • Release 환경 추천

-Osize

  • inline화 최소화

  • 함수 호출을 유지하여 앱 용량 줄이기

  • App Clip, WatchOS와 같이 용량이 적을 필요가 있는 환경에 적용

적용법

Build Settings -> Swift Compiler -> Code Generation -> Optimization Level


WMO로 해결하지 못하는 모듈 간 최적화

@inlinable

이 함수의 구현체가 컴파일 시점에 다른 모듈 코드에 복사 될 수 있음을 표시하는 매크로

// 프레임워크: CoreLogKit

public protocol Logger {
    func log(_ message: String)
}

public struct ConsoleLogger: Logger {
    
    // 내부 속성이지만, @inlinable 함수에서 접근해야 하므로 허용
    @usableFromInline
    internal let prefix: String
    
    public init(prefix: String = "[Core]") {
        self.prefix = prefix
    }

    /// @inlinable 선언:
    /// 이 함수의 구현체는 컴파일 시점에 클라이언트 코드에 복사될 수 있음을 표시
    @inlinable
    public func log(_ message: String) {
        // 내부 메서드 호출 시에도 @usableFromInline이 필요
        internalLog("\(prefix) \(message)")
    }
    
    @usableFromInline
    internal func internalLog(_ formattedMessage: String) {
        print(formattedMessage)
    }
}

// 앱
import CoreLogKit

final class LogService {
    private let logger: Logger
    
    // 의존성 주입 (DI)
    init(logger: Logger) {
        self.logger = logger
    }
    
    func performAction() {
        // 컴파일러는 logger가 ConsoleLogger 구체 타입임을 알게 되면,
        // log() 함수의 내용을 여기로 가져와서 아래처럼 코드를 바꿈.
        
        // 기존: logger.log("Action Started")
        
        // 최적화 후:
        // print("[Core] Action Started") 
    }
}
  • @inlineable 매크로를 통해 해당 함수가 모듈 외부에서 WMO등에 의해 최적화가 되어도 좋다라는 걸 명시

  • @usableFromInline 매크로를 통해 @inlineable 내에서 사용되는 프로퍼티, 메서드들들이 inline 작업 시에 사용될 수 있도록 명시

  • @usableFromInline 매크로가 사용되는 프로퍼티, 메서드는 public, internal level 이어야 함.

  • 모듈 바깥에서 @usableFromInline 프로퍼티에 read/write 발생 시 컴파일 에러 발생으로 접근자 기능이 올바르게 동작

주의할 점

  • 각 모듈 간 sync: 한 쪽 모듈이 수정된 후 배포되어도 다른 쪽 모듈이 다시 컴파일 되지 않으면 구버전의 복사된 코드 실행

  • 앱 용량 커질 가능성 있음. 모듈 A를 B, C, D, E 등에서 호출하면 모듈 A 코드를 각 모듈에서 복사해서 사용

  • 역공학 위험: 프레임 워크 내부 로직이 클라이언트 측 코드에서 드러나므로, 역공학에 주의

참고
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/attributes/#usableFromInline

https://phillip5094.tistory.com/103

https://www.swift.org/blog/whole-module-optimizations/

https://developer.apple.com/videos/play/wwdc2019/416/

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0193-cross-module-inlining-and-specialization.md

https://www.swift.org/blog/osize/

https://github.com/swiftlang/swift/blob/main/docs/OptimizationTips.rst

profile
안녕하세요! iOS 개발자입니다!

0개의 댓글