Download swift/Sources/EmbeddingGemma2CoreAI/Embedder.swift from EverPaper/embeddinggemma-2-coreai: direct link, hf CLI and curl.
- Browser
- Download file 28.3 kB
-
https://huggingface.co/EverPaper/embeddinggemma-2-coreai/resolve/main/swift/Sources/EmbeddingGemma2CoreAI/Embedder.swift
- Command line
-
hf download hf://EverPaper/embeddinggemma-2-coreai/swift/Sources/EmbeddingGemma2CoreAI/Embedder.swift
-
curl -L -o Embedder.swift https://huggingface.co/EverPaper/embeddinggemma-2-coreai/resolve/main/swift/Sources/EmbeddingGemma2CoreAI/Embedder.swift
28.3 kB
| // Copyright 2026 Everpaper. Licensed under the Apache License, Version 2.0 (see ../../../LICENSE). | |
| // | |
| // EmbeddingGemma 2 (text + images + audio) on Apple Core AI, macOS 27. Loads the bundle folder (config.json, | |
| // tokenizer/, the text asset and, on request, the vision and audio assets) and embeds texts, images and audio clips | |
| // into the same 768-d space: | |
| // text -> exact Gemma tokenizer -> <bos> prompt text <eos> -> `mm_<S>` with zero soft-token inputs -> embedding | |
| // image -> decode (alpha dropped, no EXIF, no colour management) -> exact resize -> patches -> `vision_2520` | |
| // -> n soft tokens -> sequence <bos> <|image> [n slots] <image|> <eos> -> `mm_288` -> embedding | |
| // audio -> decode -> mono -> 16 kHz -> log-mel frames -> `audio_<Tb>` -> n soft tokens | |
| // -> sequence <bos> <|audio> [n slots] <audio|> <eos> -> `mm_<S>` -> embedding | |
| // All outputs are L2-normalized by the graph; Matryoshka prefixes (512/256/128) are re-normalized on the host. | |
| // | |
| // The v1.0 API (init(bundle:compute:), tokenize, embed(ids:), embed(_:kind:dim:), prewarm) is unchanged and still | |
| // loads the text asset only; pass `loadVision: true` to embed images and `loadAudio: true` to embed audio. `compute` | |
| // places the text asset, `imageCompute` and `audioCompute` the image and audio encoders (default: the same as | |
| // `compute`): the Neural Engine uses the least CPU, GPU and power, the GPU is faster (README, "Images: Neural Engine | |
| // or GPU" and "Audio: Neural Engine or GPU"). | |
| import CoreAI | |
| import CoreGraphics | |
| import Foundation | |
| public struct BundleConfig: Decodable, Sendable { | |
| public struct Vision: Decodable, Sendable { | |
| public let asset: String | |
| public let function: String | |
| public let max_patches: Int | |
| public let max_soft_tokens: Int | |
| } | |
| public struct Audio: Decodable, Sendable { | |
| public let asset: String | |
| public let functions: [String] | |
| public let frame_buckets: [Int] | |
| public let max_soft_tokens: Int | |
| } | |
| /// The text asset (`mm_<S>` functions: text, images, or both in one sequence). | |
| public let asset: String | |
| public let functions: [String] | |
| public let buckets: [Int] | |
| public let max_tokens: Int | |
| public let vision: Vision | |
| /// The audio asset (`audio_<Tb>` functions); absent in a 1.1 bundle. | |
| public let audio: Audio? | |
| public let hidden_size: Int | |
| public let embedding_dim: Int | |
| public let matryoshka_dims: [Int] | |
| public let prompts: [String: String] | |
| public let bos_token_id: Int32 | |
| public let eos_token_id: Int32 | |
| public let pad_token_id: Int32 | |
| public let boi_token_id: Int32 | |
| public let image_token_id: Int32 | |
| public let eoi_token_id: Int32 | |
| public let boa_token_id: Int32? | |
| public let audio_token_id: Int32? | |
| public let eoa_token_id: Int32? | |
| } | |
| /// How audio longer than the model's 30 s window is embedded. | |
| public enum LongAudioPolicy: String, Sendable { | |
| /// The first 30 s only (what the upstream model does). | |
| case truncate | |
| /// The mean of the normalized embeddings of consecutive 30 s windows, re-normalized. | |
| case chunkMean = "chunk_mean" | |
| } | |
| public enum ComputePreference: String, Sendable { | |
| case neuralEngine = "ane" | |
| case gpu | |
| case cpu | |
| case `default` | |
| } | |
| public struct EmbedderError: Error, CustomStringConvertible { | |
| public let description: String | |
| public init(description: String) { self.description = description } | |
| } | |
| /// A prepared sequence for `mm_<S>`: token ids with placeholder slots, and the soft tokens that fill them. | |
| public struct MultimodalSequence: Sendable { | |
| public var ids: [Int32] | |
| public var softTokens: [Float16] // [slots * 512] in sequence order | |
| public var slots: [Int] // positions in `ids` that receive soft tokens (same count as rows) | |
| public init(ids: [Int32], softTokens: [Float16] = [], slots: [Int] = []) { | |
| self.ids = ids | |
| self.softTokens = softTokens | |
| self.slots = slots | |
| } | |
| } | |
| public final class EmbeddingGemma2 { | |
| public let bundleURL: URL | |
| public let config: BundleConfig | |
| public let tokenizer: GemmaTokenizer | |
| /// Where the text asset (texts, and the image sequence after the image encoder) runs. | |
| public let compute: ComputePreference | |
| /// Where the image encoder (`vision_2520`) runs; equals `compute` unless set with `imageCompute:`. | |
| public let imageCompute: ComputePreference | |
| /// Where the audio encoder (`audio_<Tb>`) runs; equals `compute` unless set with `audioCompute:`. | |
| public let audioCompute: ComputePreference | |
| /// Wall-clock seconds spent in `AIModel(contentsOf:)` + `loadFunction` (specialization) for the loaded assets. | |
| /// About 1 s warm; the first load by an executable that has not loaded these assets before takes minutes (README). | |
| public let loadSeconds: Double | |
| public let tokenizerLoadSeconds: Double | |
| private let textModel: AIModel | |
| private let functions: [Int: InferenceFunction] // bucket -> mm_<S> | |
| private let visionModel: AIModel? // nil unless created with loadVision: true | |
| private let visionFunction: InferenceFunction? | |
| private let audioModel: AIModel? // nil unless created with loadAudio: true | |
| private let audioFunctions: [Int: InferenceFunction] // frame bucket -> audio_<Tb> | |
| public var buckets: [Int] { functions.keys.sorted() } | |
| /// Frame buckets of the loaded audio asset (empty unless loaded). | |
| public var audioBuckets: [Int] { audioFunctions.keys.sorted() } | |
| /// True when the vision asset is loaded and `embedImage` / `softTokens` can be used. | |
| public var hasVision: Bool { visionFunction != nil } | |
| /// True when the audio asset is loaded and `embedAudio` / `audioSoftTokens` can be used. | |
| public var hasAudio: Bool { !audioFunctions.isEmpty } | |
| private var hidden: Int { config.hidden_size } | |
| /// `loadVision: false` (the default, as in v1.0) loads the text asset only. `loadVision: true` also loads the | |
| /// vision asset (~300 MB more memory, its own first-load specialization) so that images can be embedded. | |
| /// `imageCompute` places the image encoder independently of the text asset (nil = same as `compute`): | |
| /// `.neuralEngine` ~0.37 s per image with ~2 ms CPU and ~1 ms GPU time (lowest power); `.gpu` ~0.13 s per image | |
| /// with ~20-35 ms CPU and ~0.1 s GPU time (about 3x the power draw). Both match the reference within float16 noise. | |
| /// `loadAudio: true` also loads the audio asset (~560 MB) so that audio clips can be embedded; `audioCompute` | |
| /// places it independently (nil = same as `compute`), see README "Audio: Neural Engine or GPU". | |
| public init(bundle: URL, compute: ComputePreference = .neuralEngine, loadVision: Bool = false, | |
| imageCompute: ComputePreference? = nil, loadAudio: Bool = false, | |
| audioCompute: ComputePreference? = nil) async throws { | |
| bundleURL = bundle | |
| self.compute = compute | |
| self.imageCompute = imageCompute ?? compute | |
| self.audioCompute = audioCompute ?? compute | |
| config = try JSONDecoder().decode(BundleConfig.self, from: Data(contentsOf: bundle.appendingPathComponent("config.json"))) | |
| var t0 = Date() | |
| tokenizer = try GemmaTokenizer(contentsOf: bundle.appendingPathComponent("tokenizer/tokenizer.json")) | |
| tokenizerLoadSeconds = Date().timeIntervalSince(t0) | |
| guard tokenizer.bosID == config.bos_token_id, tokenizer.eosID == config.eos_token_id, tokenizer.padID == config.pad_token_id else { | |
| throw EmbedderError(description: "tokenizer special ids do not match config.json") | |
| } | |
| // Refuse an unsupported audio request before loading anything (no specialization, no temp files). | |
| if loadAudio { | |
| guard config.audio != nil else { throw EmbedderError(description: "this bundle has no audio asset (config.json \"audio\")") } | |
| guard self.audioCompute != .cpu else { | |
| throw EmbedderError(description: "audioCompute: .cpu is not supported for audio; use .neuralEngine or .gpu") | |
| } | |
| } | |
| t0 = Date() | |
| textModel = try await AIModel(contentsOf: bundle.appendingPathComponent(config.asset), options: Self.options(compute)) | |
| var fns: [Int: InferenceFunction] = [:] | |
| for name in config.functions { | |
| guard let s = Int(name.split(separator: "_").last ?? ""), let fn = try textModel.loadFunction(named: name) else { | |
| throw EmbedderError(description: "function \(name) missing from \(config.asset)") | |
| } | |
| fns[s] = fn | |
| } | |
| functions = fns | |
| if loadVision { | |
| let vm = try await AIModel(contentsOf: bundle.appendingPathComponent(config.vision.asset), options: Self.options(self.imageCompute)) | |
| guard let vf = try vm.loadFunction(named: config.vision.function) else { | |
| throw EmbedderError(description: "function \(config.vision.function) missing from \(config.vision.asset)") | |
| } | |
| visionModel = vm | |
| visionFunction = vf | |
| } else { | |
| visionModel = nil | |
| visionFunction = nil | |
| } | |
| if loadAudio { | |
| guard let ac = config.audio else { throw EmbedderError(description: "this bundle has no audio asset (config.json \"audio\")") } | |
| let am = try await AIModel(contentsOf: bundle.appendingPathComponent(ac.asset), options: Self.options(self.audioCompute)) | |
| var afns: [Int: InferenceFunction] = [:] | |
| for name in ac.functions { | |
| guard let tb = Int(name.split(separator: "_").last ?? ""), let fn = try am.loadFunction(named: name) else { | |
| throw EmbedderError(description: "function \(name) missing from \(ac.asset)") | |
| } | |
| afns[tb] = fn | |
| } | |
| audioModel = am | |
| audioFunctions = afns | |
| } else { | |
| audioModel = nil | |
| audioFunctions = [:] | |
| } | |
| loadSeconds = Date().timeIntervalSince(t0) | |
| } | |
| private static func options(_ compute: ComputePreference) -> SpecializationOptions { | |
| switch compute { | |
| case .gpu: return SpecializationOptions(preferredComputeUnitKind: .gpu) | |
| case .cpu: return SpecializationOptions.cpuOnly | |
| case .neuralEngine: return SpecializationOptions(preferredComputeUnitKind: .neuralEngine) | |
| case .default: return SpecializationOptions.default | |
| } | |
| } | |
| // MARK: - Tokenization | |
| /// `<bos>` + prompt prefix + text + `<eos>`, truncated to the largest bucket (2048 tokens: longer input is cut). | |
| /// `kind` is a key of `config.prompts` ("document", "search_query", ...); nil = no prefix. | |
| public func tokenize(_ text: String, kind: String? = "document") -> [Int32] { | |
| let prefix = kind.flatMap { config.prompts[$0] } ?? "" | |
| var ids = tokenizer.encode(prefix + text) | |
| let maxBody = (buckets.last ?? 32) - 2 | |
| if ids.count > maxBody { ids = Array(ids.prefix(maxBody)) } | |
| return [config.bos_token_id] + ids + [config.eos_token_id] | |
| } | |
| public func bucket(forTokenCount n: Int) throws -> Int { | |
| guard let b = buckets.first(where: { $0 >= n }) else { | |
| throw EmbedderError(description: "\(n) tokens exceeds the largest bucket \(buckets.last ?? 0)") | |
| } | |
| return b | |
| } | |
| // MARK: - Text | |
| /// Runs `mm_<bucket>` on already-tokenized ids (with `<bos>`/`<eos>`), right-padded with `<pad>`, no soft tokens. | |
| /// Returns the raw 768-d output (already L2-normalized by the graph), as Float. | |
| public func embed(ids: [Int32], bucket: Int? = nil) async throws -> [Float] { | |
| try await embed(sequence: MultimodalSequence(ids: ids), bucket: bucket) | |
| } | |
| /// Embeds one text. `dim` 768 (full) or a Matryoshka size 512/256/128: the leading dims, re-normalized. | |
| public func embed(_ text: String, kind: String? = "document", dim: Int = 768) async throws -> [Float] { | |
| try normalized(try await embed(ids: tokenize(text, kind: kind)), dim: dim) | |
| } | |
| // MARK: - Images | |
| /// Embeds an image file. `applyEXIFOrientation: false` reproduces the upstream reference exactly; `true` | |
| /// embeds camera photos upright (what an app usually wants; the two vectors differ by cosine ~0.97). | |
| public func embedImage(url: URL, applyEXIFOrientation: Bool = false, dim: Int = 768) async throws -> [Float] { | |
| try await embedImage(try ImagePreprocessor.prepare(url: url, applyEXIFOrientation: applyEXIFOrientation), dim: dim) | |
| } | |
| /// Embeds a CGImage (e.g. a screenshot); its raw samples are used, alpha dropped. | |
| public func embedImage(cgImage: CGImage, dim: Int = 768) async throws -> [Float] { | |
| try await embedImage(try ImagePreprocessor.prepare(try ImageDecoder.decodeRGB(cgImage: cgImage)), dim: dim) | |
| } | |
| /// Embeds an already prepared image (`ImagePreprocessor.prepare`). | |
| public func embedImage(_ inputs: VisionInputs, dim: Int = 768) async throws -> [Float] { | |
| let soft = try await softTokens(inputs) | |
| return try normalized(try await embed(sequence: imageSequence(softTokens: soft)), dim: dim) | |
| } | |
| /// Soft tokens of one prepared image (`vision_2520`): `[n, 512]` as a flat array (n = inputs.softTokenCount). | |
| public func softTokens(_ inputs: VisionInputs) async throws -> [Float16] { | |
| guard let visionFunction else { | |
| throw EmbedderError(description: "vision asset not loaded: create the model with loadVision: true") | |
| } | |
| let N = VisionInputs.maxPatches, D = VisionInputs.patchDim | |
| guard inputs.patches.count == N * D else { throw EmbedderError(description: "VisionInputs has the wrong size") } | |
| var patches = NDArray(shape: [1, N, D], scalarType: .float16) | |
| var posX = NDArray(shape: [1, N], scalarType: .int32) | |
| var posY = NDArray(shape: [1, N], scalarType: .int32) | |
| var cos = NDArray(shape: [1, 1, N, VisionInputs.headDim], scalarType: .float16) | |
| var sin = NDArray(shape: [1, 1, N, VisionInputs.headDim], scalarType: .float16) | |
| var mask = NDArray(shape: [1, N], scalarType: .float16) | |
| fill(&patches, inputs.patches) | |
| fill(&posX, inputs.posX) | |
| fill(&posY, inputs.posY) | |
| fill(&cos, inputs.cos) | |
| fill(&sin, inputs.sin) | |
| fill(&mask, inputs.patchMask) | |
| var out = try await visionFunction.run(inputs: ["patches": patches, "pos_x": posX, "pos_y": posY, "cos": cos, "sin": sin, "patch_mask": mask]) | |
| guard let soft = out.remove("soft_tokens")?.ndArray else { throw EmbedderError(description: "output 'soft_tokens' missing") } | |
| let n = inputs.softTokenCount | |
| var result = [Float16](repeating: 0, count: n * hidden) | |
| let view = soft.view(as: Float16.self) | |
| view.withUnsafePointer { p, _, _ in | |
| for i in 0..<(n * hidden) { result[i] = p[i] } | |
| } | |
| return result | |
| } | |
| /// The upstream single-image sequence: `<bos> <|image> <|image|> x n <image|> <eos>` (no prompt). | |
| public func imageSequence(softTokens: [Float16]) -> MultimodalSequence { | |
| let n = softTokens.count / hidden | |
| var ids = [config.bos_token_id, config.boi_token_id] | |
| ids.append(contentsOf: [Int32](repeating: config.image_token_id, count: n)) | |
| ids.append(contentsOf: [config.eoi_token_id, config.eos_token_id]) | |
| return MultimodalSequence(ids: ids, softTokens: softTokens, slots: Array(2..<(2 + n))) | |
| } | |
| // MARK: - Audio | |
| /// Embeds an audio file (anything AVAudioFile reads). The clip is decoded, mixed to mono, resampled to 16 kHz | |
| /// and, with `.truncate` (the upstream behaviour), its first 30 s are embedded; `.chunkMean` averages the | |
| /// embeddings of consecutive 30 s windows instead. | |
| public func embedAudio(url: URL, dim: Int = 768, longAudio: LongAudioPolicy = .truncate) async throws -> [Float] { | |
| try await embedAudio(samples16k: try AudioFrontend.loadMono16k(url: url), dim: dim, longAudio: longAudio) | |
| } | |
| /// Embeds PCM samples (any rate, any channel count: `channels[c][i]`, channel-major, every channel the same | |
| /// length); they are mixed to mono and resampled. Throws for a non-positive rate, channels of different lengths | |
| /// and arrays that look frame-major (more "channels" than samples per channel: pass `channels[c][i]`, not `[i][c]`). | |
| public func embedAudio(samples: [[Float]], sampleRate: Int, dim: Int = 768, longAudio: LongAudioPolicy = .truncate) async throws -> [Float] { | |
| guard sampleRate > 0 else { throw EmbedderError(description: "sampleRate must be positive (got \(sampleRate))") } | |
| guard let n = samples.first?.count, samples.allSatisfy({ $0.count == n }) else { | |
| throw EmbedderError(description: samples.isEmpty ? "samples has no channels" : "every channel must have the same number of samples") | |
| } | |
| guard samples.count <= max(n, 1) else { | |
| throw EmbedderError(description: "samples looks frame-major (\(samples.count) channels of \(n) samples): pass channel-major samples[channel][frame]") | |
| } | |
| let mono = AudioFrontend.toMono(DecodedAudio(sampleRate: sampleRate, channelCount: samples.count, channels: samples)) | |
| return try await embedAudio(samples16k: AudioResampler.resampleTo16k(mono, rate: sampleRate), dim: dim, longAudio: longAudio) | |
| } | |
| /// Embeds 16 kHz mono samples. | |
| public func embedAudio(samples16k: [Float], dim: Int = 768, longAudio: LongAudioPolicy = .truncate) async throws -> [Float] { | |
| switch longAudio { | |
| case .truncate: | |
| return try await embedAudio(try AudioFrontend.prepare(wav16k: samples16k), dim: dim) | |
| case .chunkMean: | |
| var acc = [Float](repeating: 0, count: config.embedding_dim) | |
| for chunk in AudioFrontend.chunks30s(samples16k) { | |
| let v = try await embedAudio(try AudioFrontend.prepare(wav16k: chunk), dim: config.embedding_dim) | |
| for i in acc.indices { acc[i] += v[i] } | |
| } | |
| return try normalized(acc, dim: dim) | |
| } | |
| } | |
| /// Embeds already prepared audio inputs (`AudioFrontend.prepare`). | |
| public func embedAudio(_ inputs: AudioInputs, dim: Int = 768) async throws -> [Float] { | |
| let soft = try await audioSoftTokens(inputs) | |
| return try normalized(try await embed(sequence: audioSequence(softTokens: soft)), dim: dim) | |
| } | |
| /// Soft tokens of one prepared clip (`audio_<bucket>`): `[n, 512]` as a flat array (n = inputs.softTokenCount). | |
| public func audioSoftTokens(_ inputs: AudioInputs) async throws -> [Float16] { | |
| guard let fn = audioFunctions[inputs.bucket] else { | |
| throw EmbedderError(description: hasAudio ? "no audio function for bucket \(inputs.bucket)" : "audio asset not loaded: create the model with loadAudio: true") | |
| } | |
| let Tb = inputs.bucket, M = AudioInputs.nMels | |
| guard inputs.features.count == Tb * M, inputs.frameMask.count == Tb, inputs.halfMask.count == Tb / 2, inputs.tokenMask.count == Tb / 4 else { | |
| throw EmbedderError(description: "AudioInputs has the wrong size") | |
| } | |
| var features = NDArray(shape: [1, Tb, M], scalarType: .float16) | |
| var frameMask = NDArray(shape: [1, Tb], scalarType: .float16) | |
| var halfMask = NDArray(shape: [1, Tb / 2], scalarType: .float16) | |
| var tokenMask = NDArray(shape: [1, Tb / 4], scalarType: .float16) | |
| fill(&features, inputs.features) | |
| fill(&frameMask, inputs.frameMask) | |
| fill(&halfMask, inputs.halfMask) | |
| fill(&tokenMask, inputs.tokenMask) | |
| var out = try await fn.run(inputs: ["features": features, "frame_mask": frameMask, "half_mask": halfMask, "token_mask": tokenMask]) | |
| guard let soft = out.remove("soft_tokens")?.ndArray else { throw EmbedderError(description: "output 'soft_tokens' missing") } | |
| let n = inputs.softTokenCount | |
| var result = [Float16](repeating: 0, count: n * hidden) | |
| let view = soft.view(as: Float16.self) | |
| view.withUnsafePointer { p, _, _ in | |
| for i in 0..<(n * hidden) { result[i] = p[i] } | |
| } | |
| return result | |
| } | |
| /// The upstream single-clip sequence: `<bos> <|audio> <|audio|> x n <audio|> <eos>` (no prompt). | |
| public func audioSequence(softTokens: [Float16]) -> MultimodalSequence { | |
| let n = softTokens.count / hidden | |
| let boa = config.boa_token_id ?? 256000, audio = config.audio_token_id ?? 258881, eoa = config.eoa_token_id ?? 258883 | |
| var ids = [config.bos_token_id, boa] | |
| ids.append(contentsOf: [Int32](repeating: audio, count: n)) | |
| ids.append(contentsOf: [eoa, config.eos_token_id]) | |
| return MultimodalSequence(ids: ids, softTokens: softTokens, slots: Array(2..<(2 + n))) | |
| } | |
| // MARK: - Any sequence | |
| /// Runs `mm_<bucket>` on a sequence (ids with `<bos>`/`<eos>`, right-padded with `<pad>`; soft tokens placed at | |
| /// `slots`). Any mix of text and images works as long as it fits a bucket (e.g. several images, or text + image). | |
| /// Returns the raw 768-d output (L2-normalized by the graph), as Float. | |
| public func embed(sequence seq: MultimodalSequence, bucket: Int? = nil) async throws -> [Float] { | |
| let S = try bucket ?? self.bucket(forTokenCount: seq.ids.count) | |
| guard let fn = functions[S] else { throw EmbedderError(description: "no function for bucket \(S)") } | |
| guard seq.ids.count <= S else { throw EmbedderError(description: "\(seq.ids.count) ids do not fit bucket \(S)") } | |
| guard seq.slots.count * hidden == seq.softTokens.count else { throw EmbedderError(description: "soft tokens do not match slots") } | |
| guard seq.slots.allSatisfy({ $0 >= 0 && $0 < seq.ids.count }) else { throw EmbedderError(description: "slot outside the sequence") } | |
| var inputIDs = NDArray(shape: [1, S], scalarType: .int32) | |
| var mask = NDArray(shape: [1, S], scalarType: .float16) | |
| var mm = NDArray(shape: [1, S, hidden], scalarType: .float16) | |
| var mmMask = NDArray(shape: [1, S], scalarType: .float16) | |
| do { | |
| let v = inputIDs.mutableView(as: Int32.self) | |
| let m = mask.mutableView(as: Float16.self) | |
| let e = mm.mutableView(as: Float16.self) | |
| let em = mmMask.mutableView(as: Float16.self) | |
| let pad = config.pad_token_id | |
| let ids = seq.ids | |
| v.withUnsafeMutablePointer { p, _, _ in for i in 0..<S { p[i] = i < ids.count ? ids[i] : pad } } | |
| m.withUnsafeMutablePointer { p, _, _ in for i in 0..<S { p[i] = i < ids.count ? 1 : 0 } } | |
| let h = hidden | |
| e.withUnsafeMutablePointer { p, _, _ in | |
| for i in 0..<(S * h) { p[i] = 0 } | |
| for (r, slot) in seq.slots.enumerated() { | |
| for j in 0..<h { p[slot * h + j] = seq.softTokens[r * h + j] } | |
| } | |
| } | |
| em.withUnsafeMutablePointer { p, _, _ in | |
| for i in 0..<S { p[i] = 0 } | |
| for slot in seq.slots { p[slot] = 1 } | |
| } | |
| } | |
| var out = try await fn.run(inputs: ["input_ids": inputIDs, "attention_mask": mask, "mm_embeds": mm, "mm_mask": mmMask]) | |
| guard let emb = out.remove("embedding")?.ndArray else { throw EmbedderError(description: "output 'embedding' missing") } | |
| let dim = config.embedding_dim | |
| var result = [Float](repeating: 0, count: dim) | |
| let view = emb.view(as: Float16.self) | |
| view.withUnsafePointer { p, _, _ in for i in 0..<dim { result[i] = Float(p[i]) } } | |
| return result | |
| } | |
| private func normalized(_ v: [Float], dim: Int) throws -> [Float] { | |
| guard config.matryoshka_dims.contains(dim) else { throw EmbedderError(description: "dim must be one of \(config.matryoshka_dims)") } | |
| var r = Array(v.prefix(dim)) | |
| let norm = max(sqrt(r.reduce(0) { $0 + $1 * $1 }), 1e-12) | |
| for i in r.indices { r[i] /= norm } | |
| return r | |
| } | |
| /// Runs every text bucket (and the vision function, if loaded) once so the first real call pays no | |
| /// first-call cost. Call it right after init, off the main thread. Returns the seconds it took. | |
| @discardableResult | |
| public func prewarm() async throws -> Double { | |
| let t0 = Date() | |
| for S in buckets { | |
| _ = try await embed(ids: [config.bos_token_id, config.eos_token_id], bucket: S) | |
| } | |
| if visionFunction != nil { | |
| _ = try await softTokens(ImagePreprocessor.patchify(resizedRGB: [UInt8](repeating: 128, count: 48 * 48 * 3), width: 48, height: 48)) | |
| } | |
| for tb in audioBuckets { | |
| let n = AudioInputs.nMels | |
| let inputs = AudioInputs(bucket: tb, frameCount: 4, softTokenCount: 1, features: [Float16](repeating: 0, count: tb * n), | |
| frameMask: [Float16](repeating: 0, count: tb), halfMask: [Float16](repeating: 0, count: tb / 2), | |
| tokenMask: [Float16](repeating: 0, count: tb / 4)) | |
| _ = try await audioSoftTokens(inputs) | |
| } | |
| return Date().timeIntervalSince(t0) | |
| } | |
| private func fill<T: BitwiseCopyable>(_ arr: inout NDArray, _ values: [T]) { | |
| let v = arr.mutableView(as: T.self) | |
| v.withUnsafeMutablePointer { p, _, _ in | |
| values.withUnsafeBufferPointer { src in p.update(from: src.baseAddress!, count: values.count) } | |
| } | |
| } | |
| // MARK: - Temp-file hygiene | |
| /// Each Neural Engine load makes MPSGraph write compiled-graph copies into `$TMPDIR/com.apple.MetalPerformance | |
| /// ShadersGraph/mpsgraph-<pid>-<date>-<n>/` (~360 MB for the text asset, ~300 MB for the vision asset). With the | |
| /// Swift `CoreAI` framework (macOS 27) the runtime deletes that folder itself when the model is released, so this | |
| /// usually finds nothing; it is a safety net for a folder that survives (e.g. the process exits while the model is | |
| /// alive). It removes only the folders that belong to the CURRENT process (its pid, created after it started). | |
| /// Call it after the `EmbeddingGemma2` instance has been released, or at exit. Returns the number of folders | |
| /// removed and their total bytes. | |
| @discardableResult | |
| public static func removeOwnMPSGraphTempFiles() -> (folders: Int, bytes: Int64) { | |
| let fm = FileManager.default | |
| let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("com.apple.MetalPerformanceShadersGraph") | |
| let prefix = "mpsgraph-\(getpid())-" | |
| let started = processStartDate() | |
| var folders = 0 | |
| var bytes: Int64 = 0 | |
| guard let entries = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey]) else { return (0, 0) } | |
| for url in entries where url.lastPathComponent.hasPrefix(prefix) { | |
| if let started, let created = (try? url.resourceValues(forKeys: [.creationDateKey]))?.creationDate, created < started - 2 { | |
| continue // a folder left by an older process that happened to have the same pid | |
| } | |
| if let e = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) { | |
| for case let f as URL in e { bytes += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0) } | |
| } | |
| if (try? fm.removeItem(at: url)) != nil { folders += 1 } | |
| } | |
| return (folders, bytes) | |
| } | |
| private static func processStartDate() -> Date? { | |
| var info = kinfo_proc() | |
| var size = MemoryLayout<kinfo_proc>.stride | |
| var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] | |
| guard sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) == 0 else { return nil } | |
| let tv = info.kp_proc.p_starttime | |
| return Date(timeIntervalSince1970: Double(tv.tv_sec) + Double(tv.tv_usec) / 1e6) | |
| } | |
| } | |
| // MARK: - Small helpers for callers and tests | |
| public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Double { | |
| var dot = 0.0, na = 0.0, nb = 0.0 | |
| for i in 0..<min(a.count, b.count) { | |
| dot += Double(a[i]) * Double(b[i]); na += Double(a[i]) * Double(a[i]); nb += Double(b[i]) * Double(b[i]) | |
| } | |
| return na > 0 && nb > 0 ? dot / (sqrt(na) * sqrt(nb)) : 0 | |
| } | |