fix(concurrency): clear 4 Swift 6 warnings under default MainActor isolation
- 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
This commit is contained in:
@@ -12,18 +12,27 @@ actor LLMSession {
|
|||||||
self.container = container
|
self.container = container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在 simulator 把默认设备强切为 CPU(MLX 的 Metal backend 在部分 Sim 路径会 abort)。
|
||||||
|
/// 真机走 body 默认设备(GPU/ANE)。
|
||||||
|
/// 用 task-scoped `withDefaultDevice`,TaskLocal 会传递到 child Task / actor 调用。
|
||||||
|
private static func withDeviceOverride<R>(
|
||||||
|
_ 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)。
|
/// 从本地目录加载模型(包含 config.json + weights + tokenizer)。
|
||||||
static func load(folderURL: URL) async throws -> LLMSession {
|
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 configuration = ModelConfiguration(directory: folderURL)
|
||||||
let container = try await LLMModelFactory.shared.loadContainer(
|
let container = try await withDeviceOverride {
|
||||||
configuration: configuration
|
try await LLMModelFactory.shared.loadContainer(
|
||||||
)
|
configuration: configuration
|
||||||
|
)
|
||||||
|
}
|
||||||
return LLMSession(container: container)
|
return LLMSession(container: container)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,46 +44,48 @@ actor LLMSession {
|
|||||||
AsyncThrowingStream { continuation in
|
AsyncThrowingStream { continuation in
|
||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
let parameters = GenerateParameters(
|
try await Self.withDeviceOverride {
|
||||||
maxTokens: maxTokens,
|
let parameters = GenerateParameters(
|
||||||
temperature: Float(0.6),
|
maxTokens: maxTokens,
|
||||||
topP: Float(0.9)
|
temperature: Float(0.6),
|
||||||
)
|
topP: Float(0.9)
|
||||||
|
)
|
||||||
|
|
||||||
try await container.perform { (context: ModelContext) in
|
try await container.perform { (context: ModelContext) in
|
||||||
let userInput = UserInput(prompt: prompt)
|
let userInput = UserInput(prompt: prompt)
|
||||||
let lmInput = try await context.processor.prepare(input: userInput)
|
let lmInput = try await context.processor.prepare(input: userInput)
|
||||||
|
|
||||||
let start = Date()
|
let start = Date()
|
||||||
var produced = 0
|
var produced = 0
|
||||||
|
|
||||||
for await event in try MLXLMCommon.generate(
|
for await event in try MLXLMCommon.generate(
|
||||||
input: lmInput,
|
input: lmInput,
|
||||||
parameters: parameters,
|
parameters: parameters,
|
||||||
context: context
|
context: context
|
||||||
) {
|
) {
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
|
|
||||||
switch event {
|
switch event {
|
||||||
case .chunk(let text):
|
case .chunk(let text):
|
||||||
produced += 1
|
produced += 1
|
||||||
let elapsed = Date().timeIntervalSince(start)
|
let elapsed = Date().timeIntervalSince(start)
|
||||||
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||||
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||||
|
|
||||||
case .info:
|
case .info:
|
||||||
// 生成完成统计,是流的最后一个事件
|
// 生成完成统计,是流的最后一个事件
|
||||||
break
|
break
|
||||||
|
|
||||||
case .toolCall:
|
case .toolCall:
|
||||||
// 纯文本生成不会触发,switch 穷举
|
// 纯文本生成不会触发,switch 穷举
|
||||||
break
|
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()
|
continuation.finish()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ModelKind: String, CaseIterable {
|
nonisolated enum ModelKind: String, CaseIterable {
|
||||||
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
||||||
case llm = "Qwen3-1.7B-4bit"
|
case llm = "Qwen3-1.7B-4bit"
|
||||||
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
|
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
|
||||||
@@ -21,10 +21,10 @@ enum ModelKind: String, CaseIterable {
|
|||||||
|
|
||||||
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
||||||
/// 可被任意 actor / Task 跨边界访问。
|
/// 可被任意 actor / Task 跨边界访问。
|
||||||
/// `nonisolated(unsafe) shared`:项目开启 `-default-isolation=MainActor` 后
|
/// 实例方法显式 `nonisolated`:项目开了 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,
|
||||||
/// static 默认 MainActor 隔离,跨 actor 访问需要 await。这里手动 opt-out。
|
/// 默认会把方法推到 MainActor,导致从 `AIRuntime` 等 actor 调用报错。
|
||||||
final class ModelStore: @unchecked Sendable {
|
final class ModelStore: @unchecked Sendable {
|
||||||
nonisolated(unsafe) static let shared: ModelStore = {
|
nonisolated static let shared: ModelStore = {
|
||||||
do {
|
do {
|
||||||
let appSupport = try FileManager.default.url(
|
let appSupport = try FileManager.default.url(
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
@@ -46,16 +46,16 @@ final class ModelStore: @unchecked Sendable {
|
|||||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
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)
|
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)
|
let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename)
|
||||||
return FileManager.default.fileExists(atPath: sentinel.path)
|
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)
|
let folder = localURL(for: kind)
|
||||||
guard let enumerator = FileManager.default.enumerator(
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
at: folder,
|
at: folder,
|
||||||
@@ -71,7 +71,7 @@ final class ModelStore: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Demo 现场旁路:从 Bundle 拷贝预装模型(W6 才真正使用,本周占位)
|
/// 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 {
|
guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target")
|
assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target")
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ enum FileVaultError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全),
|
/// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全),
|
||||||
/// 可被任意 actor / Task 跨边界访问。
|
/// 可被任意 actor / Task 跨边界访问。实例方法显式 `nonisolated`,见 ModelStore 同款注释。
|
||||||
/// `nonisolated(unsafe) shared`:见 ModelStore 同款注释。
|
|
||||||
final class FileVault: @unchecked Sendable {
|
final class FileVault: @unchecked Sendable {
|
||||||
nonisolated(unsafe) static let shared: FileVault = {
|
nonisolated static let shared: FileVault = {
|
||||||
do {
|
do {
|
||||||
let appSupport = try FileManager.default.url(
|
let appSupport = try FileManager.default.url(
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
@@ -45,7 +44,7 @@ final class FileVault: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Path Safety
|
// MARK: - Path Safety
|
||||||
|
|
||||||
private func resolveSafePath(_ relativePath: String) throws -> URL {
|
nonisolated private func resolveSafePath(_ relativePath: String) throws -> URL {
|
||||||
guard !relativePath.contains("/"),
|
guard !relativePath.contains("/"),
|
||||||
!relativePath.contains(".."),
|
!relativePath.contains(".."),
|
||||||
!relativePath.isEmpty else {
|
!relativePath.isEmpty else {
|
||||||
@@ -60,7 +59,7 @@ final class FileVault: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Public API
|
// 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 {
|
guard let data = image.jpegData(compressionQuality: quality) else {
|
||||||
throw FileVaultError.writeFailed
|
throw FileVaultError.writeFailed
|
||||||
}
|
}
|
||||||
@@ -70,7 +69,7 @@ final class FileVault: @unchecked Sendable {
|
|||||||
return SavedAsset(relativePath: filename, bytes: data.count)
|
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 url = try resolveSafePath(relativePath)
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
@@ -82,7 +81,7 @@ final class FileVault: @unchecked Sendable {
|
|||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(relativePath: String) throws {
|
nonisolated func remove(relativePath: String) throws {
|
||||||
let url = try resolveSafePath(relativePath)
|
let url = try resolveSafePath(relativePath)
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: url)
|
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 fm = FileManager.default
|
||||||
let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)
|
let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)
|
||||||
for url in contents {
|
for url in contents {
|
||||||
|
|||||||
Reference in New Issue
Block a user