Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions apps/omlx-mac/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -11512,6 +11512,24 @@
}
}
},
"profile.detail.acceleration.turboquant.mid_prefill" : {
"comment" : "TurboQuant profile-chip suffix indicating opt-in conversion when a full prefill chunk cannot fit",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "prefill pressure"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "prefill pressure"
}
}
}
},
"profile.detail.acceleration.turboquant.skip" : {
"comment" : "TurboQuant skip-last-N suffix; placeholder is the layer count",
"extractionState" : "extracted_with_value",
Expand Down Expand Up @@ -22007,20 +22025,56 @@
}
}
},
"settings.experimental.turboquant.mid_prefill.label" : {
"comment" : "Subordinate TurboQuant row label for converting the KV cache when a full prefill chunk cannot fit",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Convert under prefill pressure"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Convert under prefill pressure"
}
}
}
},
"settings.experimental.turboquant.mid_prefill.sub" : {
"comment" : "Help text for converting the TurboQuant KV cache once under prefill memory pressure",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "When a full prefill chunk cannot fit, convert the growing KV cache once and continue with TurboQuant. Requires this to be the only loaded model. Adds a one-time pause and may slow the rest of prefill."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "When a full prefill chunk cannot fit, convert the growing KV cache once and continue with TurboQuant. Requires this to be the only loaded model. Adds a one-time pause and may slow the rest of prefill."
}
}
}
},
"settings.experimental.turboquant.sub" : {
"comment" : "Sublabel describing TurboQuant KV cache",
"comment" : "Sublabel describing the normal TurboQuant KV cache conversion path",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Quantize the KV cache during prefill. Saves memory at a small quality cost."
"value" : "Convert the KV cache after prefill for generation. Saves memory at a small quality cost."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Квантовать KV-кэш во время префилла. Экономит память с небольшой потерей качества."
"value" : "Квантовать KV-кэш после префилла для генерации. Экономит память с небольшой потерей качества."
}
},
"zh-Hans" : {
Expand Down
25 changes: 23 additions & 2 deletions apps/omlx-mac/Sources/AppView/Screens/ModelSettingsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,15 @@ private struct ExperimentalSection: View {
.help(vm.vlmMtpEnabled ? vlmMtpOwnsSpeculativePathReason : "")
}
}
if vm.showsTurboquantMidPrefill {
Row(label: turboquantMidPrefillLabel,
sublabel: turboquantMidPrefillHelp) {
Toggle(turboquantMidPrefillLabel,
isOn: vm.bindProfile($vm.turboquantMidPrefill))
.labelsHidden().toggleStyle(.switch)
.help(turboquantMidPrefillHelp)
}
}

// IndexCache (DSA-only — surface to the user that the row
// only applies to models whose config matches the DSA set).
Expand Down Expand Up @@ -1370,8 +1379,20 @@ private struct ExperimentalSection: View {
private var turboquantSublabel: String {
if vm.vlmMtpEnabled { return vlmMtpOwnsSpeculativePathReason }
return String(localized: "settings.experimental.turboquant.sub",
defaultValue: "Quantize the KV cache during prefill. Saves memory at a small quality cost.",
comment: "Sublabel describing TurboQuant KV cache")
defaultValue: "Convert the KV cache after prefill for generation. Saves memory at a small quality cost.",
comment: "Sublabel describing the normal TurboQuant KV cache conversion path")
}

private var turboquantMidPrefillLabel: String {
String(localized: "settings.experimental.turboquant.mid_prefill.label",
defaultValue: "Convert under prefill pressure",
comment: "Subordinate TurboQuant row label for converting the KV cache when a full prefill chunk cannot fit")
}

private var turboquantMidPrefillHelp: String {
String(localized: "settings.experimental.turboquant.mid_prefill.sub",
defaultValue: "When a full prefill chunk cannot fit, convert the growing KV cache once and continue with TurboQuant. Requires this to be the only loaded model. Adds a one-time pause and may slow the rest of prefill.",
comment: "Help text for converting the TurboQuant KV cache once under prefill memory pressure")
}

