bleam 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/cli.cjs +23 -4
  2. package/dist/cli.d.cts +2 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +23 -4
  5. package/dist/{ui-Ce-pnUUA.d.ts → ui-Bg11tvlc.d.ts} +7 -7
  6. package/dist/ui.d.ts +1 -1
  7. package/dist/window.d.ts +1 -1
  8. package/package.json +4 -1
  9. package/templates/image-generation/app/index.tsx +1 -1
  10. package/templates/native/.gitattributes +1 -0
  11. package/templates/native/App.tsx +26 -0
  12. package/templates/native/app.json +12 -0
  13. package/templates/native/index.ts +8 -0
  14. package/templates/native/ios/.xcode.env +11 -0
  15. package/templates/native/ios/Bleam/AI/Flux2/Configuration/Flux2Config.swift +288 -0
  16. package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryConfig.swift +233 -0
  17. package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryOptimizationConfig.swift +157 -0
  18. package/templates/native/ios/Bleam/AI/Flux2/Configuration/ModelRegistry.swift +425 -0
  19. package/templates/native/ios/Bleam/AI/Flux2/Configuration/QuantizationConfig.swift +138 -0
  20. package/templates/native/ios/Bleam/AI/Flux2/Configuration/TransformerRepoProfile.swift +59 -0
  21. package/templates/native/ios/Bleam/AI/Flux2/Configuration/VAEConfig.swift +134 -0
  22. package/templates/native/ios/Bleam/AI/Flux2/Flux2Core.swift +55 -0
  23. package/templates/native/ios/Bleam/AI/Flux2/Loading/WeightLoader.swift +1192 -0
  24. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/FluxImageRunner.swift +1282 -0
  25. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/LatentUtils.swift +191 -0
  26. package/templates/native/ios/Bleam/AI/Flux2/Scheduler/FlowMatchEulerScheduler.swift +279 -0
  27. package/templates/native/ios/Bleam/AI/Flux2/Text/KleinTextEncoder.swift +380 -0
  28. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Attention.swift +379 -0
  29. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Embeddings.swift +159 -0
  30. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FeedForward.swift +154 -0
  31. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FusedKernels.swift +198 -0
  32. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2KVCache.swift +36 -0
  33. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Modulation.swift +193 -0
  34. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2ParallelAttention.swift +389 -0
  35. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2RoPE.swift +304 -0
  36. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2SingleBlock.swift +290 -0
  37. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Transformer.swift +530 -0
  38. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2TransformerBlock.swift +362 -0
  39. package/templates/native/ios/Bleam/AI/Flux2/Utils/Flux2Debug.swift +101 -0
  40. package/templates/native/ios/Bleam/AI/Flux2/Utils/MLXCheckpoint.swift +118 -0
  41. package/templates/native/ios/Bleam/AI/Flux2/Utils/MemoryManager.swift +201 -0
  42. package/templates/native/ios/Bleam/AI/Flux2/VAE/AutoencoderKL.swift +476 -0
  43. package/templates/native/ios/Bleam/AI/Flux2/VAE/ResnetBlock.swift +316 -0
  44. package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEDecoder.swift +120 -0
  45. package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEEncoder.swift +136 -0
  46. package/templates/native/ios/Bleam/AppDelegate.swift +373 -0
  47. package/templates/native/ios/Bleam/Appearance.swift +62 -0
  48. package/templates/native/ios/Bleam/Bridging-Header.h +3 -0
  49. package/templates/native/ios/Bleam/Images.xcassets/AppIcon.appiconset/Contents.json +13 -0
  50. package/templates/native/ios/Bleam/Images.xcassets/Contents.json +6 -0
  51. package/templates/native/ios/Bleam/Info.plist +76 -0
  52. package/templates/native/ios/Bleam/PrivacyInfo.xcprivacy +48 -0
  53. package/templates/native/ios/Bleam/SceneDelegate.swift +58 -0
  54. package/templates/native/ios/Bleam/SplashScreen.storyboard +47 -0
  55. package/templates/native/ios/Bleam/Supporting/Expo.plist +6 -0
  56. package/templates/native/ios/Bleam/bleam.entitlements +10 -0
  57. package/templates/native/ios/Bleam.xcodeproj/project.pbxproj +1146 -0
  58. package/templates/native/ios/Bleam.xcodeproj/xcshareddata/xcschemes/Bleam.xcscheme +88 -0
  59. package/templates/native/ios/GenerationService/GenerationService.entitlements +10 -0
  60. package/templates/native/ios/GenerationService/ImageGenerationRunner.swift +591 -0
  61. package/templates/native/ios/GenerationService/Info.plist +31 -0
  62. package/templates/native/ios/GenerationService/main.swift +165 -0
  63. package/templates/native/ios/PlatformHelper/Info.plist +29 -0
  64. package/templates/native/ios/PlatformHelper/main.swift +335 -0
  65. package/templates/native/ios/Podfile +198 -0
  66. package/templates/native/ios/Podfile.lock +2284 -0
  67. package/templates/native/ios/Podfile.properties.json +5 -0
  68. package/templates/native/ios/Shared/Generation/GenerationServiceProtocol.swift +12 -0
  69. package/templates/native/ios/Shared/Generation/GenerationWorkerProtocol.swift +124 -0
  70. package/templates/native/metro.config.js +6 -0
  71. package/templates/native/modules/bleam-runtime/BleamRuntime.podspec +14 -0
  72. package/templates/native/modules/bleam-runtime/expo-module.config.json +8 -0
  73. package/templates/native/modules/bleam-runtime/ios/AIModule.swift +1258 -0
  74. package/templates/native/modules/bleam-runtime/ios/GenerationContracts.swift +135 -0
  75. package/templates/native/modules/bleam-runtime/ios/PlatformModule.swift +313 -0
  76. package/templates/native/modules/bleam-runtime/package.json +5 -0
  77. package/templates/native/package.json +39 -0
  78. package/templates/native/scripts/build/mlx-frameworks.ts +421 -0
  79. package/templates/native/scripts/start/index.ts +474 -0
  80. package/templates/native/scripts/start/known-failures.ts +20 -0
  81. package/templates/native/scripts/start/loader.ts +76 -0
  82. package/templates/native/scripts/start/native-state.ts +242 -0
  83. package/templates/native/scripts/start/package.json +4 -0
  84. package/templates/native/scripts/start/swift-packages.ts +414 -0
  85. package/templates/native/scripts/start/xcode-formatter.ts +106 -0
  86. package/templates/native/tsconfig.json +14 -0
  87. package/templates/native/yarn.lock +3749 -0
