iPadOS でもっとメモリを使いたい!(iPadOS 16.1 対応版)

はじめに

久々の記事投稿となります,R&D ユニットの久保です.

OPTiM が開発している OPTIM Geo Scan では iPhone や iPad 上で巨大な点群ファイルを扱うため,メモリ不足によってクラッシュを引き起こすことがないように空間計算量には非常に気を遣って開発しています.OPTiM Geo Scan は LiDAR スキャナを搭載した iPhone や iPad Pro に対応していますが,特に iPad Pro ではメモリの大容量化が進んでいます.どうせならこの大容量なメモリを使いこなしたい!ということで iPadOS 15 における変化,そして 2022/10/25 にリリースされた iPadOS 16.1 における変化をまとめてみました.

この記事では Xcode 14.0.1 と iPadOS 16.1 を使用しています.

iPadOS における App のメモリ使用量周りの変遷

iPadOS 14 以前

iPadOS 15 が発表される直前の 2021 年 5 月,M1 プロセッサ搭載の iPad Pro 11-inch (3rd generation) と iPad Pro 12.9-inch (5th generation) が発売されました.これらのデバイスではストレージの容量に応じてメモリの容量が 8 GB または 16 GB から選べました.しかし,発売当時搭載されていた iPadOS 14 までは App がメモリを 3~5 GB くらい使用した時点でシステムに kill されてしまっていました.

iPadOS 15

その状況を打開すべく,iOS/iPadOS 15 では Increased Memory Limit (com.apple.developer.kernel.increased-memory-limit) Entitlement が追加されました.この Entitlement を付与すると,App が使用可能なメモリの上限をそのデバイスの物理メモリの搭載量に応じて増やすことができるようになりました.

しかしまだ一定以上メモリを使用すると kill されるのは変わっていません.具体的なサイズは os_proc_available_memory() 関数の戻り値で確認することができます.os_proc_available_memory() は App のプロセスが確保可能な残りのメモリサイズをバイト数で返してくれる関数で,Increased Memory Limit Entitlement の有無とは関係なく使用できます.返ってくる値は実行するたびに多少前後するもののデバイスと OS バージョンが同じであれば基本的に同じです.

例えば,搭載物理メモリが 6 GB の iPad Pro 11-inch (2nd generation) では Increased Memory Limit Entitlement があろうがなかろうが 4.5 GiB ですが,搭載物理メモリが 16 GB の iPad Pro 11-inch (3rd generation) や iPad Pro 12.9-inch (5th generation) だと,Increased Memory Limit Entitlement なしの App を実行した場合にこの関数で約 5 GiB の数値が返って来ていたのが,Entitlement ありだと約 12 GiB の数値が返って来るようになりました.

また,これだけではまだ実際には実際に書き込んだ領域が大体 5 GB を超えた時点で App がクラッシュしていました.それは App が使用できるアドレス空間が狭いからです.そこで必要になってくるのが Extended Virtual Addressing (com.apple.developer.kernel.extended-virtual-addressing) Entitlement で,これを付与することによって App のメモリ空間を拡張することができ,無事に os_proc_available_memory() で返ってきた 12 GiB をフルに使うことができるようになります.ただし,個人で開発していたり組織外のエンジニアと一緒に開発していたりする場合は,Extended Virtual Addressing Entitlement は有償の Apple Developer アカウントでしか有効化できないという点に注意が必要です.

まとめると,iPadOS 15 では元々 App が使えるメモリの上限が約 5 GiB だったのを Increased Memory Limit と Extended Virtual Addressing という 2 つの Entitlements を付与することによって最大 12 GiB まで増やすことができるようになりました.

ちなみに,iPadOS 15 リリース当時は以下のスレッドにもあるようにこれらの Entitlements の付与が Xcode のプロジェクト設定の Signing & Capabilities の Capability の追加から行えず少々面倒だったのですが,Xcode 14 現在では解消されているようです.

iPadOS 16.1

iPadOS 16 の最初のパブリックなリリースである 16.1 ではさらに 2 つの変化がありました.

  • 仮想メモリのスワップ (Virtual memory swap) に対応 (App が使用できるメモリサイズが最大 16 GB まで拡張された)
  • Extended Virtual Addressing Entitlement なしで 5 GB 以上のメモリを使用できるようになった (つまり無償の Apple Developer アカウントで十分になった)

まず仮想メモリのスワップですが,こちらは iPadOS 16 の新機能紹介や WWDC22 の基調講演でも言及されています.

上記のリンク先ではあまり詳しくは触れられていないのですが,iOS, iPadOS や macOS における仮想メモリ管理の仕組みについては以下に目を通してみると理解が進むと思います.(ただし内容が一部古いことに注意)

対応デバイスが限定されており,以下のようになっています.簡単にまとめると M1 以降の SoC を搭載したストレージ 256 GB 以上のデバイスということになりそうです.

256GB以上のストレージを搭載したiPad Air(第5世代)、12.9インチiPad Pro(第5世代以降)、11インチiPad Pro(第3世代以降)で利用できます。