private var specprefillSublabel: String {
Expand Down
14 changes: 13 additions & 1 deletion apps/omlx-mac/Sources/AppView/Screens/ProfileViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@

import SwiftUI

/// Profile-chip detail state for TurboQuant's opt-in mid-prefill conversion.
/// The child flag is meaningful only while its parent feature is enabled.
func turboquantMidPrefillDetailIsActive(_ settings: [String: AnyCodable]) -> Bool {
boolOf(settings[ProfileSettingsKey.turboquantKvEnabled]) == true
&& boolOf(settings[ProfileSettingsKey.turboquantMidPrefill]) == true
}

// MARK: - Scope colors / labels

/// Per-scope visual treatment. Lifted from omlx-screens.jsx:878-884
Expand Down Expand Up @@ -980,7 +987,12 @@ struct ProfileDetailCard: View {
defaultValue: "skip \(skip)",
comment: "TurboQuant skip-last-N suffix; placeholder is the layer count")
: nil
let parts = [bitsText, skipText].compactMap { $0 }
let midPrefillText = turboquantMidPrefillDetailIsActive(s)
? String(localized: "profile.detail.acceleration.turboquant.mid_prefill",
defaultValue: "prefill pressure",
comment: "TurboQuant profile-chip suffix indicating opt-in conversion when a full prefill chunk cannot fit")
: nil
let parts = [bitsText, skipText, midPrefillText].compactMap { $0 }
return parts.isEmpty
? baseName
: String(localized: "profile.detail.acceleration.turboquant.with_parts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ enum ProfileSettingsKey {
static let trustRemoteCode = "trust_remote_code"
static let turboquantKvEnabled = "turboquant_kv_enabled"
static let turboquantKvBits = "turboquant_kv_bits"
static let turboquantMidPrefill = "turboquant_mid_prefill"
static let indexCacheFreq = "index_cache_freq"
static let specprefillEnabled = "specprefill_enabled"
static let specprefillDraftModel = "specprefill_draft_model"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class ModelSettingsScreenVM {
case trustRemoteCode
case reasoningParser
case chatTemplateKwargs
case turboquantKvEnabled, turboquantKvBits
case turboquantKvEnabled, turboquantKvBits, turboquantMidPrefill
case indexCacheEnabled, indexCacheFreq
case specprefillEnabled, specprefillDraftModel, specprefillKeepPct, specprefillThreshold
case dflashEnabled, dflashDraftModel, dflashMaxCtx
Expand Down Expand Up @@ -253,6 +253,7 @@ final class ModelSettingsScreenVM {
// Experimental: TurboQuant KV
var turboquantKvEnabled: Bool = false
var turboquantKvBits: String = "4"
var turboquantMidPrefill: Bool = false

// Experimental: IndexCache (DSA-only)
var indexCacheEnabled: Bool = false
Expand Down Expand Up @@ -329,6 +330,10 @@ final class ModelSettingsScreenVM {
return Self.diffusionConfigModelTypes.contains(type)
}

var showsTurboquantMidPrefill: Bool {
!isDiffusionModel && turboquantKvEnabled
}

private func isDiffusionUnsupportedField(_ field: Field) -> Bool {
switch field {
case .topP, .topK, .minP, .repetitionPenalty, .presencePenalty:
Expand All @@ -339,7 +344,7 @@ final class ModelSettingsScreenVM {
return true
case .forceSampling, .reasoningParser:
return true
case .turboquantKvEnabled, .turboquantKvBits:
case .turboquantKvEnabled, .turboquantKvBits, .turboquantMidPrefill:
return true
case .indexCacheEnabled, .indexCacheFreq:
return true
Expand Down Expand Up @@ -462,6 +467,8 @@ final class ModelSettingsScreenVM {
)
self.turboquantKvEnabled = s?.turboquantKvEnabled ?? false
self.turboquantKvBits = s?.turboquantKvBits.map { Self.formatBits($0) } ?? "4"
self.turboquantMidPrefill = !self.isDiffusionModel
&& (s?.turboquantMidPrefill ?? false)
self.indexCacheEnabled = s?.indexCacheFreq != nil
self.indexCacheFreq = s?.indexCacheFreq.map(String.init) ?? "4"
self.specprefillEnabled = s?.specprefillEnabled ?? false
Expand Down Expand Up @@ -594,6 +601,8 @@ final class ModelSettingsScreenVM {
patch.forcedCtKwargs = pair.forced ?? []
case .turboquantKvEnabled: patch.turboquantKvEnabled = turboquantKvEnabled
case .turboquantKvBits: patch.turboquantKvBits = Double(turboquantKvBits)
case .turboquantMidPrefill:
patch.turboquantMidPrefill = turboquantMidPrefill
case .indexCacheEnabled:
patch.indexCacheFreq = indexCacheEnabled ? (Int(indexCacheFreq) ?? 4) : 0
case .indexCacheFreq:
Expand Down Expand Up @@ -886,6 +895,10 @@ final class ModelSettingsScreenVM {
if turboquantKvEnabled, let bits = Double(turboquantKvBits) {
out[ProfileSettingsKey.turboquantKvBits] = AnyCodable(bits)
}
putBool(
ProfileSettingsKey.turboquantMidPrefill,
turboquantMidPrefill
)
if indexCacheEnabled, let n = Int(indexCacheFreq), n >= 2 {
out[ProfileSettingsKey.indexCacheFreq] = AnyCodable(n)
}
Expand Down
2 changes: 2 additions & 0 deletions apps/omlx-mac/Sources/Net/DTO/ModelsDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ struct ModelSettingsDTO: Codable, Equatable, Sendable {
// Experimental: TurboQuant KV cache
let turboquantKvEnabled: Bool?
let turboquantKvBits: Double?
let turboquantMidPrefill: Bool?
// Experimental: IndexCache (DSA models only)
let indexCacheFreq: Int?
// Experimental: SpecPrefill
Expand Down Expand Up @@ -166,6 +167,7 @@ struct ModelSettingsPatch: Encodable, Equatable, Sendable {
// Experimental: TurboQuant KV
var turboquantKvEnabled: Bool? = nil
var turboquantKvBits: Double? = nil
var turboquantMidPrefill: Bool? = nil
// Experimental: IndexCache
var indexCacheFreq: Int? = nil
// Experimental: SpecPrefill
Expand Down
36 changes: 36 additions & 0 deletions apps/omlx-mac/Tests/oMLXTests/DTOFixtureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,52 @@ final class DTOFixtureTests: XCTestCase {
if let first = list.models.first {
XCTAssertFalse(first.id.isEmpty, "ModelDTO.id must be non-empty.")
XCTAssertEqual(first.displayName, "deepsweet/Qwen3.6-27B-UD-MLX-4bit")
XCTAssertEqual(first.settings?.turboquantMidPrefill, false)
}
}

func testTurboquantMidPrefillResponseAllowsMissingField() throws {
let data = Data(#"{}"#.utf8)

let settings = try Self.makeDecoder().decode(ModelSettingsDTO.self, from: data)

XCTAssertNil(settings.turboquantMidPrefill)
}

func testTurboquantMidPrefillResponseDecodesPresentTrue() throws {
let data = Data(#"{"turboquant_mid_prefill":true}"#.utf8)

let settings = try Self.makeDecoder().decode(ModelSettingsDTO.self, from: data)

XCTAssertEqual(settings.turboquantMidPrefill, true)
}

func testTurboquantMidPrefillPatchUsesExactWireKey() throws {
var patch = ModelSettingsPatch()
patch.turboquantMidPrefill = true
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase

let body = try XCTUnwrap(
JSONSerialization.jsonObject(with: encoder.encode(patch)) as? [String: Any]
)

XCTAssertEqual(body.count, 1)
XCTAssertEqual(body["turboquant_mid_prefill"] as? Bool, true)
XCTAssertNil(body["turboquantMidPrefill"])
}

// MARK: - Profile list (per-model)

func testModelProfilesFixtureDecodes() throws {
let data = try fixture("model-profiles")
let resp = try Self.makeDecoder().decode(ProfileListResponse.self, from: data)
XCTAssertNotNil(resp.profiles,
"Profiles array must be present even when empty.")
XCTAssertEqual(
resp.profiles.first?.settings?[ProfileSettingsKey.turboquantMidPrefill]?.value as? Bool,
false
)
}

// MARK: - Profile templates
Expand Down
1 change: 1 addition & 0 deletions apps/omlx-mac/Tests/oMLXTests/Fixtures/model-profiles.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"max_context_window": 131072,
"force_sampling": false,
"turboquant_kv_enabled": false,
"turboquant_mid_prefill": false,
"dflash_enabled": false,
"max_tokens": 262144,
"thinking_budget_tokens": 8192,
Expand Down
1 change: 1 addition & 0 deletions apps/omlx-mac/Tests/oMLXTests/Fixtures/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"reasoning_parser": null,
"turboquant_kv_enabled": false,
"turboquant_kv_bits": 4.0,
"turboquant_mid_prefill": false,
"turboquant_skip_last": true,
"specprefill_enabled": false,
"specprefill_draft_model": null,
Expand Down
3 changes: 3 additions & 0 deletions apps/omlx-mac/Tests/oMLXTests/LocalizationSmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ final class LocalizationSmokeTests: XCTestCase {
"bench.context.header.title", "bench.context.section.configuration",
// Settings + helpers
"settings.section.basic", "settings.advanced.experimental.section",
"settings.experimental.turboquant.mid_prefill.label",
"settings.experimental.turboquant.mid_prefill.sub",
"profile.detail.acceleration.turboquant.mid_prefill",
"appearance.row.menubar_icon", "appearance.row.menubar_icon.restore",
// Menubar + updates
"menubar.item.quit", "menubar.stats.session_section",
Expand Down
67 changes: 67 additions & 0 deletions apps/omlx-mac/Tests/oMLXTests/ModelSettingsScreenVMTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,73 @@ final class ModelSettingsScreenVMTests: XCTestCase {
XCTAssertFalse(values.contains("model-MTPLX-runtime"))
}

func testTurboquantMidPrefillDefaultsFalseAndHidden() {
let vm = ModelSettingsScreenVM()

XCTAssertFalse(vm.turboquantMidPrefill)
XCTAssertFalse(vm.showsTurboquantMidPrefill)
XCTAssertEqual(ProfileSettingsKey.turboquantMidPrefill, "turboquant_mid_prefill")
}

func testTurboquantMidPrefillProfileStateRoundTripsWhenParentEnabled() {
let vm = ModelSettingsScreenVM()
vm.model = makeModel(id: "text-model", configModelType: "qwen3_5")
vm.turboquantKvEnabled = true
vm.turboquantMidPrefill = true

let settings = vm.currentSettingsDict()

XCTAssertTrue(vm.showsTurboquantMidPrefill)
XCTAssertEqual(
settings["turboquant_mid_prefill"]?.value as? Bool,
true
)
XCTAssertTrue(turboquantMidPrefillDetailIsActive(settings))
}

func testTurboquantMidPrefillStaysSavedButInertWhenParentDisabled() {
let vm = ModelSettingsScreenVM()
vm.model = makeModel(id: "text-model", configModelType: "qwen3_5")
vm.turboquantKvEnabled = false
vm.turboquantMidPrefill = true

let settings = vm.currentSettingsDict()

XCTAssertFalse(vm.showsTurboquantMidPrefill)
XCTAssertEqual(
settings["turboquant_mid_prefill"]?.value as? Bool,
true
)
XCTAssertFalse(turboquantMidPrefillDetailIsActive(settings))
}

func testTurboquantMidPrefillIsExcludedForDiffusion() {
let vm = ModelSettingsScreenVM()
vm.model = makeModel(id: "diffusion-model", configModelType: "diffusion_gemma")
vm.turboquantKvEnabled = true
vm.turboquantMidPrefill = true

let settings = vm.currentSettingsDict()

XCTAssertFalse(vm.showsTurboquantMidPrefill)
XCTAssertNil(settings["turboquant_mid_prefill"])
XCTAssertFalse(turboquantMidPrefillDetailIsActive(settings))
}

func testTurboquantProfileDetailRequiresBothFlags() {
let parentOnly: [String: AnyCodable] = [
ProfileSettingsKey.turboquantKvEnabled: AnyCodable(true),
ProfileSettingsKey.turboquantMidPrefill: AnyCodable(false),
]
let childOnly: [String: AnyCodable] = [
ProfileSettingsKey.turboquantKvEnabled: AnyCodable(false),
ProfileSettingsKey.turboquantMidPrefill: AnyCodable(true),
]

XCTAssertFalse(turboquantMidPrefillDetailIsActive(parentOnly))
XCTAssertFalse(turboquantMidPrefillDetailIsActive(childOnly))
}

private func makeModel(id: String, configModelType: String?) -> ModelDTO {
ModelDTO(
id: id,
Expand Down
Loading