mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-02 22:04:28 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
311 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|