Vor ein paar Tagen haben wir einen Commit mit dem Titel feat: Improve photo analysis ausgeliefert. Der Diff sah harmlos aus. Am nächsten Nachmittag brauchte unsere Smart-Analysis auf einer 26.000-Foto-Bibliothek — die vorher in ~90 Sekunden durchgelaufen war — plötzlich 6 bis 17 Minuten.
Gleicher Codepfad. Gleiches Gerät. Gleiche Bibliothek. 5- bis 10-mal langsamer.
Das hier ist das Post-mortem.
Was wir gesehen haben
Die Smart-Analysis-Pipeline streamt Fotos durchs Native-Modul, berechnet pro Asset ein paar Attribute (Größe, Lokalität, einfache EXIF-Daten) und schreibt sie in einen SQLite-Index. Wir haben Telemetrie auf den Durchsatz pro Batch, und sie passt sich an: wenn die letzten Batches schnell durchgelaufen sind, wächst die Batch-Größe.
Nach der Regression erzählte uns die Telemetrie eine deprimierende kleine Geschichte:
[PhotoLibraryToolkit] ℹ️ Performance good (66.2 items/s), increasing batch to 49
[PhotoLibraryToolkit] ℹ️ Performance good (67.4 items/s), increasing batch to 73
[PhotoLibraryToolkit] ℹ️ Performance good (69.7 items/s), increasing batch to 109
[PhotoLibraryToolkit] ℹ️ Performance good (68.2 items/s), increasing batch to 163
Sie hielt alles für in Ordnung — diese Zahlen liegen über dem “Batch erhöhen”-Schwellwert. Aber das historische Normal war 150–600 items/s. Wir waren jetzt eine Größenordnung darunter, und der adaptive Scheduler hatte keine Möglichkeit, das zu erkennen.
Was der Commit verändert hat
Der Übeltäter war eine einzige Swift-Funktion: fileInfo(for asset: PHAsset). Sie gibt zwei Werte zurück: die Dateigröße des Assets und ob es lokal gespeichert ist (vs. in iCloud).
Vorher:
private func fileInfo(for asset: PHAsset) -> (Int64, Bool) {
var size: Int64 = 0
var isLocal = false
let resources = PHAssetResource.assetResources(for: asset)
if let resource = resources.first {
if let fileSize = resource.value(forKey: "fileSize") as? Int64 {
size = fileSize
isLocal = true
}
}
return (size, isLocal)
}
Synchron. Liest die Größe aus PhotoKits Resource-Metadaten — ein Property-Lookup, praktisch gratis. ~0,001 s pro Asset.
Nachher:
private func fileInfo(for asset: PHAsset) -> (Int64, Bool) {
var size: Int64 = 0
var isLocal = false
let semaphore = DispatchSemaphore(value: 0)
if asset.mediaType == .image {
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = false
asset.requestContentEditingInput(with: options) { input, info in
if let url = input?.fullSizeImageURL {
isLocal = true
do {
let attr = try FileManager.default.attributesOfItem(atPath: url.path)
if let fileSize = attr[.size] as? Int64 {
size = fileSize
}
} catch { /* ... */ }
}
semaphore.signal()
}
} else if asset.mediaType == .video {
// Symmetrisches Pattern mit requestAVAsset
}
_ = semaphore.wait(timeout: .now() + 2.0) // bis zu 2 Sekunden. pro Asset.
return (size, isLocal)
}
Gleicher Rückgabewert. Anderes Mittel, ihn zu bekommen.
Das “Improvement” war, dass der neue Code genauer ist. requestContentEditingInput löst die tatsächliche Datei auf der Disk auf und meldet ihre echte Größe, statt PhotoKits gecachten Metadaten zu vertrauen (die für HEIC-Dateien mit Auxiliary-Data gelegentlich veraltet sind).
Das versteckte Preisschild:
-
requestContentEditingInputist async. Jedes Asset zahlt jetzt den Preis für einen Async-Dispatch + Semaphore-Wait. Das 2-Sekunden-Timeout war die Worst-Case-Decke; der typische Fall war trotzdem teuer. -
Es lädt das ganze Bild in den Speicher, um diese Full-Size-URL geben zu können. Ein 10-MB-Foto lädt 10 MB Pixel in den RAM, nur damit wir
attributesOfItemnach der Byte-Anzahl fragen können. -
Die äußere Pipeline nutzt
concurrency: 10. Hilft nicht. Alle zehn Worker blockieren auf ihrem eigenen Semaphore-Wait und konkurrieren dann um den iOS-Memory-Pressure mit zehn gleichzeitig laufenden Bild-Decodes.
Es ist genau der Trade-off, nach dem ich den Post benannt habe: statt das Lieferpapier des LKWs zu lesen, hat der neue Code den LKW auf die Waage gefahren.
Wie wir es gefunden haben
Die erste Telemetrie-Stichprobe nach der Regression sah verwirrend aus, weil der adaptive Batcher weiter “performance good” gemeldet hat. Er hielt 67 items/s für einen gesunden Durchsatz, weil er keine vorherige Baseline einkodiert hatte — er reagierte auf relative Deltas, nicht absolute Schwellwerte.
Sobald uns aufgefallen war, dass die absolute Zahl falsch ist, dauerte die Investigation ungefähr 15 Minuten:
git log --since="2 days ago" -- packages/expo-photo-library-toolkit/ios/zeigte vier Commits.- Drei waren offensichtlich kosmetisch. Der vierte hieß
feat: Improve photo analysis. git show 7abade2 -- ios/SmartAnalysisService.swiftzeigte den neuenfileInfo-Body.- Der Ausdruck
DispatchSemaphore(value: 0)in einer Per-Asset-Funktion reicht aus, um zusammenzuzucken, noch bevor man weiter liest.
Der Fix
Wir sind zurück auf den synchronen Metadaten-Pfad — mit einer Verbesserung, die wir mitgenommen haben: eine Fallback-Schätzung für iCloud-Only-Assets, bei denen PhotoKit eine Größe von null meldet. Die Schätzung ist grob (Breite × Höhe × 1,5 Bytes für typische JPEG-/HEIC-Kompressionsverhältnisse), aber sie lässt das Cleanup-UI eine sinnvolle “X GB frei”-Summe zeigen, auch wenn Assets noch nicht heruntergeladen sind.
private func fileInfo(for asset: PHAsset) -> (Int64, Bool) {
var size: Int64 = 0
var isLocal = false
let resources = PHAssetResource.assetResources(for: asset)
if let resource = resources.first {
if let fileSize = resource.value(forKey: "fileSize") as? Int64 {
size = fileSize
} else if let fileSize = resource.value(forKey: "fileSize") as? NSNumber {
size = fileSize.int64Value
}
// iCloud-Only-Dateien haben typischerweise keine fileSize oder 0.
isLocal = (size > 0)
}
// Fallback-Schätzung für iCloud-Assets.
if size == 0 {
let w = Int64(asset.pixelWidth)
let h = Int64(asset.pixelHeight)
size = (w * h * 3) / 2
isLocal = false
}
return (size, isLocal)
}
Die 26-K-Bibliothek ist wieder bei ~90 Sekunden. Die Durchsatz-Telemetrie ist wieder über 150 items/s.
Was wir an unserer Arbeitsweise geändert haben
Ein Revert ist einfach. Die interessante Arbeit ist sicherzustellen, dass die Regression nicht still hätte ausgeliefert werden können.
Wir haben drei Dinge hinzugefügt:
-
Absolute Durchsatz-Untergrenzen in der Telemetrie, nicht nur relative Deltas. Alles unter 100 items/s auf iPhone-12+-Hardware loggt jetzt ein Warning, auch wenn der adaptive Batcher glücklich ist.
-
Ein
Critical: avoid this API-Dokument im Repo des Native-Moduls.PHContentEditingInputRequestOptionsreiht sich ein in eine kurze Liste von PhotoKit-APIs, die verführerisch aussehen (sie wirken wie die “richtige” Abstraktion), aber für Bulk-Operationen katastrophal teuer sind. Künftige Commits, die diese Symbole importieren, lösen einen Lint-Warning aus, der auf das Dokument verlinkt. -
Eine Benchmark-Suite, die gegen eine synthetische 5-K-Asset-Bibliothek läuft, als Teil der CI fürs Native-Modul. Fängt nicht jede Regression, aber fängt die Größenordnungs-Regressions — was die einzige Klasse ist, die groß genug ist, um aus Versehen als “feat”-Commit ausgeliefert zu werden.
Die Lektion in einem Satz
Bevor du eine Async-API aufrufst, um eine Zahl zu bekommen, die du aus Metadaten lesen könntest: frag dich, ob du gerade den LKW wiegst — oder einfach das Lieferpapier liest.