Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

311 lines
15 KiB
Swift

import Foundation
import Metal
/// GPU code for pipeline states, kept on disk across launches.
///
/// Libraries ship compiled, but turning a library function into code for this GPU still happens the first time a
/// pipeline is made in each process, and the system's own shader cache does not reliably spare it, so every first use
/// stalls. Pipelines made here are compiled into a binary archive once per app version, OS build and GPU, saved in
/// Caches, and made from the archive afterwards.
///
/// The archive can never serve a stale pipeline. Metal finds a pipeline in it by the pipeline's whole descriptor and
/// the code of its functions, so a changed shader, function constant or pipeline state is a miss: it is compiled and
/// added. Whatever compiles that code into GPU code is covered by the archive's file name, which changes with the
/// app version, the OS build and the GPU, and starts a fresh archive.
///
/// Apple advises against updating archives at runtime in shipping apps because that needs resilience to corruption
/// and bounded storage: a file that fails to load is never trusted (the next save replaces it), saves replace the file
/// atomically, and the archive is version-scoped and capped in size.
///
/// Saving goes through Metal's own code, which has crashed the app (see `MetalBinaryArchiveSerialization`, which turns
/// the known case into an error). A save is marked on disk while it runs, so a process that dies inside one leaves
/// the mark behind; from then on the archive is still used but never saved again, until the app or OS version
/// changes and a new archive starts.
///
/// The archive can be switched off remotely (`setArchiveDisabled`, driven by the
/// `ios_killswitch_disable_metal_pipeline_cache` app configuration key); pipelines are then compiled as they were before
/// this cache existed.
///
/// **Currently switched off in the app** (`isSwitchedOff`): a save still crashed inside Metal on iOS 26.3 with the
/// guard in place (2026-10-02), so the guard does not cover every path there.
///
/// Thread-safe, and never makes a caller wait for another thread's compile or save.
public final class MetalPipelineCache {
private static let maxArchiveSize = 16 * 1024 * 1024
/// Remembers the killswitch across launches: the archive is opened at launch, before any app configuration is
/// loaded.
private static let isArchiveDisabledKey = "MetalPipelineCache.isArchiveDisabled"
/// Keeps the app's cache from using an archive at all, as the killswitch does, until saving is understood.
private static let isSwitchedOff = true
private let device: MTLDevice
/// Guards every use of the archive, whose thread safety is not documented. A caller that finds it held (another
/// thread is compiling into the archive or saving it) compiles its pipeline directly rather than waiting.
private let lock = NSLock()
private let saveQueue = DispatchQueue(label: "org.telegram.MetalPipelineCache", qos: .utility)
/// Where archives are kept.
private let directoryUrl: URL?
/// Where the archive is saved; nil when it must not be written this launch.
private let url: URL?
/// An `MTLBinaryArchive`, which needs macOS 11 or iOS 14 (the package also builds for older macOS).
private let archiveStorage: AnyObject?
private var isSaveScheduled = false
/// Guarded by `lock`.
private var isArchiveDisabled: Bool
/// True when this launch found no usable archive: the first start after an install or update, or after an OS
/// update. A good moment to make the pipelines a user is likely to need, in the background.
public let isFresh: Bool
convenience init(device: MTLDevice) {
// Namespaced by app: on macOS, Caches can be shared between builds of the app.
let directoryUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first.flatMap { cachesUrl in
return cachesUrl
.appendingPathComponent("MetalPipelineCache", isDirectory: true)
.appendingPathComponent(Bundle.main.bundleIdentifier ?? "default", isDirectory: true)
}
self.init(device: device, directoryUrl: directoryUrl, isSwitchedOff: MetalPipelineCache.isSwitchedOff)
}
/// `isSwitchedOff` behaves like the killswitch for this instance: no archive, and the directory is deleted.
init(device: MTLDevice, directoryUrl: URL?, isSwitchedOff: Bool = false) {
self.device = device
let isArchiveDisabled = isSwitchedOff || UserDefaults.standard.bool(forKey: MetalPipelineCache.isArchiveDisabledKey)
var url: URL?
var archive: AnyObject?
// Without an archive there is nothing to fill on a first start.
var isFresh = false
if isArchiveDisabled {
if let directoryUrl {
let _ = try? FileManager.default.removeItem(at: directoryUrl)
}
} else {
// The simulator's Metal accepts pipelines into an archive but fails an assertion when serializing it
// ("Target device architecture is nil"): it has no GPU architecture to target. Compile as usual there.
#if !targetEnvironment(simulator)
if #available(macOS 11.0, iOS 14.0, *) {
(url, archive, isFresh) = MetalPipelineCache.openArchive(device: device, directoryUrl: directoryUrl)
}
#endif
}
self.directoryUrl = directoryUrl
self.url = url
self.archiveStorage = archive
self.isFresh = isFresh
self.isArchiveDisabled = isArchiveDisabled
}
/// Switches the archive off, now and on later launches, or back on from the next launch. Switching it off deletes
/// it; from then on pipelines are compiled as they were before this cache existed.
public func setArchiveDisabled(_ isDisabled: Bool) {
if UserDefaults.standard.bool(forKey: MetalPipelineCache.isArchiveDisabledKey) != isDisabled {
UserDefaults.standard.set(isDisabled, forKey: MetalPipelineCache.isArchiveDisabledKey)
}
if !isDisabled {
return
}
// The lock may be held by a compile; take it on the save queue rather than on the caller's thread.
self.saveQueue.async { [weak self] in
guard let self else {
return
}
self.lock.lock()
let wasDisabled = self.isArchiveDisabled
self.isArchiveDisabled = true
self.lock.unlock()
if !wasDisabled, let directoryUrl = self.directoryUrl {
let _ = try? FileManager.default.removeItem(at: directoryUrl)
}
}
}
@available(macOS 11.0, iOS 14.0, *)
private static func openArchive(device: MTLDevice, directoryUrl: URL?) -> (url: URL?, archive: MTLBinaryArchive?, isFresh: Bool) {
let emptyArchive = try? device.makeBinaryArchive(descriptor: MTLBinaryArchiveDescriptor())
let fileManager = FileManager.default
guard let directoryUrl else {
return (nil, emptyArchive, true)
}
let _ = try? fileManager.createDirectory(at: directoryUrl, withIntermediateDirectories: true)
let fileName = MetalPipelineCache.archiveFileName(device: device)
let fileUrl = directoryUrl.appendingPathComponent(fileName)
let saveMarkerUrl = MetalPipelineCache.saveMarkerUrl(archiveUrl: fileUrl)
// Archives of previous versions are never valid again, and neither are their saves' marks.
for item in (try? fileManager.contentsOfDirectory(atPath: directoryUrl.path)) ?? [] where item != fileName && item != saveMarkerUrl.lastPathComponent {
let _ = try? fileManager.removeItem(at: directoryUrl.appendingPathComponent(item))
}
// A mark means a save of this archive never finished: the process died in it. The file it was replacing is
// untouched (saves are renamed into place), so keep using it, but do not risk saving it again.
let saveUrl: URL? = fileManager.fileExists(atPath: saveMarkerUrl.path) ? nil : fileUrl
// Changed pipelines are added next to their old versions, which are never used again. Within one app version
// that only happens in development builds; start over if it ever adds up.
if let size = (try? fileManager.attributesOfItem(atPath: fileUrl.path))?[.size] as? Int, size > MetalPipelineCache.maxArchiveSize {
let _ = try? fileManager.removeItem(at: fileUrl)
}
guard fileManager.fileExists(atPath: fileUrl.path) else {
return (saveUrl, emptyArchive, true)
}
let descriptor = MTLBinaryArchiveDescriptor()
descriptor.url = fileUrl
if let archive = try? device.makeBinaryArchive(descriptor: descriptor) {
return (saveUrl, archive, false)
}
if (try? FileHandle(forReadingFrom: fileUrl)) == nil {
// Not readable yet: Caches stays protected until the first unlock after a reboot, and a background launch
// can come before it. The archive is most likely fine, so leave it alone this launch.
return (nil, emptyArchive, true)
}
// Readable but not an archive; the next save replaces it.
return (saveUrl, emptyArchive, true)
}
public func makeRenderPipelineState(descriptor: MTLRenderPipelineDescriptor) -> MTLRenderPipelineState? {
// An archive only serves its own device; functions from another device are compiled on theirs.
let functionDevice = descriptor.vertexFunction?.device ?? self.device
let isArchivable = functionDevice === self.device && (descriptor.fragmentFunction.flatMap({ $0.device === self.device }) ?? true)
if #available(macOS 11.0, iOS 14.0, *), isArchivable, let archive = self.archiveStorage as? MTLBinaryArchive, self.lock.try() {
defer {
self.lock.unlock()
}
if self.isArchiveDisabled {
return try? functionDevice.makeRenderPipelineState(descriptor: descriptor)
}
guard let descriptor = descriptor.copy() as? MTLRenderPipelineDescriptor else {
return nil
}
descriptor.binaryArchives = [archive]
if let pipelineState = try? self.device.makeRenderPipelineState(descriptor: descriptor, options: [.failOnBinaryArchiveMiss], reflection: nil) {
return pipelineState
}
// Compile it into the archive, then make it from there, so it is compiled once.
if (try? archive.addRenderPipelineFunctions(descriptor: descriptor)) != nil {
self.scheduleSave()
}
return try? self.device.makeRenderPipelineState(descriptor: descriptor)
}
return try? functionDevice.makeRenderPipelineState(descriptor: descriptor)
}
public func makeComputePipelineState(descriptor: MTLComputePipelineDescriptor) -> MTLComputePipelineState? {
let functionDevice = descriptor.computeFunction?.device ?? self.device
if #available(macOS 11.0, iOS 14.0, *), functionDevice === self.device, let archive = self.archiveStorage as? MTLBinaryArchive, self.lock.try() {
defer {
self.lock.unlock()
}
if self.isArchiveDisabled {
return try? functionDevice.makeComputePipelineState(descriptor: descriptor, options: [], reflection: nil)
}
guard let descriptor = descriptor.copy() as? MTLComputePipelineDescriptor else {
return nil
}
descriptor.binaryArchives = [archive]
if let pipelineState = try? self.device.makeComputePipelineState(descriptor: descriptor, options: [.failOnBinaryArchiveMiss], reflection: nil) {
return pipelineState
}
if (try? archive.addComputePipelineFunctions(descriptor: descriptor)) != nil {
self.scheduleSave()
}
return try? self.device.makeComputePipelineState(descriptor: descriptor, options: [], reflection: nil)
}
return try? functionDevice.makeComputePipelineState(descriptor: descriptor, options: [], reflection: nil)
}
public func makeComputePipelineState(function: MTLFunction) -> MTLComputePipelineState? {
let descriptor = MTLComputePipelineDescriptor()
descriptor.computeFunction = function
return self.makeComputePipelineState(descriptor: descriptor)
}
/// Called with `lock` held.
private func scheduleSave() {
if self.isSaveScheduled || self.url == nil {
return
}
self.isSaveScheduled = true
// Pipelines tend to be made in bursts; save once after one.
self.saveQueue.asyncAfter(deadline: .now() + 1.0, execute: { [weak self] in
self?.save()
})
}
private func save() {
guard #available(macOS 11.0, iOS 14.0, *) else {
return
}
// On the save queue, so waiting here holds up no one; callers skip the archive while it is held.
self.lock.lock()
defer {
self.lock.unlock()
}
self.isSaveScheduled = false
guard !self.isArchiveDisabled, let archive = self.archiveStorage as? MTLBinaryArchive, let url = self.url else {
return
}
// Created before serializing and removed after it returns, so it is only left behind by a process that died
// in between. Without it, a save that crashes would crash again on every launch.
let saveMarkerUrl = MetalPipelineCache.saveMarkerUrl(archiveUrl: url)
guard FileManager.default.createFile(atPath: saveMarkerUrl.path, contents: nil) else {
return
}
defer {
let _ = try? FileManager.default.removeItem(at: saveMarkerUrl)
}
let temporaryUrl = url.deletingLastPathComponent().appendingPathComponent(UUID().uuidString + ".tmp")
do {
try MetalBinaryArchiveSerialization.serialize(archive, to: temporaryUrl)
// rename(2) replaces the previous archive atomically: there is always either the old file or the new one.
if rename(temporaryUrl.path, url.path) != 0 {
let _ = try? FileManager.default.removeItem(at: temporaryUrl)
}
} catch {
let _ = try? FileManager.default.removeItem(at: temporaryUrl)
}
}
/// Whether this launch saves the archive (for tests).
var isSaveEnabled: Bool {
return self.url != nil
}
static func archiveFileName(device: MTLDevice) -> String {
return "pipelines-\(MetalPipelineCache.archiveKey(device: device)).metallib"
}
/// Exists while the archive at `archiveUrl` is being saved.
static func saveMarkerUrl(archiveUrl: URL) -> URL {
return archiveUrl.deletingPathExtension().appendingPathExtension("saving")
}
/// Identifies the app version, OS build and GPU the archived code was compiled for.
private static func archiveKey(device: MTLDevice) -> String {
let info = Bundle.main.infoDictionary
let appVersion = info?["CFBundleShortVersionString"] as? String ?? ""
let buildNumber = info?["CFBundleVersion"] as? String ?? ""
let components = [appVersion, buildNumber, ProcessInfo.processInfo.operatingSystemVersionString, device.name]
// FNV-1a: stable across launches, unlike `hashValue`.
var hash: UInt64 = 0xcbf29ce484222325
for byte in components.joined(separator: "|").utf8 {
hash ^= UInt64(byte)
hash = hash &* 0x100000001b3
}
return String(hash, radix: 16)
}
}