さて,改めて新機能紹介の文章を引用すると以下のようにあります.

iPadのストレージを利用して、あらゆるアプリで使えるメモリを拡張できます。最も高いパフォーマンスが求められるアプリでは、最大16ギガバイトまでメモリを拡張できます。

私は当初これが意味しているのが「App がスワップを使えるようになったことで使えるメモリの量がこれまでより 16 GB 分追加で増える」ということだと誤解していましたが,実際に試してみるとそうではないことがわかりました.詳細は後述します.

スワップを使用している証拠が掴みたい

iPadOS がスワップに対応したと聞くとスワップの使用率を表示してみたくなると思います.ですがそのような API は現在のところ iOS SDK にはありません.ただ iPadOS がどういう OS かということをご存知であれば macOS と同様の仕組みを採用しているのでは?という推測がある程度つきます.

実際に iPadOS 上で使えるサードパーティの Terminal アプリである a-Shell などを使って /private/var/vm/swapfile* の存在を確かめてみると,iPadOS 16 になる以前からスワップファイルの存在を確認することができました.スワップファイルが存在しない場合は "Operation not permitted" ではなく "No such file or directory" が返ってくるのでそれで判別がつきます.なのでおそらくは App はスワップを使えずともシステム側は以前からスワップを利用していたのでしょう.

$ ls /private/var/vm/swapfile0
ls: /private/var/vm/swapfile0: Operation not permitted

そこで macOS におけるスワップの使用率を調べてみます.macOS では Activity Monitor でスワップの使用率が表示できますし,同じ数値をサードパーティの htop などでも確認できます.

では htop はどこからその値を取って来ているのでしょうか?htop のソースコードを追ってみると以下の行で取得していることがわかりました.

この sysctl はコマンドラインからも叩くことができて,sysctl vm.swapusage というコマンドでも同じ結果が取得できます.

$ sysctl vm.swapusage
vm.swapusage: total = 0.00M  used = 0.00M  free = 0.00M  (encrypted)

しかし iPadOS にはファーストパーティの Terminal はなく,サードパーティの Terminal アプリでも sysctl に対応したものはないので,先ほどの htop のコードを参考に Swift から呼び出すことを考えます.コードにすると以下のようになるかと思います.

import Darwin

func getSwapUsed() -> xsw_usage {
    var query = [CTL_VM, VM_SWAPUSAGE]
    var result = xsw_usage()
    var resultSize = MemoryLayout<xsw_usage>.size
    let r = sysctl(&query, CUnsignedInt(query.count), &result, &resultSize, nil, 0)
    precondition(r == 0, "errno: \(errno)")
    precondition(resultSize == MemoryLayout<xsw_usage>.size)
    return result
}

let swapUsed = getSwapUsed()
print(swapUsed)

しかし実行してみると sysctl 関数からは戻り値 -1 が返ってきます.sysctl 関数のマニュアルを読むと,戻り値が -1 の場合はグローバル変数 errno にエラーコードが入っていると書いてあります.なので errno の値を見ると 1 でした.errno の定義が書いてあるマニュアルを読むと 1 は "Operation not permitted." でした.ということで残念ながら iPadOS からは sysctl を使ってスワップの使用率を見ることができません.

そうなると次に使えそうなのが vm_stat コマンドです.これも sysctl と同様でコードから呼び出す場合は host_statistics64 関数を使って同じ値が取得できます.iPadOS でも以下のような感じの Swift コードで swap ins や swap outs のページ数 (ページングにおけるページ) が取得できます.vm_statistics64 のメンバにどういうものがあるかついてはドキュメントがないので,iPadOS や macOS のカーネルである XNU のコード を読むとわかります.

import Darwin

func getVMStatistics64() -> vm_statistics64? {
    let hostPort = mach_host_self()
    var hostSize = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size / MemoryLayout<integer_t>.size)
    var vmstat = vm_statistics64()
    let result = withUnsafeMutablePointer(to: &vmstat) {
        $0.withMemoryRebound(to: integer_t.self, capacity: Int(hostSize)) {
            host_statistics64(
                hostPort,
                HOST_VM_INFO64,
                $0,
                &hostSize
            ) == KERN_SUCCESS
        }
    }
    
    return result ? vmstat : nil
}

func getPageSize() -> UInt64 {
    var pageSize: vm_size_t = 0
    host_page_size(mach_host_self(), &pageSize)
    return UInt64(pageSize)
}

let vmstat = getVMStatistics64()!
let pageSize = getPageSize()
let pageIns = Double(vmstat.pageins * pageSize) / Double(1 << 30)
let pageOuts = Double(vmstat.pageouts * pageSize) / Double(1 << 30)
let swapIns = Double(vmstat.swapins * pageSize) / Double(1 << 30)
let swapOuts = Double(vmstat.swapouts * pageSize) / Double(1 << 30)
let faults = Double(vmstat.faults * pageSize) / Double(1 << 40)
 
