From e4a68a1bdd07c5b711582f684a867e3f6b461715 Mon Sep 17 00:00:00 2001 From: link2026 Date: Mon, 25 May 2026 23:18:08 +0800 Subject: [PATCH] fix(concurrency): clear 4 Swift 6 warnings under default MainActor isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelStore/FileVault: drop nonisolated(unsafe) on shared, mark all instance methods nonisolated (they only read filesystem); ModelKind enum also nonisolated - AIRuntime ↔ ModelStore cross-actor call resolved by the above - LLMSession: replace deprecated Device.setDefault(device:) with task-scoped Device.withDefaultDevice(.cpu, body:); wrap both load and generate so the TaskLocal propagates through ModelContainer.perform --- 康康/AI/LLMSession.swift | 93 ++++++++++++++++++-------------- 康康/AI/ModelStore.swift | 16 +++--- 康康/Persistence/FileVault.swift | 15 +++--- 3 files changed, 67 insertions(+), 57 deletions(-) diff --git a/康康/AI/LLMSession.swift b/康康/AI/LLMSession.swift index 1109951..5aebe69 100644 --- a/康康/AI/LLMSession.swift +++ b/康康/AI/LLMSession.swift @@ -12,18 +12,27 @@ actor LLMSession { self.container = container } + /// 在 simulator 把默认设备强切为 CPU(MLX 的 Metal backend 在部分 Sim 路径会 abort)。 + /// 真机走 body 默认设备(GPU/ANE)。 + /// 用 task-scoped `withDefaultDevice`,TaskLocal 会传递到 child Task / actor 调用。 + private static func withDeviceOverride( + _ body: () async throws -> R + ) async rethrows -> R { + #if targetEnvironment(simulator) + return try await Device.withDefaultDevice(.cpu, body) + #else + return try await body() + #endif + } + /// 从本地目录加载模型(包含 config.json + weights + tokenizer)。 static func load(folderURL: URL) async throws -> LLMSession { - #if targetEnvironment(simulator) - // MLX 的 iOS Simulator GPU stream 初始化会在部分 Metal backend 路径中 abort。 - // 模拟器只用于功能调试,强制走 CPU;真机仍保留默认 GPU/ANE 相关路径。 - Device.setDefault(device: .cpu) - #endif - let configuration = ModelConfiguration(directory: folderURL) - let container = try await LLMModelFactory.shared.loadContainer( - configuration: configuration - ) + let container = try await withDeviceOverride { + try await LLMModelFactory.shared.loadContainer( + configuration: configuration + ) + } return LLMSession(container: container) } @@ -35,46 +44,48 @@ actor LLMSession { AsyncThrowingStream { continuation in let task = Task { do { - let parameters = GenerateParameters( - maxTokens: maxTokens, - temperature: Float(0.6), - topP: Float(0.9) - ) + try await Self.withDeviceOverride { + let parameters = GenerateParameters( + maxTokens: maxTokens, + temperature: Float(0.6), + topP: Float(0.9) + ) - try await container.perform { (context: ModelContext) in - let userInput = UserInput(prompt: prompt) - let lmInput = try await context.processor.prepare(input: userInput) + try await container.perform { (context: ModelContext) in + let userInput = UserInput(prompt: prompt) + let lmInput = try await context.processor.prepare(input: userInput) - let start = Date() - var produced = 0 + let start = Date() + var produced = 0 - for await event in try MLXLMCommon.generate( - input: lmInput, - parameters: parameters, - context: context - ) { - if Task.isCancelled { break } + for await event in try MLXLMCommon.generate( + input: lmInput, + parameters: parameters, + context: context + ) { + if Task.isCancelled { break } - switch event { - case .chunk(let text): - produced += 1 - let elapsed = Date().timeIntervalSince(start) - let rate = elapsed > 0 ? Double(produced) / elapsed : 0 - continuation.yield(TokenChunk(text: text, decodeRate: rate)) + switch event { + case .chunk(let text): + produced += 1 + let elapsed = Date().timeIntervalSince(start) + let rate = elapsed > 0 ? Double(produced) / elapsed : 0 + continuation.yield(TokenChunk(text: text, decodeRate: rate)) - case .info: - // 生成完成统计,是流的最后一个事件 - break + case .info: + // 生成完成统计,是流的最后一个事件 + break - case .toolCall: - // 纯文本生成不会触发,switch 穷举 - break + case .toolCall: + // 纯文本生成不会触发,switch 穷举 + break + } } + // 注:研究笔记里曾建议尾部 MLX.GPU.synchronize() 以确保 + // GPU 操作全部完成。但 AsyncStream 已经 yield 真实解码后的 + // 文字,GPU 是否完全空闲不影响数据正确性。去掉此调用同时省 + // 一份 transitive import MLX 的依赖,简化 SPM 链接。 } - // 注:研究笔记里曾建议尾部 MLX.GPU.synchronize() 以确保 - // GPU 操作全部完成。但 AsyncStream 已经 yield 真实解码后的 - // 文字,GPU 是否完全空闲不影响数据正确性。去掉此调用同时省 - // 一份 transitive import MLX 的依赖,简化 SPM 链接。 } continuation.finish() } catch { diff --git a/康康/AI/ModelStore.swift b/康康/AI/ModelStore.swift index 834db85..613ca4b 100644 --- a/康康/AI/ModelStore.swift +++ b/康康/AI/ModelStore.swift @@ -1,6 +1,6 @@ import Foundation -enum ModelKind: String, CaseIterable { +nonisolated enum ModelKind: String, CaseIterable { /// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。 case llm = "Qwen3-1.7B-4bit" case vl = "Qwen2.5-VL-3B-Instruct-4bit" @@ -21,10 +21,10 @@ enum ModelKind: String, CaseIterable { /// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全), /// 可被任意 actor / Task 跨边界访问。 -/// `nonisolated(unsafe) shared`:项目开启 `-default-isolation=MainActor` 后 -/// static 默认 MainActor 隔离,跨 actor 访问需要 await。这里手动 opt-out。 +/// 实例方法显式 `nonisolated`:项目开了 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, +/// 默认会把方法推到 MainActor,导致从 `AIRuntime` 等 actor 调用报错。 final class ModelStore: @unchecked Sendable { - nonisolated(unsafe) static let shared: ModelStore = { + nonisolated static let shared: ModelStore = { do { let appSupport = try FileManager.default.url( for: .applicationSupportDirectory, @@ -46,16 +46,16 @@ final class ModelStore: @unchecked Sendable { try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) } - func localURL(for kind: ModelKind) -> URL { + nonisolated func localURL(for kind: ModelKind) -> URL { rootURL.appendingPathComponent(kind.rawValue, isDirectory: true) } - func isReady(_ kind: ModelKind) -> Bool { + nonisolated func isReady(_ kind: ModelKind) -> Bool { let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename) return FileManager.default.fileExists(atPath: sentinel.path) } - func totalBytes(for kind: ModelKind) -> Int { + nonisolated func totalBytes(for kind: ModelKind) -> Int { let folder = localURL(for: kind) guard let enumerator = FileManager.default.enumerator( at: folder, @@ -71,7 +71,7 @@ final class ModelStore: @unchecked Sendable { } /// Demo 现场旁路:从 Bundle 拷贝预装模型(W6 才真正使用,本周占位) - func seedFromBundle(_ kind: ModelKind) throws { + nonisolated func seedFromBundle(_ kind: ModelKind) throws { guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else { #if DEBUG assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target") diff --git a/康康/Persistence/FileVault.swift b/康康/Persistence/FileVault.swift index 77c4dc8..223217b 100644 --- a/康康/Persistence/FileVault.swift +++ b/康康/Persistence/FileVault.swift @@ -9,10 +9,9 @@ enum FileVaultError: Error { } /// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全), -/// 可被任意 actor / Task 跨边界访问。 -/// `nonisolated(unsafe) shared`:见 ModelStore 同款注释。 +/// 可被任意 actor / Task 跨边界访问。实例方法显式 `nonisolated`,见 ModelStore 同款注释。 final class FileVault: @unchecked Sendable { - nonisolated(unsafe) static let shared: FileVault = { + nonisolated static let shared: FileVault = { do { let appSupport = try FileManager.default.url( for: .applicationSupportDirectory, @@ -45,7 +44,7 @@ final class FileVault: @unchecked Sendable { // MARK: - Path Safety - private func resolveSafePath(_ relativePath: String) throws -> URL { + nonisolated private func resolveSafePath(_ relativePath: String) throws -> URL { guard !relativePath.contains("/"), !relativePath.contains(".."), !relativePath.isEmpty else { @@ -60,7 +59,7 @@ final class FileVault: @unchecked Sendable { // MARK: - Public API - func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset { + nonisolated func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset { guard let data = image.jpegData(compressionQuality: quality) else { throw FileVaultError.writeFailed } @@ -70,7 +69,7 @@ final class FileVault: @unchecked Sendable { return SavedAsset(relativePath: filename, bytes: data.count) } - func loadImage(relativePath: String) throws -> UIImage { + nonisolated func loadImage(relativePath: String) throws -> UIImage { let url = try resolveSafePath(relativePath) let data: Data do { @@ -82,7 +81,7 @@ final class FileVault: @unchecked Sendable { return image } - func remove(relativePath: String) throws { + nonisolated func remove(relativePath: String) throws { let url = try resolveSafePath(relativePath) do { try FileManager.default.removeItem(at: url) @@ -91,7 +90,7 @@ final class FileVault: @unchecked Sendable { } } - func wipe() throws { + nonisolated func wipe() throws { let fm = FileManager.default let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil) for url in contents {