@@ -0,0 +1,1192 @@
1
+ // WeightLoader.swift - Load model weights from safetensors
2
+ // Copyright 2025 Vincent Gourbin
3
+
4
+ import Foundation
5
+ import MLX
6
+ import MLXNN
7
+
8
+ /// Utilities for loading Flux.2 model weights from safetensors files
9
+ public class Flux2WeightLoader {
10
+
11
+ /// Load all weights from a model directory
12
+ /// - Parameter modelPath: Path to directory containing safetensors files
13
+ /// - Returns: Dictionary of weight name to MLXArray
14
+ public static func loadWeights(from modelPath: String) throws -> [String: MLXArray] {
15
+ let fm = FileManager.default
16
+ let contents = try fm.contentsOfDirectory(atPath: modelPath)
17
+ let safetensorFiles = contents.filter { $0.hasSuffix(".safetensors") }.sorted()
18
+
19
+ if safetensorFiles.isEmpty {
20
+ throw Flux2WeightLoaderError.noWeightsFound(modelPath)
21
+ }
22
+
23
+ Flux2Debug.log("Found \(safetensorFiles.count) safetensor files in \(modelPath)")
24
+
25
+ var allWeights: [String: MLXArray] = [:]
26
+
27
+ for filename in safetensorFiles {
28
+ let filePath = "\(modelPath)/\(filename)"
29
+ let weights = try loadArrays(url: URL(fileURLWithPath: filePath))
30
+
31
+ for (key, value) in weights {
32
+ let normalizedKey = normalizeUREVTransformerKey(key, filename: filename, modelPath: modelPath)
33
+ allWeights[normalizedKey] = value
34
+ }
35
+
36
+ Flux2Debug.log("Loaded \(weights.count) tensors from \(filename)")
37
+ }
38
+
39
+ return allWeights
40
+ }
41
+
42
+ private static func normalizeUREVTransformerKey(_ key: String, filename: String, modelPath: String) -> String {
43
+ let isUREVCheckpoint = filename == "urevF2K4BFp16.safetensors"
44
+ && modelPath.split(separator: "/").contains("urev")
45
+ let prefix = "model.diffusion_model."
46
+ guard isUREVCheckpoint, key.hasPrefix(prefix) else { return key }
47
+ return String(key.dropFirst(prefix.count))
48
+ }
49
+
50
+ /// Load weights from URL
51
+ public static func loadWeights(from url: URL) throws -> [String: MLXArray] {
52
+ try loadWeights(from: url.path)
53
+ }
54
+
55
+ // MARK: - Transformer Weight Mapping
56
+
57
+ /// Detect if weights are in BFL (Black Forest Labs) native format
58
+ private static func isBFLFormat(_ weights: [String: MLXArray]) -> Bool {
59
+ // BFL format uses "double_blocks" and "single_blocks" directly
60
+ // Diffusers format uses "transformer_blocks" and "single_transformer_blocks"
61
+ return weights.keys.contains { $0.hasPrefix("double_blocks.") || $0.hasPrefix("single_blocks.") }
62
+ }
63
+
64
+ /// Convert HuggingFace transformer weight keys to Swift module paths
65
+ /// Also handles dequantization of quanto qint8 weights
66
+ /// Supports both Diffusers format and BFL native format
67
+ /// Note: For Diffusers quanto format, entries are removed from `weights` as they are mapped
68
+ /// to avoid holding both raw quanto (~32GB) and dequantified float16 (~60GB) simultaneously.
69
+ static func mapTransformerWeights(
70
+ _ weights: inout [String: MLXArray],
71
+ profile: Flux2TransformerRepoProfile
72
+ ) -> [String: MLXArray] {
73
+ if profile.checkpointFormat == .bfl || isBFLFormat(weights) {
74
+ Flux2Debug.log("Detected BFL native format weights")
75
+ return mapBFLTransformerWeights(&weights)
76
+ }
77
+
78
+ Flux2Debug.log("Detected Diffusers format weights for \(profile.repoId)")
79
+ return mapDiffusersTransformerWeights(&weights, profile: profile)
80
+ }
81
+
82
+ /// Detect whether the raw transformer checkpoint is already stored in a packed
83
+ /// quantized format that should be dequantized during load rather than requantized
84
+ /// again at runtime.
85
+ static func isNativePrequantizedTransformerCheckpoint(_ weights: [String: MLXArray]) -> Bool {
86
+ let hasPackedWeights = weights.contains { key, value in
87
+ key.hasSuffix(".weight") && value.dtype == .uint32
88
+ }
89
+ let hasQuantizationSidecars = weights.keys.contains { $0.hasSuffix(".scales") || $0.hasSuffix(".biases") }
90
+
91
+ return hasPackedWeights && hasQuantizationSidecars
92
+ }
93
+
94
+ static func isNativePrequantizedTransformerCheckpoint(
95
+ _ weights: [String: MLXArray],
96
+ profile: Flux2TransformerRepoProfile
97
+ ) -> Bool {
98
+ switch profile.checkpointFormat {
99
+ case .packedDiffusers, .bfl:
100
+ return isNativePrequantizedTransformerCheckpoint(weights)
101
+ case .diffusers, .diffusersQuanto:
102
+ return false
103
+ }
104
+ }
105
+
106
+ /// Map weights in BFL (Black Forest Labs) native format
107
+ /// BFL format uses fused QKV and different naming conventions
108
+ /// Entries are removed from the input dict as they are processed to minimize peak memory,
109
+ /// avoiding holding both raw bf16 weights and mapped/converted weights simultaneously.
110
+ private static func mapBFLTransformerWeights(_ weights: inout [String: MLXArray]) -> [String: MLXArray] {
111
+ var mapped: [String: MLXArray] = [:]
112
+
113
+ // Debug: count double_blocks keys
114
+ let doubleBlockKeys = weights.keys.filter { $0.hasPrefix("double_blocks.") }
115
+ Flux2Debug.log("BFL format: found \(doubleBlockKeys.count) double_blocks keys")
116
+ for key in doubleBlockKeys.prefix(5) {
117
+ Flux2Debug.verbose(" double_block key: \(key)")
118
+ }
119
+
120
+ // Iterate over snapshot of keys; remove each entry after processing to free memory
121
+ let allKeys = Array(weights.keys)
122
+ for key in allKeys {
123
+ guard let value = weights.removeValue(forKey: key) else { continue }
124
+ // BFL format uses fused QKV weights that need to be split
125
+ // double_blocks.{i}.img_attn.qkv.weight -> split into toQ, toK, toV
126
+ // double_blocks.{i}.txt_attn.qkv.weight -> split into addQProj, addKProj, addVProj
127
+
128
+ if let mappedKey = mapCurrentBFLTransformerKey(key) {
129
+ mapped[mappedKey] = value
130
+ continue
131
+ }
132
+
133
+ if key.contains(".img_attn.qkv.weight") {
134
+ Flux2Debug.verbose("Splitting img_attn QKV: \(key) shape=\(value.shape)")
135
+ // Extract block index
136
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
137
+ // Split fused QKV into Q, K, V (shape: [3*dim, dim] -> 3x [dim, dim])
138
+ let dim = value.shape[0] / 3
139
+ let q = value[0..<dim, 0...]
140
+ let k = value[dim..<(2*dim), 0...]
141
+ let v = value[(2*dim)..., 0...]
142
+ mapped["transformerBlocks.\(blockIdx).attn.toQ.weight"] = q
143
+ mapped["transformerBlocks.\(blockIdx).attn.toK.weight"] = k
144
+ mapped["transformerBlocks.\(blockIdx).attn.toV.weight"] = v
145
+ } else if key.contains(".txt_attn.qkv.weight") {
146
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
147
+ let dim = value.shape[0] / 3
148
+ let q = value[0..<dim, 0...]
149
+ let k = value[dim..<(2*dim), 0...]
150
+ let v = value[(2*dim)..., 0...]
151
+ mapped["transformerBlocks.\(blockIdx).attn.addQProj.weight"] = q
152
+ mapped["transformerBlocks.\(blockIdx).attn.addKProj.weight"] = k
153
+ mapped["transformerBlocks.\(blockIdx).attn.addVProj.weight"] = v
154
+ } else if key.contains(".img_attn.proj.weight") {
155
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
156
+ mapped["transformerBlocks.\(blockIdx).attn.toOut.weight"] = value
157
+ } else if key.contains(".txt_attn.proj.weight") {
158
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
159
+ mapped["transformerBlocks.\(blockIdx).attn.toAddOut.weight"] = value
160
+ } else if key.contains(".img_attn.norm.query_norm.scale") {
161
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
162
+ mapped["transformerBlocks.\(blockIdx).attn.normQ.weight"] = value
163
+ } else if key.contains(".img_attn.norm.key_norm.scale") {
164
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
165
+ mapped["transformerBlocks.\(blockIdx).attn.normK.weight"] = value
166
+ } else if key.contains(".txt_attn.norm.query_norm.scale") {
167
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
168
+ mapped["transformerBlocks.\(blockIdx).attn.normAddedQ.weight"] = value
169
+ } else if key.contains(".txt_attn.norm.key_norm.scale") {
170
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
171
+ mapped["transformerBlocks.\(blockIdx).attn.normAddedK.weight"] = value
172
+ } else if key.contains(".img_mlp.0.weight") {
173
+ // BFL: img_mlp.0 is the gated linear (produces 2x for SwiGLU)
174
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
175
+ mapped["transformerBlocks.\(blockIdx).ff.activation.proj.weight"] = value
176
+ } else if key.contains(".img_mlp.2.weight") {
177
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
178
+ mapped["transformerBlocks.\(blockIdx).ff.linearOut.weight"] = value
179
+ } else if key.contains(".txt_mlp.0.weight") {
180
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
181
+ mapped["transformerBlocks.\(blockIdx).ffContext.activation.proj.weight"] = value
182
+ } else if key.contains(".txt_mlp.2.weight") {
183
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
184
+ mapped["transformerBlocks.\(blockIdx).ffContext.linearOut.weight"] = value
185
+ } else if key.hasPrefix("single_blocks.") && key.contains(".linear1.weight") {
186
+ // Single blocks have fused QKV+MLP
187
+ let blockIdx = extractBlockIndex(from: key, prefix: "single_blocks.")
188
+ mapped["singleTransformerBlocks.\(blockIdx).attn.toQkvMlp.weight"] = value
189
+ } else if key.hasPrefix("single_blocks.") && key.contains(".linear2.weight") {
190
+ let blockIdx = extractBlockIndex(from: key, prefix: "single_blocks.")
191
+ mapped["singleTransformerBlocks.\(blockIdx).attn.toOut.weight"] = value
192
+ } else if key.hasPrefix("single_blocks.") && key.contains(".norm.query_norm.scale") {
193
+ let blockIdx = extractBlockIndex(from: key, prefix: "single_blocks.")
194
+ mapped["singleTransformerBlocks.\(blockIdx).attn.normQ.weight"] = value
195
+ } else if key.hasPrefix("single_blocks.") && key.contains(".norm.key_norm.scale") {
196
+ let blockIdx = extractBlockIndex(from: key, prefix: "single_blocks.")
197
+ mapped["singleTransformerBlocks.\(blockIdx).attn.normK.weight"] = value
198
+ } else if key == "img_in.weight" {
199
+ Flux2Debug.log("BFL: mapping img_in -> xEmbedder: \(value.shape)")
200
+ mapped["xEmbedder.weight"] = value
201
+ } else if key == "txt_in.weight" {
202
+ Flux2Debug.log("BFL: mapping txt_in -> contextEmbedder: \(value.shape)")
203
+ mapped["contextEmbedder.weight"] = value
204
+ } else if key == "time_in.in_layer.weight" {
205
+ Flux2Debug.log("BFL: mapping time_in.in_layer -> timestepEmbedder.linear1: \(value.shape)")
206
+ mapped["timeGuidanceEmbed.timestepEmbedder.linear1.weight"] = value
207
+ } else if key == "time_in.out_layer.weight" {
208
+ Flux2Debug.log("BFL: mapping time_in.out_layer -> timestepEmbedder.linear2: \(value.shape)")
209
+ mapped["timeGuidanceEmbed.timestepEmbedder.linear2.weight"] = value
210
+ } else if key == "double_stream_modulation_img.lin.weight" {
211
+ mapped["doubleStreamModulationImg.linear.weight"] = value
212
+ } else if key == "double_stream_modulation_txt.lin.weight" {
213
+ mapped["doubleStreamModulationTxt.linear.weight"] = value
214
+ } else if key == "single_stream_modulation.lin.weight" {
215
+ mapped["singleStreamModulation.linear.weight"] = value
216
+ } else if key == "final_layer.adaLN_modulation.1.weight" {
217
+ // IMPORTANT: BFL format vs Diffusers format weight layout difference
218
+ //
219
+ // The normOut layer (AdaLayerNormContinuous) projects conditioning to scale+shift:
220
+ // params = linear(silu(conditioning)) // [B, dim * 2]
221
+ // scale = params[0:dim]
222
+ // shift = params[dim:]
223
+ //
224
+ // BFL format stores the linear weight as [shift_weights | scale_weights]
225
+ // Diffusers format stores it as [scale_weights | shift_weights]
226
+ //
227
+ // Without this swap, bf16 BFL models produce inverted scale/shift values,
228
+ // causing ~10x higher output magnitude and posterized/noisy images.
229
+ //
230
+ // Fix: Swap the two halves of the weight matrix to match diffusers layout.
231
+ let dim = value.shape[0] / 2 // 3072 for Klein 4B, 6144 for Dev
232
+ let shiftRows = value[0..<dim, 0...] // First half (shift in BFL)
233
+ let scaleRows = value[dim..., 0...] // Second half (scale in BFL)
234
+ let swapped = concatenated([scaleRows, shiftRows], axis: 0)
235
+ Flux2Debug.log("BFL: Swapped normOut.linear.weight halves (dim=\(dim)): \(value.shape) -> \(swapped.shape)")
236
+ mapped["normOut.linear.weight"] = swapped
237
+ } else if key == "final_layer.linear.weight" {
238
+ mapped["projOut.weight"] = value
239
+ } else {
240
+ Flux2Debug.verbose("BFL format: unmapped key \(key)")
241
+ }
242
+ }
243
+
244
+ // Input dict is now empty — raw BFL weight references freed
245
+ Flux2Debug.log("Input dict cleared — freed raw BFL weight entries")
246
+ Flux2Debug.log("Mapped \(mapped.count) BFL format weights")
247
+
248
+ // Debug: print mapped keys by category
249
+ let transformerBlockKeys2 = mapped.keys.filter { $0.hasPrefix("transformerBlocks.") }
250
+ let singleBlockKeys = mapped.keys.filter { $0.hasPrefix("singleTransformerBlocks.") }
251
+ Flux2Debug.log(" - transformerBlocks keys: \(transformerBlockKeys2.count)")
252
+ Flux2Debug.log(" - singleTransformerBlocks keys: \(singleBlockKeys.count)")
253
+
254
+ Flux2Debug.verbose("transformerBlocks keys:")
255
+ for key in transformerBlockKeys2.sorted().prefix(10) {
256
+ Flux2Debug.verbose(" - \(key): \(mapped[key]!.shape)")
257
+ }
258
+
259
+ // DEBUG: Print raw BFL weight statistics BEFORE conversion
260
+ Flux2Debug.log("=== Raw BFL Weight Statistics (BEFORE conversion) ===")
261
+ for key in ["xEmbedder.weight", "contextEmbedder.weight",
262
+ "transformerBlocks.0.attn.toQ.weight",
263
+ "transformerBlocks.0.ff.activation.proj.weight",
264
+ "singleTransformerBlocks.0.attn.toQkvMlp.weight"] {
265
+ if let w = mapped[key] {
266
+ eval(w)
267
+ // Convert to float32 for accurate stats
268
+ let wf32 = w.asType(.float32)
269
+ let absVal = MLX.abs(wf32)
270
+ let meanVal = mean(absVal).item(Float.self)
271
+ let maxVal = MLX.max(absVal).item(Float.self)
272
+ let minVal = MLX.min(absVal).item(Float.self)
273
+ let hasNaN = any(isNaN(wf32)).item(Bool.self)
274
+ let numInf = sum(MLX.abs(wf32) .> 1e30).item(Int.self) // Count very large values as proxy for inf
275
+ Flux2Debug.log(" \(key): dtype=\(w.dtype), shape=\(w.shape), mean=\(meanVal), max=\(maxVal), min=\(minVal), hasNaN=\(hasNaN), numInf=\(numInf)")
276
+ }
277
+ }
278
+
279
+ // Convert bfloat16 → float16 directly (no intermediate f32)
280
+ // bf16 has wider exponent range (8-bit) than f16 (5-bit), so values outside
281
+ // f16 range [−65504, 65504] would overflow to inf. MLX handles this correctly
282
+ // by saturating to ±inf, which is the same behavior as the f32 intermediate path.
283
+ // Going direct saves ~24GB of temporary f32 allocations for Dev-size models.
284
+ // Progressive removal from `mapped` avoids holding both dicts simultaneously.
285
+ var converted: [String: MLXArray] = [:]
286
+ var convertedCount = 0
287
+ let mappedKeys = Array(mapped.keys)
288
+ for key in mappedKeys {
289
+ guard let value = mapped.removeValue(forKey: key) else { continue }
290
+ if value.dtype == .bfloat16 {
291
+ converted[key] = value.asType(.float16)
292
+ convertedCount += 1
293
+ } else {
294
+ converted[key] = value
295
+ }
296
+ }
297
+ Flux2Debug.log("Converted \(convertedCount) bfloat16 weights to float16 (direct)")
298
+
299
+ // DEBUG: Print weight statistics for key layers (AFTER processing)
300
+ Flux2Debug.log("=== BFL Weight Statistics (AFTER processing) ===")
301
+ for key in ["xEmbedder.weight", "contextEmbedder.weight",
302
+ "transformerBlocks.0.attn.toQ.weight",
303
+ "transformerBlocks.0.ff.activation.proj.weight",
304
+ "singleTransformerBlocks.0.attn.toQkvMlp.weight"] {
305
+ if let w = converted[key] {
306
+ eval(w)
307
+ let wf32 = w.asType(.float32)
308
+ let absVal = MLX.abs(wf32)
309
+ let meanVal = mean(absVal).item(Float.self)
310
+ let maxVal = MLX.max(absVal).item(Float.self)
311
+ let minVal = MLX.min(absVal).item(Float.self)
312
+ let hasNaN = any(isNaN(wf32)).item(Bool.self)
313
+ let numInf = sum(MLX.abs(wf32) .> 1e30).item(Int.self) // Count very large values as proxy for inf
314
+ Flux2Debug.log(" \(key): dtype=\(w.dtype), shape=\(w.shape), mean=\(meanVal), max=\(maxVal), min=\(minVal), hasNaN=\(hasNaN), numInf=\(numInf)")
315
+ } else {
316
+ Flux2Debug.log(" \(key): NOT FOUND")
317
+ }
318
+ }
319
+
320
+ return converted
321
+ }
322
+
323
+ private static func mapCurrentBFLTransformerKey(_ key: String) -> String? {
324
+ if key.hasPrefix("double_blocks.") {
325
+ let blockIdx = extractBlockIndex(from: key, prefix: "double_blocks.")
326
+
327
+ if let suffix = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false).last {
328
+ switch String(suffix) {
329
+ case "img_to_q.weight": return "transformerBlocks.\(blockIdx).attn.toQ.weight"
330
+ case "img_to_q.scales": return "transformerBlocks.\(blockIdx).attn.toQ.scales"
331
+ case "img_to_q.biases": return "transformerBlocks.\(blockIdx).attn.toQ.biases"
332
+ case "img_to_k.weight": return "transformerBlocks.\(blockIdx).attn.toK.weight"
333
+ case "img_to_k.scales": return "transformerBlocks.\(blockIdx).attn.toK.scales"
334
+ case "img_to_k.biases": return "transformerBlocks.\(blockIdx).attn.toK.biases"
335
+ case "img_to_v.weight": return "transformerBlocks.\(blockIdx).attn.toV.weight"
336
+ case "img_to_v.scales": return "transformerBlocks.\(blockIdx).attn.toV.scales"
337
+ case "img_to_v.biases": return "transformerBlocks.\(blockIdx).attn.toV.biases"
338
+ case "img_to_out.weight": return "transformerBlocks.\(blockIdx).attn.toOut.weight"
339
+ case "img_to_out.scales": return "transformerBlocks.\(blockIdx).attn.toOut.scales"
340
+ case "img_to_out.biases": return "transformerBlocks.\(blockIdx).attn.toOut.biases"
341
+ case "txt_to_q.weight": return "transformerBlocks.\(blockIdx).attn.addQProj.weight"
342
+ case "txt_to_q.scales": return "transformerBlocks.\(blockIdx).attn.addQProj.scales"
343
+ case "txt_to_q.biases": return "transformerBlocks.\(blockIdx).attn.addQProj.biases"
344
+ case "txt_to_k.weight": return "transformerBlocks.\(blockIdx).attn.addKProj.weight"
345
+ case "txt_to_k.scales": return "transformerBlocks.\(blockIdx).attn.addKProj.scales"
346
+ case "txt_to_k.biases": return "transformerBlocks.\(blockIdx).attn.addKProj.biases"
347
+ case "txt_to_v.weight": return "transformerBlocks.\(blockIdx).attn.addVProj.weight"
348
+ case "txt_to_v.scales": return "transformerBlocks.\(blockIdx).attn.addVProj.scales"
349
+ case "txt_to_v.biases": return "transformerBlocks.\(blockIdx).attn.addVProj.biases"
350
+ case "txt_to_out.weight": return "transformerBlocks.\(blockIdx).attn.toAddOut.weight"
351
+ case "txt_to_out.scales": return "transformerBlocks.\(blockIdx).attn.toAddOut.scales"
352
+ case "txt_to_out.biases": return "transformerBlocks.\(blockIdx).attn.toAddOut.biases"
353
+ case "img_norm_q.weight": return "transformerBlocks.\(blockIdx).attn.normQ.weight"
354
+ case "img_norm_k.weight": return "transformerBlocks.\(blockIdx).attn.normK.weight"
355
+ case "txt_norm_q.weight": return "transformerBlocks.\(blockIdx).attn.normAddedQ.weight"
356
+ case "txt_norm_k.weight": return "transformerBlocks.\(blockIdx).attn.normAddedK.weight"
357
+ case "img_mlp_in.weight": return "transformerBlocks.\(blockIdx).ff.activation.proj.weight"
358
+ case "img_mlp_in.scales": return "transformerBlocks.\(blockIdx).ff.activation.proj.scales"
359
+ case "img_mlp_in.biases": return "transformerBlocks.\(blockIdx).ff.activation.proj.biases"
360
+ case "img_mlp_out.weight": return "transformerBlocks.\(blockIdx).ff.linearOut.weight"
361
+ case "img_mlp_out.scales": return "transformerBlocks.\(blockIdx).ff.linearOut.scales"
362
+ case "img_mlp_out.biases": return "transformerBlocks.\(blockIdx).ff.linearOut.biases"
363
+ case "txt_mlp_in.weight": return "transformerBlocks.\(blockIdx).ffContext.activation.proj.weight"
364
+ case "txt_mlp_in.scales": return "transformerBlocks.\(blockIdx).ffContext.activation.proj.scales"
365
+ case "txt_mlp_in.biases": return "transformerBlocks.\(blockIdx).ffContext.activation.proj.biases"
366
+ case "txt_mlp_out.weight": return "transformerBlocks.\(blockIdx).ffContext.linearOut.weight"
367
+ case "txt_mlp_out.scales": return "transformerBlocks.\(blockIdx).ffContext.linearOut.scales"
368
+ case "txt_mlp_out.biases": return "transformerBlocks.\(blockIdx).ffContext.linearOut.biases"
369
+ default: return nil
370
+ }
371
+ }
372
+ }
373
+
374
+ if key.hasPrefix("single_blocks.") {
375
+ let blockIdx = extractBlockIndex(from: key, prefix: "single_blocks.")
376
+
377
+ if let suffix = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false).last {
378
+ switch String(suffix) {
379
+ case "to_qkv_mlp.weight": return "singleTransformerBlocks.\(blockIdx).attn.toQkvMlp.weight"
380
+ case "to_qkv_mlp.scales": return "singleTransformerBlocks.\(blockIdx).attn.toQkvMlp.scales"
381
+ case "to_qkv_mlp.biases": return "singleTransformerBlocks.\(blockIdx).attn.toQkvMlp.biases"
382
+ case "to_out.weight": return "singleTransformerBlocks.\(blockIdx).attn.toOut.weight"
383
+ case "to_out.scales": return "singleTransformerBlocks.\(blockIdx).attn.toOut.scales"
384
+ case "to_out.biases": return "singleTransformerBlocks.\(blockIdx).attn.toOut.biases"
385
+ case "norm_q.weight": return "singleTransformerBlocks.\(blockIdx).attn.normQ.weight"
386
+ case "norm_k.weight": return "singleTransformerBlocks.\(blockIdx).attn.normK.weight"
387
+ default: return nil
388
+ }
389
+ }
390
+ }
391
+
392
+ switch key {
393
+ case "x_embedder.weight":
394
+ return "xEmbedder.weight"
395
+ case "x_embedder.scales":
396
+ return "xEmbedder.scales"
397
+ case "x_embedder.biases":
398
+ return "xEmbedder.biases"
399
+ case "context_embedder.weight":
400
+ return "contextEmbedder.weight"
401
+ case "context_embedder.scales":
402
+ return "contextEmbedder.scales"
403
+ case "context_embedder.biases":
404
+ return "contextEmbedder.biases"
405
+ case "time_embed_1.weight":
406
+ return "timeGuidanceEmbed.timestepEmbedder.linear1.weight"
407
+ case "time_embed_1.scales":
408
+ return "timeGuidanceEmbed.timestepEmbedder.linear1.scales"
409
+ case "time_embed_1.biases":
410
+ return "timeGuidanceEmbed.timestepEmbedder.linear1.biases"
411
+ case "time_embed_2.weight":
412
+ return "timeGuidanceEmbed.timestepEmbedder.linear2.weight"
413
+ case "time_embed_2.scales":
414
+ return "timeGuidanceEmbed.timestepEmbedder.linear2.scales"
415
+ case "time_embed_2.biases":
416
+ return "timeGuidanceEmbed.timestepEmbedder.linear2.biases"
417
+ case "double_mod_img.linear.weight":
418
+ return "doubleStreamModulationImg.linear.weight"
419
+ case "double_mod_img.linear.scales":
420
+ return "doubleStreamModulationImg.linear.scales"
421
+ case "double_mod_img.linear.biases":
422
+ return "doubleStreamModulationImg.linear.biases"
423
+ case "double_mod_txt.linear.weight":
424
+ return "doubleStreamModulationTxt.linear.weight"
425
+ case "double_mod_txt.linear.scales":
426
+ return "doubleStreamModulationTxt.linear.scales"
427
+ case "double_mod_txt.linear.biases":
428
+ return "doubleStreamModulationTxt.linear.biases"
429
+ case "single_mod.linear.weight":
430
+ return "singleStreamModulation.linear.weight"
431
+ case "single_mod.linear.scales":
432
+ return "singleStreamModulation.linear.scales"
433
+ case "single_mod.linear.biases":
434
+ return "singleStreamModulation.linear.biases"
435
+ case "norm_out.weight":
436
+ return "normOut.linear.weight"
437
+ case "norm_out.scales":
438
+ return "normOut.linear.scales"
439
+ case "norm_out.biases":
440
+ return "normOut.linear.biases"
441
+ case "proj_out.weight":
442
+ return "projOut.weight"
443
+ case "proj_out.scales":
444
+ return "projOut.scales"
445
+ case "proj_out.biases":
446
+ return "projOut.biases"
447
+ default:
448
+ return nil
449
+ }
450
+ }
451
+
452
+ private static func quantizedBaseKey(for mappedKey: String) -> String? {
453
+ if mappedKey.hasSuffix(".scales") {
454
+ return String(mappedKey.dropLast(7)) + ".weight"
455
+ }
456
+ if mappedKey.hasSuffix(".biases") {
457
+ return String(mappedKey.dropLast(7)) + ".weight"
458
+ }
459
+ return nil
460
+ }
461
+
462
+ private static func inferQuantizationBits(packedShape: [Int], expectedShape: [Int]) -> Int? {
463
+ guard packedShape.count == expectedShape.count,
464
+ let packedCols = packedShape.last,
465
+ let expectedCols = expectedShape.last,
466
+ packedCols > 0,
467
+ expectedCols > 0 else {
468
+ return nil
469
+ }
470
+
471
+ let numerator = packedCols * 32
472
+ guard numerator % expectedCols == 0 else {
473
+ return nil
474
+ }
475
+
476
+ let bits = numerator / expectedCols
477
+ return bits > 0 ? bits : nil
478
+ }
479
+
480
+ private static func inferGroupSize(expectedShape: [Int], scalesShape: [Int]) -> Int? {
481
+ guard expectedShape.count == scalesShape.count,
482
+ let expectedCols = expectedShape.last,
483
+ let scaleGroups = scalesShape.last,
484
+ scaleGroups > 0,
485
+ expectedCols > 0,
486
+ expectedCols % scaleGroups == 0 else {
487
+ return nil
488
+ }
489
+
490
+ return expectedCols / scaleGroups
491
+ }
492
+
493
+ private static func maybeSwapNormOutRows(
494
+ _ weight: MLXArray,
495
+ mappedKey: String,
496
+ profile: Flux2TransformerRepoProfile
497
+ ) -> MLXArray {
498
+ guard profile.needsNormOutSwap,
499
+ mappedKey == "normOut.linear.weight",
500
+ weight.shape.count == 2 else {
501
+ return weight
502
+ }
503
+
504
+ let dim = weight.shape[0] / 2
505
+ guard dim > 0 else { return weight }
506
+ let shiftRows = weight[0..<dim, 0...]
507
+ let scaleRows = weight[dim..., 0...]
508
+ return concatenated([scaleRows, shiftRows], axis: 0)
509
+ }
510
+
511
+ private static func requiresStrictPackedValidation(for profile: Flux2TransformerRepoProfile) -> Bool {
512
+ profile.checkpointFormat == .packedDiffusers
513
+ }
514
+
515
+ private static func isBonsaiTernaryProfile(_ profile: Flux2TransformerRepoProfile) -> Bool {
516
+ profile.repoId == ModelRegistry.TransformerRepo.prismMLBonsaiKlein4B2bit.rawValue
517
+ }
518
+
519
+ private static func validateCriticalTransformerCoverage(
520
+ updates: [String: MLXArray],
521
+ packedModulePaths: Set<String> = [],
522
+ flatParameters: [String: MLXArray],
523
+ profile: Flux2TransformerRepoProfile
524
+ ) throws {
525
+ guard requiresStrictPackedValidation(for: profile) else {
526
+ return
527
+ }
528
+
529
+ let missingParameters = flatParameters.keys.sorted().filter { key in
530
+ updates[key] == nil && !packedModulePaths.contains(parameterModulePath(for: key))
531
+ }
532
+ guard missingParameters.isEmpty else {
533
+ let sample = missingParameters.prefix(12).joined(separator: ", ")
534
+ let missingByTopLevel = Dictionary(grouping: missingParameters) { key in
535
+ key.split(separator: ".").first.map(String.init) ?? key
536
+ }
537
+ let summary = missingByTopLevel
538
+ .map { "\($0.key): \($0.value.count)" }
539
+ .sorted()
540
+ .joined(separator: ", ")
541
+ throw Flux2WeightLoaderError.weightMismatch(
542
+ "Missing \(missingParameters.count) transformer parameters for \(profile.repoId): \(sample). Summary: \(summary)"
543
+ )
544
+ }
545
+ }
546
+
547
+ private static func parameterModulePath(for key: String) -> String {
548
+ if key.hasSuffix(".weight") {
549
+ return String(key.dropLast(7))
550
+ }
551
+ if key.hasSuffix(".bias") {
552
+ return String(key.dropLast(5))
553
+ }
554
+ if key.hasSuffix(".scales") {
555
+ return String(key.dropLast(7))
556
+ }
557
+ if key.hasSuffix(".biases") {
558
+ return String(key.dropLast(7))
559
+ }
560
+ return key
561
+ }
562
+
563
+ private static func supportsPackedAffineRuntime(bits: Int) -> Bool {
564
+ switch bits {
565
+ case 2, 3, 4, 5, 6, 8:
566
+ return true
567
+ default:
568
+ return false
569
+ }
570
+ }
571
+
572
+ /// Extract block index from key like "double_blocks.3.img_attn.qkv.weight"
573
+ private static func extractBlockIndex(from key: String, prefix: String) -> Int {
574
+ guard key.hasPrefix(prefix) else { return 0 }
575
+ let afterPrefix = String(key.dropFirst(prefix.count))
576
+ guard let dotIndex = afterPrefix.firstIndex(of: ".") else { return 0 }
577
+ let indexStr = String(afterPrefix[..<dotIndex])
578
+ return Int(indexStr) ?? 0
579
+ }
580
+
581
+ /// Map weights in Diffusers format (HuggingFace/quanto quantized)
582
+ /// Entries are removed from the input dict as they are processed to minimize peak memory.
583
+ /// This avoids holding both raw quanto data (~32GB) and dequantified float16 (~60GB) simultaneously.
584
+ private static func mapDiffusersTransformerWeights(
585
+ _ weights: inout [String: MLXArray],
586
+ profile: Flux2TransformerRepoProfile
587
+ ) -> [String: MLXArray] {
588
+ var mapped: [String: MLXArray] = [:]
589
+ var quantizedData: [String: MLXArray] = [:] // base_key -> ._data
590
+ var quantizedScales: [String: MLXArray] = [:] // base_key -> ._scale
591
+ let hasExplicitTimeTextEmbed = weights.keys.contains { key in
592
+ let normalizedKey = key.hasPrefix("transformer.") ? String(key.dropFirst(12)) : key
593
+ return normalizedKey.hasPrefix("time_text_embed.")
594
+ }
595
+
596
+ // First pass: extract entries from input dict and remove them to free memory
597
+ let allKeys = Array(weights.keys)
598
+ for key in allKeys {
599
+ guard let value = weights.removeValue(forKey: key) else { continue }
600
+
601
+ var processedKey = key
602
+
603
+ // Remove common prefixes
604
+ if processedKey.hasPrefix("transformer.") {
605
+ processedKey = String(processedKey.dropFirst(12))
606
+ }
607
+
608
+ // Check for quanto quantization suffixes
609
+ if processedKey.hasSuffix("._data") {
610
+ let baseKey = String(processedKey.dropLast(6)) // Remove "._data"
611
+ let mappedKey = mapTransformerKeySimple(baseKey, hasExplicitTimeTextEmbed: hasExplicitTimeTextEmbed)
612
+ quantizedData[mappedKey] = value
613
+ } else if processedKey.hasSuffix("._scale") {
614
+ let baseKey = String(processedKey.dropLast(7)) // Remove "._scale"
615
+ let mappedKey = mapTransformerKeySimple(baseKey, hasExplicitTimeTextEmbed: hasExplicitTimeTextEmbed)
616
+ quantizedScales[mappedKey] = value
617
+ } else if processedKey.hasSuffix(".input_scale") || processedKey.hasSuffix(".output_scale") {
618
+ // Skip activation scales for quanto (value is released)
619
+ continue
620
+ } else {
621
+ // Non-quantized weight, map directly
622
+ let mappedKey = mapTransformerKeySimple(processedKey, hasExplicitTimeTextEmbed: hasExplicitTimeTextEmbed)
623
+ mapped[mappedKey] = value
624
+ }
625
+ }
626
+ // Input dict is now empty — raw safetensors references freed
627
+ Flux2Debug.log("Input dict cleared — freed raw weight entries")
628
+
629
+ // Second pass: dequantize qint8 weights to float16
630
+ // Release quanto data/scale entries progressively to keep peak ≈ 60GB (float16 only)
631
+ var dequantCount = 0
632
+ let quantizedKeys = Array(quantizedData.keys)
633
+ for key in quantizedKeys {
634
+ guard let data = quantizedData.removeValue(forKey: key) else { continue }
635
+
636
+ if let scale = quantizedScales.removeValue(forKey: key) {
637
+ // Dequantize: weight = data.float() * scale, then convert to float16
638
+ let dequantized = (data.asType(.float32) * scale).asType(.float16)
639
+ mapped[key] = dequantized
640
+ dequantCount += 1
641
+ } else {
642
+ // No scale found, use data as-is
643
+ mapped[key] = data.asType(.float16)
644
+ Flux2Debug.warning("No scale found for quantized weight: \(key)")
645
+ }
646
+ // Periodically materialize and clear GPU cache
647
+ if dequantCount % 50 == 0 {
648
+ eval(mapped.values.map { $0 })
649
+ MLX.Memory.clearCache()
650
+ }
651
+ }
652
+ Flux2Debug.log("Dequantized \(dequantCount) qint8 weights to float16")
653
+
654
+ // DEBUG: Print weight statistics for key layers
655
+ Flux2Debug.log("=== Diffusers Weight Statistics ===")
656
+ for key in ["xEmbedder.weight", "contextEmbedder.weight",
657
+ "transformerBlocks.0.attn.toQ.weight",
658
+ "transformerBlocks.0.ff.activation.proj.weight",
659
+ "singleTransformerBlocks.0.attn.toQkvMlp.weight"].prefix(5) {
660
+ if let w = mapped[key] {
661
+ eval(w)
662
+ let absVal = MLX.abs(w)
663
+ let meanVal = mean(absVal).item(Float.self)
664
+ let maxVal = MLX.max(absVal).item(Float.self)
665
+ let minVal = MLX.min(absVal).item(Float.self)
666
+ Flux2Debug.log(" \(key): shape=\(w.shape), mean=\(meanVal), max=\(maxVal), min=\(minVal)")
667
+ } else {
668
+ Flux2Debug.log(" \(key): NOT FOUND")
669
+ }
670
+ }
671
+
672
+ return mapped
673
+ }
674
+
675
+ /// Map key without handling quantization suffixes (called after suffix processing)
676
+ private static func mapTransformerKeySimple(_ key: String, hasExplicitTimeTextEmbed: Bool = false) -> String {
677
+ var mapped = key
678
+
679
+ // Map single stream blocks (checkpoint may use various formats)
680
+ mapped = mapped.replacingOccurrences(of: "single_transformer_blocks.", with: "singleTransformerBlocks.")
681
+ mapped = mapped.replacingOccurrences(of: "single_transformerBlocks.", with: "singleTransformerBlocks.")
682
+
683
+ // Map double stream blocks (both formats)
684
+ mapped = mapped.replacingOccurrences(of: "transformer_blocks.", with: "transformerBlocks.")
685
+
686
+ // Map attention components - both underscore and camelCase from checkpoint
687
+ mapped = mapped.replacingOccurrences(of: "attn.to_q.", with: "attn.toQ.")
688
+ mapped = mapped.replacingOccurrences(of: "attn.to_k.", with: "attn.toK.")
689
+ mapped = mapped.replacingOccurrences(of: "attn.to_v.", with: "attn.toV.")
690
+ mapped = mapped.replacingOccurrences(of: "attn.toQ.", with: "attn.toQ.")
691
+ mapped = mapped.replacingOccurrences(of: "attn.toK.", with: "attn.toK.")
692
+ mapped = mapped.replacingOccurrences(of: "attn.toV.", with: "attn.toV.")
693
+ mapped = mapped.replacingOccurrences(of: "attn.to_out.0.", with: "attn.toOut.")
694
+ mapped = mapped.replacingOccurrences(of: "attn.toOut.0.", with: "attn.toOut.")
695
+ mapped = mapped.replacingOccurrences(of: "attn.add_q_proj.", with: "attn.addQProj.")
696
+ mapped = mapped.replacingOccurrences(of: "attn.add_k_proj.", with: "attn.addKProj.")
697
+ mapped = mapped.replacingOccurrences(of: "attn.add_v_proj.", with: "attn.addVProj.")
698
+ mapped = mapped.replacingOccurrences(of: "attn.addQProj.", with: "attn.addQProj.")
699
+ mapped = mapped.replacingOccurrences(of: "attn.addKProj.", with: "attn.addKProj.")
700
+ mapped = mapped.replacingOccurrences(of: "attn.addVProj.", with: "attn.addVProj.")
701
+ mapped = mapped.replacingOccurrences(of: "attn.to_add_out.", with: "attn.toAddOut.")
702
+ mapped = mapped.replacingOccurrences(of: "attn.toAddOut.", with: "attn.toAddOut.")
703
+
704
+ // Map single block attention (fused QKV+MLP)
705
+ // Model uses toQkvMlp, checkpoint uses to_qkv_mlp_proj
706
+ mapped = mapped.replacingOccurrences(of: "attn.to_qkv_mlp_proj.", with: "attn.toQkvMlp.")
707
+
708
+ // Map to_out without .0. (single stream blocks)
709
+ mapped = mapped.replacingOccurrences(of: "attn.to_out.", with: "attn.toOut.")
710
+
711
+ // Map norms - both formats
712
+ mapped = mapped.replacingOccurrences(of: "attn.norm_q.", with: "attn.normQ.")
713
+ mapped = mapped.replacingOccurrences(of: "attn.norm_k.", with: "attn.normK.")
714
+ mapped = mapped.replacingOccurrences(of: "attn.normQ.", with: "attn.normQ.")
715
+ mapped = mapped.replacingOccurrences(of: "attn.normK.", with: "attn.normK.")
716
+ mapped = mapped.replacingOccurrences(of: "attn.norm_added_q.", with: "attn.normAddedQ.")
717
+ mapped = mapped.replacingOccurrences(of: "attn.norm_added_k.", with: "attn.normAddedK.")
718
+ mapped = mapped.replacingOccurrences(of: "attn.normAddedQ.", with: "attn.normAddedQ.")
719
+ mapped = mapped.replacingOccurrences(of: "attn.normAddedK.", with: "attn.normAddedK.")
720
+ mapped = mapped.replacingOccurrences(of: "norm1_context.", with: "norm1Context.")
721
+ mapped = mapped.replacingOccurrences(of: "norm2_context.", with: "norm2Context.")
722
+
723
+ // Map feedforward
724
+ // Model uses SwiGLU with activation.proj for input, checkpoint uses linear_in
725
+ mapped = mapped.replacingOccurrences(of: "ff_context.linear_in.", with: "ffContext.activation.proj.")
726
+ mapped = mapped.replacingOccurrences(of: "ff_context.linear_out.", with: "ffContext.linearOut.")
727
+ mapped = mapped.replacingOccurrences(of: ".ff.linear_in.", with: ".ff.activation.proj.")
728
+ mapped = mapped.replacingOccurrences(of: ".ff.linear_out.", with: ".ff.linearOut.")
729
+ // Generic fallbacks
730
+ mapped = mapped.replacingOccurrences(of: "ff_context.", with: "ffContext.")
731
+ mapped = mapped.replacingOccurrences(of: "linear_in.", with: "activation.proj.")
732
+ mapped = mapped.replacingOccurrences(of: "linear_out.", with: "linearOut.")
733
+
734
+ // Map embeddings
735
+ mapped = mapped.replacingOccurrences(of: "x_embedder.", with: "xEmbedder.")
736
+ mapped = mapped.replacingOccurrences(of: "context_embedder.", with: "contextEmbedder.")
737
+ mapped = mapped.replacingOccurrences(of: "contextEmbedder.", with: "contextEmbedder.")
738
+ mapped = mapped.replacingOccurrences(of: "time_text_embed.linear_1.", with: "timeGuidanceEmbed.timestepEmbedder.linear1.")
739
+ mapped = mapped.replacingOccurrences(of: "time_text_embed.linear_2.", with: "timeGuidanceEmbed.timestepEmbedder.linear2.")
740
+ if hasExplicitTimeTextEmbed {
741
+ mapped = mapped.replacingOccurrences(of: "time_guidance_embed.linear_1.", with: "timeGuidanceEmbed.guidanceEmbedder.linear1.")
742
+ mapped = mapped.replacingOccurrences(of: "time_guidance_embed.linear_2.", with: "timeGuidanceEmbed.guidanceEmbedder.linear2.")
743
+ } else {
744
+ mapped = mapped.replacingOccurrences(of: "time_guidance_embed.linear_1.", with: "timeGuidanceEmbed.timestepEmbedder.linear1.")
745
+ mapped = mapped.replacingOccurrences(of: "time_guidance_embed.linear_2.", with: "timeGuidanceEmbed.timestepEmbedder.linear2.")
746
+ }
747
+ mapped = mapped.replacingOccurrences(of: "time_text_embed.", with: "timeGuidanceEmbed.")
748
+ mapped = mapped.replacingOccurrences(of: "time_guidance_embed.", with: "timeGuidanceEmbed.")
749
+
750
+ // Map timestep embedder
751
+ mapped = mapped.replacingOccurrences(of: "timestep_embedder.", with: "timestepEmbedder.")
752
+ mapped = mapped.replacingOccurrences(of: "guidance_embedder.", with: "guidanceEmbedder.")
753
+ mapped = mapped.replacingOccurrences(of: "linear_1.", with: "linear1.")
754
+ mapped = mapped.replacingOccurrences(of: "linear_2.", with: "linear2.")
755
+
756
+ // Map modulation layers
757
+ mapped = mapped.replacingOccurrences(of: "double_stream_modulation_img.", with: "doubleStreamModulationImg.")
758
+ mapped = mapped.replacingOccurrences(of: "double_stream_modulation_txt.", with: "doubleStreamModulationTxt.")
759
+ mapped = mapped.replacingOccurrences(of: "single_stream_modulation.", with: "singleStreamModulation.")
760
+
761
+ // Map output
762
+ mapped = mapped.replacingOccurrences(of: "norm_out.", with: "normOut.")
763
+ mapped = mapped.replacingOccurrences(of: "proj_out.", with: "projOut.")
764
+ mapped = mapped.replacingOccurrences(of: "normOut.", with: "normOut.")
765
+ mapped = mapped.replacingOccurrences(of: "projOut.", with: "projOut.")
766
+
767
+ return mapped
768
+ }
769
+
770
+ // MARK: - VAE Weight Mapping
771
+
772
+ /// Convert HuggingFace VAE weight keys to Swift module paths
773
+ /// Also transposes Conv2d weights from PyTorch OIHW format to MLX OHWI format
774
+ public static func mapVAEWeights(_ weights: [String: MLXArray]) -> [String: MLXArray] {
775
+ var mapped: [String: MLXArray] = [:]
776
+
777
+ for (key, var value) in weights {
778
+ var newKey = key
779
+
780
+ // Transpose Conv2d weights from PyTorch OIHW to MLX OHWI format
781
+ // Conv2d weights have 4 dimensions and key ends with ".weight"
782
+ // PyTorch: [out_channels, in_channels, kernel_h, kernel_w]
783
+ // MLX: [out_channels, kernel_h, kernel_w, in_channels]
784
+ if key.hasSuffix(".weight") && value.ndim == 4 {
785
+ value = value.transposed(0, 2, 3, 1) // OIHW -> OHWI
786
+ }
787
+
788
+ // Map encoder/decoder
789
+ newKey = newKey.replacingOccurrences(of: "encoder.conv_in.", with: "encoder.convIn.")
790
+ newKey = newKey.replacingOccurrences(of: "encoder.conv_out.", with: "encoder.convOut.")
791
+ newKey = newKey.replacingOccurrences(of: "encoder.conv_norm_out.", with: "encoder.convNormOut.")
792
+ newKey = newKey.replacingOccurrences(of: "decoder.conv_in.", with: "decoder.convIn.")
793
+ newKey = newKey.replacingOccurrences(of: "decoder.conv_out.", with: "decoder.convOut.")
794
+ newKey = newKey.replacingOccurrences(of: "decoder.conv_norm_out.", with: "decoder.convNormOut.")
795
+
796
+ // Map down/up blocks
797
+ newKey = newKey.replacingOccurrences(of: "down_blocks.", with: "downBlocks.")
798
+ newKey = newKey.replacingOccurrences(of: "up_blocks.", with: "upBlocks.")
799
+
800
+ // Map mid_block - special handling for tuple structure
801
+ // mid_block.resnets.0 -> midBlock.0 (resnet1)
802
+ // mid_block.attentions.0 -> midBlock.1 (attention)
803
+ // mid_block.resnets.1 -> midBlock.2 (resnet2)
804
+ // Apply to both encoder and decoder
805
+ newKey = newKey.replacingOccurrences(of: "encoder.mid_block.resnets.0.", with: "encoder.midBlock.0.")
806
+ newKey = newKey.replacingOccurrences(of: "encoder.mid_block.attentions.0.", with: "encoder.midBlock.1.")
807
+ newKey = newKey.replacingOccurrences(of: "encoder.mid_block.resnets.1.", with: "encoder.midBlock.2.")
808
+ newKey = newKey.replacingOccurrences(of: "decoder.mid_block.resnets.0.", with: "decoder.midBlock.0.")
809
+ newKey = newKey.replacingOccurrences(of: "decoder.mid_block.attentions.0.", with: "decoder.midBlock.1.")
810
+ newKey = newKey.replacingOccurrences(of: "decoder.mid_block.resnets.1.", with: "decoder.midBlock.2.")
811
+
812
+ // Map ResNet blocks in down/up blocks
813
+ // The model uses tuples: (blocks: [ResnetBlock2D], upsample/downsample: Upsample2D?)
814
+ // This flattens as: upBlocks.{idx}.0.{resnet_idx} for blocks, upBlocks.{idx}.1 for upsample
815
+ // Checkpoint uses: up_blocks.{idx}.resnets.{resnet_idx}
816
+ newKey = newKey.replacingOccurrences(of: "resnets.", with: "0.")
817
+ newKey = newKey.replacingOccurrences(of: "conv_shortcut.", with: "convShortcut.")
818
+
819
+ // Map upsamplers and downsamplers
820
+ // upsamplers.0.conv -> 1.conv (position 1 in tuple)
821
+ // downsamplers.0.conv -> 1.conv (position 1 in tuple)
822
+ newKey = newKey.replacingOccurrences(of: "upsamplers.0.conv.", with: "1.conv.")
823
+ newKey = newKey.replacingOccurrences(of: "downsamplers.0.conv.", with: "1.conv.")
824
+
825
+ // Map attention
826
+ newKey = newKey.replacingOccurrences(of: "attentions.", with: "attentions.")
827
+ newKey = newKey.replacingOccurrences(of: "group_norm.", with: "groupNorm.")
828
+ newKey = newKey.replacingOccurrences(of: "to_q.", with: "toQ.")
829
+ newKey = newKey.replacingOccurrences(of: "to_k.", with: "toK.")
830
+ newKey = newKey.replacingOccurrences(of: "to_v.", with: "toV.")
831
+ newKey = newKey.replacingOccurrences(of: "to_out.0.", with: "toOut.") // VAE attention has indexed to_out
832
+ newKey = newKey.replacingOccurrences(of: "to_out.", with: "toOut.")
833
+
834
+ // Map quant conv - order matters! More specific patterns first
835
+ newKey = newKey.replacingOccurrences(of: "post_quant_conv.", with: "postQuantConv.")
836
+ newKey = newKey.replacingOccurrences(of: "quant_conv.", with: "quantConv.")
837
+
838
+ // Map batch norm (bn -> latentBatchNorm)
839
+ newKey = newKey.replacingOccurrences(of: "bn.", with: "latentBatchNorm.")
840
+ newKey = newKey.replacingOccurrences(of: "running_mean", with: "runningMean")
841
+ newKey = newKey.replacingOccurrences(of: "running_var", with: "runningVar")
842
+ newKey = newKey.replacingOccurrences(of: "num_batches_tracked", with: "numBatchesTracked")
843
+
844
+ mapped[newKey] = value
845
+ }
846
+
847
+ return mapped
848
+ }
849
+
850
+ // MARK: - Weight Application
851
+
852
+ /// Apply weights to a transformer model
853
+ /// Note: `weights` is passed inout — for Diffusers quanto format, entries are freed during mapping
854
+ /// to reduce peak memory from ~98GB to ~71GB.
855
+ public static func applyTransformerWeights(
856
+ _ weights: inout [String: MLXArray],
857
+ to model: Flux2Transformer2DModel,
858
+ profile: Flux2TransformerRepoProfile
859
+ ) throws {
860
+ let mapped = mapTransformerWeights(&weights, profile: profile)
861
+
862
+ // Use MLX's built-in weight loading - flatten to get full paths
863
+ let flattenedArray = model.parameters().flattened()
864
+ var flatParameters: [String: MLXArray] = [:]
865
+ for (key, value) in flattenedArray {
866
+ flatParameters[key] = value
867
+ }
868
+
869
+ var updates: [String: MLXArray] = [:]
870
+ var moduleUpdates: [String: Module] = [:]
871
+ var packedModulePaths = Set<String>()
872
+ var notFound = 0
873
+
874
+ // Debug: print flattened model parameter keys by category
875
+ let modelTransformerBlocks = flatParameters.keys.filter { $0.hasPrefix("transformerBlocks.") }
876
+ let modelSingleBlocks = flatParameters.keys.filter { $0.hasPrefix("singleTransformerBlocks.") }
877
+ let modelTimeEmbed = flatParameters.keys.filter { $0.contains("timeGuidanceEmbed") || $0.contains("xEmbedder") }
878
+ Flux2Debug.verbose("Model parameter keys:")
879
+ Flux2Debug.verbose(" - transformerBlocks params: \(modelTransformerBlocks.count)")
880
+ Flux2Debug.verbose(" - singleTransformerBlocks params: \(modelSingleBlocks.count)")
881
+ Flux2Debug.verbose(" - time/embedder params: \(modelTimeEmbed.count)")
882
+ Flux2Debug.verbose(" - total params: \(flatParameters.count)")
883
+ for key in modelTimeEmbed.sorted() {
884
+ Flux2Debug.verbose(" \(key)")
885
+ }
886
+
887
+ var linearModules: [String: Linear] = [:]
888
+ for (path, module) in model.leafModules().flattened() {
889
+ if let linear = module as? Linear {
890
+ linearModules[path] = linear
891
+ }
892
+ }
893
+
894
+ var dequantizedWeights: [String: MLXArray] = [:]
895
+ var packedRuntimeCount = 0
896
+ var denseFallbackCount = 0
897
+ var unsupportedFallbackBits = Set<Int>()
898
+ let requiresBonsaiTernaryPackedRuntime = isBonsaiTernaryProfile(profile)
899
+
900
+ for (key, value) in mapped where value.dtype == .uint32 {
901
+ guard let scales = mapped["\(key.dropLast(6))scales"],
902
+ let biases = mapped["\(key.dropLast(6))biases"] else {
903
+ throw Flux2WeightLoaderError.weightMismatch(
904
+ "Packed transformer weight \(key) is missing matching scales/biases tensors for \(profile.repoId)"
905
+ )
906
+ }
907
+
908
+ guard let expectedShape = flatParameters[key]?.shape else {
909
+ if requiresBonsaiTernaryPackedRuntime {
910
+ throw Flux2WeightLoaderError.weightMismatch(
911
+ "Bonsai ternary packed tensor \(key) is not present in the instantiated Klein transformer for \(profile.repoId)"
912
+ )
913
+ }
914
+
915
+ Flux2Debug.warning(
916
+ "Skipping extra packed transformer tensor \(key) not present in instantiated model for \(profile.repoId)"
917
+ )
918
+ continue
919
+ }
920
+
921
+ guard let bits = inferQuantizationBits(packedShape: value.shape, expectedShape: expectedShape),
922
+ let groupSize = inferGroupSize(expectedShape: expectedShape, scalesShape: scales.shape) else {
923
+ throw Flux2WeightLoaderError.weightMismatch(
924
+ "Unable to infer quantization metadata for \(key): packed=\(value.shape) scales=\(scales.shape) expected=\(expectedShape)"
925
+ )
926
+ }
927
+
928
+ if requiresBonsaiTernaryPackedRuntime && (bits != 2 || groupSize != 128) {
929
+ throw Flux2WeightLoaderError.weightMismatch(
930
+ "Bonsai ternary requires packed 2-bit/groupSize=128 runtime weights, got bits=\(bits), groupSize=\(groupSize) for \(key) in \(profile.repoId)"
931
+ )
932
+ }
933
+
934
+ let modulePath = parameterModulePath(for: key)
935
+ if supportsPackedAffineRuntime(bits: bits), let linear = linearModules[modulePath] {
936
+ let quantizedLinear = QuantizedLinear(
937
+ weight: value,
938
+ bias: linear.bias,
939
+ scales: scales,
940
+ biases: biases,
941
+ groupSize: groupSize,
942
+ bits: bits,
943
+ mode: .affine
944
+ )
945
+ moduleUpdates[modulePath] = quantizedLinear
946
+ packedModulePaths.insert(modulePath)
947
+ packedRuntimeCount += 1
948
+ continue
949
+ }
950
+
951
+ if supportsPackedAffineRuntime(bits: bits) {
952
+ throw Flux2WeightLoaderError.weightMismatch(
953
+ "Packed transformer weight \(key) inferred as \(bits)-bit/groupSize=\(groupSize), but no matching Linear module exists at \(modulePath) for \(profile.repoId)"
954
+ )
955
+ }
956
+
957
+ if !supportsPackedAffineRuntime(bits: bits) {
958
+ if requiresBonsaiTernaryPackedRuntime {
959
+ throw Flux2WeightLoaderError.weightMismatch(
960
+ "Bonsai ternary requires packed 2-bit MLX affine runtime; \(key) inferred unsupported bits=\(bits) for \(profile.repoId)"
961
+ )
962
+ }
963
+
964
+ unsupportedFallbackBits.insert(bits)
965
+ }
966
+
967
+ let dense = maybeSwapNormOutRows(
968
+ dequantized(
969
+ value,
970
+ scales: scales,
971
+ biases: biases,
972
+ groupSize: groupSize,
973
+ bits: bits
974
+ ).asType(.float16),
975
+ mappedKey: key,
976
+ profile: profile
977
+ )
978
+ dequantizedWeights[key] = dense
979
+ denseFallbackCount += 1
980
+ }
981
+
982
+ if !moduleUpdates.isEmpty {
983
+ model.update(modules: ModuleChildren.unflattened(moduleUpdates))
984
+ Flux2Debug.log("Loaded \(packedRuntimeCount) packed transformer weights as QuantizedLinear modules")
985
+ }
986
+
987
+ if !unsupportedFallbackBits.isEmpty {
988
+ let bits = unsupportedFallbackBits.sorted().map(String.init).joined(separator: ", ")
989
+ Flux2Debug.warning(
990
+ "Packed transformer uses unsupported affine runtime bits [\(bits)]; falling back to dense float16 for \(profile.repoId)"
991
+ )
992
+ }
993
+
994
+ if !dequantizedWeights.isEmpty {
995
+ Flux2Debug.log("Dequantized \(denseFallbackCount) packed transformer weights to float16 fallback")
996
+ }
997
+
998
+ if requiresBonsaiTernaryPackedRuntime {
999
+ guard denseFallbackCount == 0 else {
1000
+ throw Flux2WeightLoaderError.weightMismatch(
1001
+ "Bonsai ternary must not dequantize packed transformer weights; \(denseFallbackCount) weights fell back to dense for \(profile.repoId)"
1002
+ )
1003
+ }
1004
+ Flux2Debug.log("Bonsai ternary packed runtime verified: \(packedRuntimeCount) 2-bit/groupSize=128 QuantizedLinear modules")
1005
+ }
1006
+
1007
+ for (key, value) in mapped {
1008
+ if key.hasSuffix(".scales") || key.hasSuffix(".biases") {
1009
+ continue
1010
+ }
1011
+
1012
+ if packedModulePaths.contains(parameterModulePath(for: key)) {
1013
+ continue
1014
+ }
1015
+
1016
+ let effectiveValue: MLXArray
1017
+ if let dequantized = dequantizedWeights[key] {
1018
+ effectiveValue = dequantized
1019
+ } else {
1020
+ effectiveValue = maybeSwapNormOutRows(value, mappedKey: key, profile: profile)
1021
+ }
1022
+
1023
+ if flatParameters.keys.contains(key) {
1024
+ updates[key] = effectiveValue
1025
+ } else {
1026
+ notFound += 1
1027
+ if notFound <= 10 {
1028
+ Flux2Debug.log("Warning: No parameter found for key: \(key)")
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ if notFound > 10 {
1034
+ Flux2Debug.log("... and \(notFound - 10) more missing parameters")
1035
+ }
1036
+
1037
+ // Update model with new weights using the flattened format
1038
+ try validateCriticalTransformerCoverage(
1039
+ updates: updates,
1040
+ packedModulePaths: packedModulePaths,
1041
+ flatParameters: flatParameters,
1042
+ profile: profile
1043
+ )
1044
+ _ = model.update(parameters: ModuleParameters.unflattened(updates))
1045
+
1046
+ // Debug: print some weight shapes
1047
+ for (key, value) in updates.sorted(by: { $0.key < $1.key }).prefix(10) {
1048
+ Flux2Debug.log(" Weight \(key): shape=\(value.shape), dtype=\(value.dtype)")
1049
+ }
1050
+
1051
+ Flux2Debug.log("Applied \(updates.count) weights to transformer (\(notFound) not found)")
1052
+ }
1053
+
1054
+ /// Apply weights to a VAE model
1055
+ public static func applyVAEWeights(
1056
+ _ weights: [String: MLXArray],
1057
+ to model: AutoencoderKLFlux2
1058
+ ) throws {
1059
+ let mapped = mapVAEWeights(weights)
1060
+
1061
+ // Check for BatchNorm running stats
1062
+ if let runningMean = mapped["latentBatchNorm.runningMean"],
1063
+ let runningVar = mapped["latentBatchNorm.runningVar"] {
1064
+ model.loadBatchNormStats(runningMean: runningMean, runningVar: runningVar)
1065
+ }
1066
+
1067
+ // Flatten model parameters for key matching
1068
+ let flattenedArray = model.parameters().flattened()
1069
+ var flatParameters: [String: MLXArray] = [:]
1070
+ for (key, value) in flattenedArray {
1071
+ flatParameters[key] = value
1072
+ }
1073
+
1074
+ // Debug: print some VAE parameter keys
1075
+ Flux2Debug.verbose("VAE model parameter keys (first 20 of \(flatParameters.count) flattened):")
1076
+ for key in flatParameters.keys.sorted().prefix(20) {
1077
+ Flux2Debug.verbose(" - \(key)")
1078
+ }
1079
+
1080
+ var updates: [String: MLXArray] = [:]
1081
+ var notFound = 0
1082
+
1083
+ for (key, value) in mapped {
1084
+ if key.contains("running") { continue } // Skip running stats, handled above
1085
+
1086
+ if flatParameters.keys.contains(key) {
1087
+ updates[key] = value
1088
+ } else {
1089
+ notFound += 1
1090
+ if notFound <= 10 {
1091
+ Flux2Debug.log("VAE Warning: No parameter found for key: \(key)")
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ if notFound > 10 {
1097
+ Flux2Debug.log("... and \(notFound - 10) more missing VAE parameters")
1098
+ }
1099
+
1100
+ _ = model.update(parameters: ModuleParameters.unflattened(updates))
1101
+
1102
+ Flux2Debug.log("Applied \(updates.count) weights to VAE (\(notFound) not found)")
1103
+ }
1104
+
1105
+ // MARK: - Quantized Weight Loading
1106
+
1107
+ /// Load quantized transformer weights
1108
+ public static func loadQuantizedTransformer(
1109
+ from modelPath: String,
1110
+ quantization: TransformerQuantization
1111
+ ) throws -> [String: MLXArray] {
1112
+ let weights = try loadWeights(from: modelPath)
1113
+
1114
+ // For qint8, weights may already be quantized in safetensors
1115
+ // If not, we need to quantize them here
1116
+ if quantization != .bf16 {
1117
+ Flux2Debug.log("Loading \(quantization.rawValue) quantized weights")
1118
+ }
1119
+
1120
+ return weights
1121
+ }
1122
+
1123
+ // MARK: - Verification
1124
+
1125
+ /// Verify that all required weights are present
1126
+ public static func verifyTransformerWeights(_ weights: [String: MLXArray]) -> (complete: Bool, missing: [String]) {
1127
+ let requiredPrefixes = [
1128
+ "xEmbedder",
1129
+ "contextEmbedder",
1130
+ "timeGuidanceEmbed",
1131
+ "transformerBlocks.0",
1132
+ "singleTransformerBlocks.0",
1133
+ "normOut",
1134
+ "projOut"
1135
+ ]
1136
+
1137
+ var missing: [String] = []
1138
+
1139
+ for prefix in requiredPrefixes {
1140
+ let hasPrefix = weights.keys.contains { $0.hasPrefix(prefix) }
1141
+ if !hasPrefix {
1142
+ missing.append(prefix)
1143
+ }
1144
+ }
1145
+
1146
+ return (missing.isEmpty, missing)
1147
+ }
1148
+
1149
+ /// Get summary of loaded weights
1150
+ public static func summarizeWeights(_ weights: [String: MLXArray]) {
1151
+ var totalParams: Int64 = 0
1152
+ var byPrefix: [String: Int64] = [:]
1153
+
1154
+ for (key, array) in weights {
1155
+ let params = Int64(array.shape.reduce(1, *))
1156
+ totalParams += params
1157
+
1158
+ // Group by first component
1159
+ let prefix = String(key.split(separator: ".").first ?? Substring(key))
1160
+ byPrefix[prefix, default: 0] += params
1161
+ }
1162
+
1163
+ Flux2Debug.log("Weight Summary:")
1164
+ for (prefix, params) in byPrefix.sorted(by: { $0.value > $1.value }) {
1165
+ let gb = Float(params * 2) / 1_000_000_000 // bf16 = 2 bytes
1166
+ Flux2Debug.log(" \(prefix): \(params) params (~\(String(format: "%.2f", gb))GB)")
1167
+ }
1168
+ Flux2Debug.log("Total: \(totalParams) parameters")
1169
+ }
1170
+ }
1171
+
1172
+ // MARK: - Errors
1173
+
1174
+ public enum Flux2WeightLoaderError: LocalizedError {
1175
+ case noWeightsFound(String)
1176
+ case weightMismatch(String)
1177
+ case fileNotFound(String)
1178
+ case incompatibleShape(expected: [Int], got: [Int], key: String)
1179
+
1180
+ public var errorDescription: String? {
1181
+ switch self {
1182
+ case .noWeightsFound(let path):
1183
+ return "No safetensors files found in: \(path)"
1184
+ case .weightMismatch(let message):
1185
+ return "Weight mismatch: \(message)"
1186
+ case .fileNotFound(let path):
1187
+ return "File not found: \(path)"
1188
+ case .incompatibleShape(let expected, let got, let key):
1189
+ return "Incompatible shape for \(key): expected \(expected), got \(got)"
1190
+ }
1191
+ }
1192
+ }