Skip to content

Commit 9e1ffa5

Browse files
authored
Add VLM inference infrastructure: engine, protocol, and CLI support (#65)
* Add VLM inference infrastructure: engine, protocol, and CLI support Runtime: - MultimodalInferenceEngine protocol with encodeImage() and generate() - CoreAISequentialVLMEngine: vision encoder + projector + embed_tokens + LLM decoder with scatter-merge of image embeddings at placeholder positions - EmbeddedInput type wrapping NDArray embeddings with position metadata - VisionConfig in LanguageConfig for image_size, patch_size, token count/id - LanguageBundle parses top-level "vision" block from metadata.json CLI (llm-runner): - --image flag routes through VLM engine when bundle kind is .vlm - Chat template detection with generic fallback for prompt construction - Accumulated token decode for correct spacing - Stop sequence support in VLM path Supports any VLM that exports 3 components (vision.aimodel, embed.aimodel, model.aimodel) with a vision config block in metadata.json. Model-family- specific export code lives in internal/python. * VisionConfig: add image normalization fields (mean, std, rescaleFactor) Read per-channel normalization from metadata instead of hardcoding. Fields are optional — bundles without them default to CLIP values (the most common across VLMs). Gemma/SigLIP bundles specify their own [0.5, 0.5, 0.5] values explicitly in metadata.json. * VLM cleanup: enforce VisionConfig, generalize EmbeddedInput, support bfloat16 - LanguageBundle init throws if a .vlm bundle omits the vision block - Rename imageTokenPositions → embeddingPositions (supports future audio/multi-modal embedding injection, not just images) - Accept bfloat16 logits in addition to float16 - Scatter merge uses UInt16 view (type-agnostic for f16/bf16) * Format changes * Fix potential infinite loop in KV cache growth, suppress unused-arg warnings - Guard against currentKVCapacity==0 in growKVCache (would loop forever) - Prefix unused protocol args with _ in warmup() * Update InferenceEngine.swift
1 parent 1eb2dae commit 9e1ffa5

7 files changed

Lines changed: 1577 additions & 17 deletions

File tree

swift/Sources/CoreAILanguageModels/Bundle/LanguageBundle.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public struct LanguageBundle: Sendable {
2121
public let bundle: ModelBundle
2222
public let modelAssetPath: String
2323
public let language: LanguageConfig
24+
public let visionConfig: VisionConfig?
2425

2526
public init(from path: String) throws {
2627
let expanded = (path as NSString).expandingTildeInPath
@@ -45,6 +46,11 @@ public struct LanguageBundle: Sendable {
4546
}
4647
self.modelAssetPath = main
4748
self.language = language
49+
self.visionConfig = payload.vision
50+
51+
if bundle.kind == .vlm && self.visionConfig == nil {
52+
throw ModelBundle.BundleError.missingField("vision")
53+
}
4854
}
4955

5056
// MARK: - Convenience accessors
@@ -98,6 +104,7 @@ extension LanguageBundle {
98104
fileprivate struct LanguagePayload: Decodable {
99105
let assets: Assets
100106
let language: LanguageConfig?
107+
let vision: VisionConfig?
101108

102109
struct Assets: Decodable {
103110
let main: String?

swift/Sources/CoreAILanguageModels/Bundle/LanguageConfig.swift

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,23 @@ public struct LanguageConfig: Codable, Sendable, Equatable {
2222
/// known role conventions (`main`, `extend_<N>`, `load_embeddings`, ...).
2323
public let functionMap: FunctionMap?
2424

25+
/// Vision-specific configuration. Nil for text-only language models.
26+
public let vision: VisionConfig?
27+
2528
public init(
2629
tokenizer: String,
2730
vocabSize: Int,
2831
maxContextLength: Int,
2932
embeddedTokenizer: Bool = true,
30-
functionMap: FunctionMap? = nil
33+
functionMap: FunctionMap? = nil,
34+
vision: VisionConfig? = nil
3135
) {
3236
self.tokenizer = tokenizer
3337
self.vocabSize = vocabSize
3438
self.maxContextLength = maxContextLength
3539
self.embeddedTokenizer = embeddedTokenizer
3640
self.functionMap = functionMap
41+
self.vision = vision
3742
}
3843

3944
enum CodingKeys: String, CodingKey {
@@ -42,6 +47,7 @@ public struct LanguageConfig: Codable, Sendable, Equatable {
4247
case maxContextLength = "max_context_length"
4348
case embeddedTokenizer = "embedded_tokenizer"
4449
case functionMap = "function_map"
50+
case vision
4551
}
4652

4753
public init(from decoder: Swift.Decoder) throws {
@@ -51,6 +57,7 @@ public struct LanguageConfig: Codable, Sendable, Equatable {
5157
self.maxContextLength = try c.decode(Int.self, forKey: .maxContextLength)
5258
self.embeddedTokenizer = try c.decodeIfPresent(Bool.self, forKey: .embeddedTokenizer) ?? true
5359
self.functionMap = try c.decodeIfPresent(FunctionMap.self, forKey: .functionMap)
60+
self.vision = try c.decodeIfPresent(VisionConfig.self, forKey: .vision)
5461
}
5562

5663
// MARK: - Additional Stop Tokens
@@ -137,3 +144,71 @@ public struct LanguageConfig: Codable, Sendable, Equatable {
137144
return Array(result)
138145
}
139146
}
147+
148+
/// Vision-specific configuration for VLM bundles.
149+
/// Nil for text-only language models.
150+
public struct VisionConfig: Codable, Sendable, Equatable {
151+
/// Input image size (square). Vision encoder expects this resolution.
152+
public let imageSize: Int
153+
154+
/// Patch size for the vision transformer.
155+
public let patchSize: Int
156+
157+
/// Number of embedding tokens produced per image after projection.
158+
public let imageTokenCount: Int
159+
160+
/// Token ID used as a placeholder in the text sequence for image positions.
161+
public let imageTokenId: Int32
162+
163+
/// Per-channel normalization mean (RGB). Defaults to CLIP values when omitted.
164+
public let imageMean: [Double]
165+
166+
/// Per-channel normalization std (RGB). Defaults to CLIP values when omitted.
167+
public let imageStd: [Double]
168+
169+
/// Pixel rescale factor applied before normalization. Defaults to 1.0 when omitted.
170+
public let rescaleFactor: Double
171+
172+
/// CLIP normalization (Qwen VL, Pixtral, InternVL, Phi-3.5-vision).
173+
public static let clipMean = [0.48145466, 0.4578275, 0.40821073]
174+
public static let clipStd = [0.26862954, 0.26130258, 0.27577711]
175+
176+
public init(
177+
imageSize: Int,
178+
patchSize: Int,
179+
imageTokenCount: Int,
180+
imageTokenId: Int32,
181+
imageMean: [Double]? = nil,
182+
imageStd: [Double]? = nil,
183+
rescaleFactor: Double? = nil
184+
) {
185+
self.imageSize = imageSize
186+
self.patchSize = patchSize
187+
self.imageTokenCount = imageTokenCount
188+
self.imageTokenId = imageTokenId
189+
self.imageMean = imageMean ?? Self.clipMean
190+
self.imageStd = imageStd ?? Self.clipStd
191+
self.rescaleFactor = rescaleFactor ?? 1.0
192+
}
193+
194+
enum CodingKeys: String, CodingKey {
195+
case imageSize = "image_size"
196+
case patchSize = "patch_size"
197+
case imageTokenCount = "image_token_count"
198+
case imageTokenId = "image_token_id"
199+
case imageMean = "image_mean"
200+
case imageStd = "image_std"
201+
case rescaleFactor = "rescale_factor"
202+
}
203+
204+
public init(from decoder: Swift.Decoder) throws {
205+
let c = try decoder.container(keyedBy: CodingKeys.self)
206+
self.imageSize = try c.decode(Int.self, forKey: .imageSize)
207+
self.patchSize = try c.decode(Int.self, forKey: .patchSize)
208+
self.imageTokenCount = try c.decode(Int.self, forKey: .imageTokenCount)
209+
self.imageTokenId = try c.decode(Int32.self, forKey: .imageTokenId)
210+
self.imageMean = try c.decodeIfPresent([Double].self, forKey: .imageMean) ?? Self.clipMean
211+
self.imageStd = try c.decodeIfPresent([Double].self, forKey: .imageStd) ?? Self.clipStd
212+
self.rescaleFactor = try c.decodeIfPresent(Double.self, forKey: .rescaleFactor) ?? 1.0
213+
}
214+
}

0 commit comments

Comments
 (0)