From 49a186714b72bcce768f3247aa2b79907e8f6336 Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Mon, 31 Aug 2026 23:18:31 -0400 Subject: [PATCH] fix(diarizer/offline): warn on macOS 14 BNNS-crash-prone builds + document known OS issue (#878) Library-side portion of #878 (follow-up to #661/#817 family). JulianPscheid's CI matrix proved the offline diarizer libBNNS crash (EXC_BAD_ACCESS in BNNSGraphContextExecute_v2 -> _platform_memmove) is an Apple OS bug in the macOS 14 line: 1200/1200 crashes on macos-14 runners across every variant (unpatched v0.15.5, Core ML-owned FBank storage, fully serialized single-model runs), 0/N on macos-15 and macos-26. On VMs without an ANE every prediction takes the BNNS CPU path, making the crash deterministic; on Apple Silicon it is intermittent because only ANE-fallback predictions hit BNNS. No serialization or compute-unit routing avoids it; Apple fixed it in macOS 15. Since no library-side mitigation exists, the honest fix is diagnosis (same approach as #818 for the separate 26.4-26.5 Kokoro BNNS bug): - OfflineDiarizerManager.initialize(models:) warns on macOS 14 builds pointing at #878, so field crashes get attributed to the OS instead of usage patterns or FluidAudio versions. Predicate extracted as isBnnsCrashProneOS(_:onMacOS:) for testability; iOS is unflagged since no reproduction has been reported on the iOS 17 line. - OfflineDiarizerOsAdvisoryTests: boundary coverage (13.6 / 14.0 / 14.8.7 / 15.0 / 26.5.2, iOS unflagged). - Documentation/Diarization/GettingStarted.md: Known OS issue note in the Offline VBx section, distinguishing this bug from the Kokoro 26.4-26.5 class. - Cross-ref comment at the FBank .cpuOnly pin, which is why the diarizer is the component that surfaces this on macOS 14. swift build green; swift format lint clean on touched files. Tests run in CI (XCTest unavailable locally). --- Documentation/Diarization/GettingStarted.md | 2 ++ .../Offline/Core/OfflineDiarizerManager.swift | 25 +++++++++++++++ .../Offline/Core/OfflineDiarizerModels.swift | 2 ++ .../OfflineDiarizerOsAdvisoryTests.swift | 32 +++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 Tests/FluidAudioTests/Diarizer/Offline/OfflineDiarizerOsAdvisoryTests.swift diff --git a/Documentation/Diarization/GettingStarted.md b/Documentation/Diarization/GettingStarted.md index df62409d1..73eeef4a9 100644 --- a/Documentation/Diarization/GettingStarted.md +++ b/Documentation/Diarization/GettingStarted.md @@ -191,6 +191,8 @@ let diarizer = DiarizerManager(config: config) > Requires macOS 14 / iOS 17 or later. The offline stack uses native C++ clustering and AsyncStream coordination that are unavailable on older OS releases. +> **Known OS issue — macOS 14 can crash in BNNS.** macOS 14 (all patch levels through at least 14.8.7) carries an Apple bug that crashes Core ML predictions on the BNNS CPU path (`EXC_BAD_ACCESS` in `libBNNS`, `BNNSGraphContextExecute_v2` → `_platform_memmove`). Offline diarization is the component most exposed because its FBank model always runs on CPU. Nothing at the library or app level avoids it — serialized pipelines, single-model runs, and every compute-unit routing all crash ([#878](https://github.com/FluidInference/FluidAudio/issues/878), 1200/1200 reproduction on macOS 14 CI runners; also [#661](https://github.com/FluidInference/FluidAudio/issues/661)). On machines without a Neural Engine (VMs, CI runners) the crash is deterministic; on Apple Silicon it is intermittent, striking when predictions fall back from the ANE to BNNS. Apple fixed it in macOS 15 — the only remedy is updating the OS. `OfflineDiarizerManager` logs a warning when initialized on an affected build. (This is a distinct bug from the macOS/iOS 26.4–26.5 BNNS crash documented for Kokoro TTS in [KokoroAne.md](../TTS/KokoroAne.md).) + When you need full parity with the pyannote/Core ML exporter (powerset segmentation + VBx clustering), use `OfflineDiarizerManager`. It orchestrates segmentation, soft mask interpolation, WeSpeaker embedding extraction, PLDA/VBx clustering, and timeline reconstruction in one place: ```swift diff --git a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift index 17f04669b..f8cc9b912 100644 --- a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift +++ b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift @@ -17,10 +17,35 @@ public final class OfflineDiarizerManager { } public func initialize(models: OfflineDiarizerModels) { + if Self.isBnnsCrashProneOS(ProcessInfo.processInfo.operatingSystemVersion) { + logger.warning( + "macOS 14 has a known Apple BNNS bug that can crash offline " + + "diarization (EXC_BAD_ACCESS in libBNNS) regardless of compute-unit " + + "routing or serialization. Fixed in macOS 15. " + + "See https://github.com/FluidInference/FluidAudio/issues/878") + } self.models = models logger.info("Offline diarizer models initialized") } + #if os(macOS) + private static let runningOnMacOS = true + #else + private static let runningOnMacOS = false + #endif + + /// macOS 14 carries an Apple BNNS bug that crashes Core ML predictions on + /// the BNNS CPU path (`BNNSGraphContextExecute_v2` → `_platform_memmove`, + /// #661/#878). Deterministic on machines without an ANE, intermittent on + /// Apple Silicon when predictions fall back from the ANE. No usage pattern + /// avoids it; Apple fixed it in macOS 15. iOS is unflagged — no reproduction + /// has been reported on the iOS 17 line. + static func isBnnsCrashProneOS( + _ version: OperatingSystemVersion, onMacOS: Bool = runningOnMacOS + ) -> Bool { + onMacOS && version.majorVersion == 14 + } + /// Ensure offline diarizer models are available, downloading and compiling them when needed. /// - Parameters: /// - directory: Custom cache directory. Defaults to `OfflineDiarizerModels.defaultModelsDirectory()`. diff --git a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift index ad9dd38bd..51efa24c7 100644 --- a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift +++ b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift @@ -111,6 +111,8 @@ public struct OfflineDiarizerModels: Sendable { throw OfflineDiarizationError.modelNotLoaded(ModelNames.OfflineDiarizer.pldaRho) } + // CPU is fastest for FBank, but on macOS 14 this pins every FBank + // prediction to the crash-prone BNNS path (#878). let fbankComputeUnits: MLComputeUnits = .cpuOnly let fbankModels = try await ModelHub.loadModels( .diarizer, diff --git a/Tests/FluidAudioTests/Diarizer/Offline/OfflineDiarizerOsAdvisoryTests.swift b/Tests/FluidAudioTests/Diarizer/Offline/OfflineDiarizerOsAdvisoryTests.swift new file mode 100644 index 000000000..0b32dec9d --- /dev/null +++ b/Tests/FluidAudioTests/Diarizer/Offline/OfflineDiarizerOsAdvisoryTests.swift @@ -0,0 +1,32 @@ +import XCTest + +@testable import FluidAudio + +final class OfflineDiarizerOsAdvisoryTests: XCTestCase { + + private func version(_ major: Int, _ minor: Int, _ patch: Int = 0) -> OperatingSystemVersion { + OperatingSystemVersion(majorVersion: major, minorVersion: minor, patchVersion: patch) + } + + // #878: the entire macOS 14 line crashes in libBNNS regardless of + // patch level (1200/1200 on 14.8.7 CI runners). + func testMacOS14LineIsFlagged() { + XCTAssertTrue(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 0), onMacOS: true)) + XCTAssertTrue(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 6, 1), onMacOS: true)) + XCTAssertTrue(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 8, 7), onMacOS: true)) + } + + func testFixedAndUnaffectedMacOSLinesAreNotFlagged() { + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(13, 6), onMacOS: true)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(15, 0), onMacOS: true)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(15, 7, 7), onMacOS: true)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(26, 5, 2), onMacOS: true)) + } + + // No reproduction reported on iOS; only macOS is flagged. + func testIOSIsNotFlagged() { + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 0), onMacOS: false)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(17, 0), onMacOS: false)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(18, 0), onMacOS: false)) + } +}