print("page ins: \(pageIns) GiB")
print("page outs: \(pageOuts) GiB")
print("swap ins: \(swapIns) GiB")
print("swap outs: \(swapOuts) GiB")
print("faults: \(faults) TiB")

しかし実際に値を見てみるとわかるように,sysctl vm.swapusage で表示されていたスワップの使用率をここからどう計算すればよいかさっぱりだったので,ここで断念しました.

行き詰まって来たので,今度は方針を変えてスワップを使わざるを得なくなるようなコードをわざと書いてみることにします.

実験に使ったのは以下のようなボタンを押すたびに 1 GiB ずつ Array を作ってランダムな数値でメモリ書き込みをしていく SwiftUI の App です.これに Increased Memory Limit Entitlement と Extended Virtual Addressing Entitlement を付与して実験しました.

import SwiftUI
import os

struct ContentView: View {
    @State private var array = [[UInt8]]()

    var arraySize: Int { self.array.map(\.count).reduce(0, +) }

    var body: some View {
        VStack {
            Text("current used: \(self.arraySize) bytes")
            Text("available: \(os_proc_available_memory()) bytes")
            Text("total: \(self.arraySize + os_proc_available_memory()) bytes")
            Button("Increase") {
                self.array.append([UInt8](repeating: .random(in: 0...0xff), count: 1024 * 1024 * 1024))
            }
            .buttonStyle(.borderedProminent)
            Button("Random read") {
                print(self.array[.random(in: 0..<self.array.count)][.random(in: 0..<1024 * 1024 * 1024)])
            }
        }
    }
}

これを実行するとまず気づくのが,搭載物理メモリが 16 GB の iPad Pro 11-inch (3rd generation) や iPad Pro 12.9-inch (5th generation) で実行した時に os_proc_available_memory() で約 16 GiB が返ってくる点です.iPadOS 15 の頃は約 12 GiB だったので明らかに変化しています.

そして "increase" ボタンを押して "current used" のサイズを増やしていくと,16 GiB に到達するかどうかのあたりで App が kill されてしまいます.そのため,

iPadのストレージを利用して、あらゆるアプリで使えるメモリを拡張できます。最も高いパフォーマンスが求められるアプリでは、最大16ギガバイトまでメモリを拡張できます。

は「App がスワップを使えるようになったことで使えるメモリの量がこれまでより最大 16 GB 分追加で増える」という意味ではなく,「App が使えるメモリの量がトータルで最大 16 GB まで増えた」という意味だったようです.搭載物理メモリが 8 GB の iPad Pro や iPad Air でどうなるのかが手元にデバイスがないため試せていないのですが,もしそれらでも 16 GiB まで使えるようになっていればそちらの方が大きな変化かもしれません.

実際に先ほどのコードを macOS で (macOS ネイティブの App として) 実行してみると搭載物理メモリが 8 GB の Mac mini でも 20 GB を超えてメモリを使用できることがわかります.そしてその際 Activity Monitor などシステム全体のメモリ使用量をリアルタイムに可視化できる App を同時に動かしているとわかるのですが,"Increase" ボタンを押下して "current used" のサイズを増やしていくと,メモリ圧縮が優秀なためスワップが使われずにメモリが圧縮されてしまっていることがわかります.ランダムな場所を read しても結果はほとんど変わりませんでした.(Omnistat 2 などの App を使ってメモリの使用状況や圧縮された量を見ながら iPadOS で動かしてみた際も同様の傾向が見て取れます)

結論として,スワップが実際に使われているかどうかはわからなかったものの,iPadOS 16.1 での「仮想メモリのスワップ」対応が意味するところが’「App が使えるメモリの量がトータルで 16 GB まで増えた」であったということがわかりました.

ついでに先ほどの実験コードで Extended Virtual Addressing Entitlement を外してみても搭載物理メモリが 16 GB の iPad Pro 11-inch (3rd generation) や iPad Pro 12.9-inch (5th generation) で 16 GiB 付近までメモリが使用できることが判明しました.これによって iPadOS 16.1 以降をターゲットにする場合は有償 Apple Developer アカウントが必須な Entitlement に依存する必要がなくなったため,メモリヘビーな App の開発の敷居がより下がったのではないかと思います.

まとめ

より大容量のメモリを搭載するようになってきた iPad Pro ですが,それを活かすソフトウェア側の変更として,iPadOS 15 では最大 12 GiB までのメモリ使用が許容され,iPadOS 16 ではさらにスワップも含めて合計で最大 16 GiB まで許容されるようになり,有償 Developer アカウントが必要な Entitlement も無くなって門戸が広がりました.

これによって動作するデバイスを限定してさえ仕舞えばメモリを大量に必要とする App の開発がだいぶやりやすくなって来たと思います.ただし,あくまで閾値が上がっただけで「ある一定のメモリ使用量を超えたら kill される」という制限自体は変わらないので,依然として限界を常に気にしながら設計する必要は今後もありますが.

OPTiM では我々と一緒に最大限のパフォーマンスを出すためにメモリの限界ギリギリを慎重に攻める仲間も募集しているので,興味のある方は以下のリンク先からどうぞ.