diff --git a/apps/omlx-mac/Resources/Localizable.xcstrings b/apps/omlx-mac/Resources/Localizable.xcstrings index 5d23f35c2..b009a7f39 100644 --- a/apps/omlx-mac/Resources/Localizable.xcstrings +++ b/apps/omlx-mac/Resources/Localizable.xcstrings @@ -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", @@ -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" : { diff --git a/apps/omlx-mac/Sources/AppView/Screens/ModelSettingsScreen.swift b/apps/omlx-mac/Sources/AppView/Screens/ModelSettingsScreen.swift index bd9f05e57..dfd9dd1a9 100644 --- a/apps/omlx-mac/Sources/AppView/Screens/ModelSettingsScreen.swift +++ b/apps/omlx-mac/Sources/AppView/Screens/ModelSettingsScreen.swift @@ -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). @@ -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 { diff --git a/apps/omlx-mac/Sources/AppView/Screens/ProfileViews.swift b/apps/omlx-mac/Sources/AppView/Screens/ProfileViews.swift index 077d8cf34..fa5ea22da 100644 --- a/apps/omlx-mac/Sources/AppView/Screens/ProfileViews.swift +++ b/apps/omlx-mac/Sources/AppView/Screens/ProfileViews.swift @@ -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 @@ -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", diff --git a/apps/omlx-mac/Sources/AppView/Utils/ProfileWorkingState.swift b/apps/omlx-mac/Sources/AppView/Utils/ProfileWorkingState.swift index fe262bcf6..e1b9c5f32 100644 --- a/apps/omlx-mac/Sources/AppView/Utils/ProfileWorkingState.swift +++ b/apps/omlx-mac/Sources/AppView/Utils/ProfileWorkingState.swift @@ -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" diff --git a/apps/omlx-mac/Sources/AppView/ViewModels/ModelSettingsScreenVM.swift b/apps/omlx-mac/Sources/AppView/ViewModels/ModelSettingsScreenVM.swift index 060002c11..29fd26ab5 100644 --- a/apps/omlx-mac/Sources/AppView/ViewModels/ModelSettingsScreenVM.swift +++ b/apps/omlx-mac/Sources/AppView/ViewModels/ModelSettingsScreenVM.swift @@ -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 @@ -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 @@ -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: @@ -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 @@ -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 @@ -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: @@ -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) } diff --git a/apps/omlx-mac/Sources/Net/DTO/ModelsDTO.swift b/apps/omlx-mac/Sources/Net/DTO/ModelsDTO.swift index 876d6d73a..30aff6610 100644 --- a/apps/omlx-mac/Sources/Net/DTO/ModelsDTO.swift +++ b/apps/omlx-mac/Sources/Net/DTO/ModelsDTO.swift @@ -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 @@ -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 diff --git a/apps/omlx-mac/Tests/oMLXTests/DTOFixtureTests.swift b/apps/omlx-mac/Tests/oMLXTests/DTOFixtureTests.swift index 78c84752f..04e67dcae 100644 --- a/apps/omlx-mac/Tests/oMLXTests/DTOFixtureTests.swift +++ b/apps/omlx-mac/Tests/oMLXTests/DTOFixtureTests.swift @@ -125,9 +125,41 @@ 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 { @@ -135,6 +167,10 @@ final class DTOFixtureTests: XCTestCase { 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 diff --git a/apps/omlx-mac/Tests/oMLXTests/Fixtures/model-profiles.json b/apps/omlx-mac/Tests/oMLXTests/Fixtures/model-profiles.json index 59e1c1522..826ed9b48 100644 --- a/apps/omlx-mac/Tests/oMLXTests/Fixtures/model-profiles.json +++ b/apps/omlx-mac/Tests/oMLXTests/Fixtures/model-profiles.json @@ -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, diff --git a/apps/omlx-mac/Tests/oMLXTests/Fixtures/models.json b/apps/omlx-mac/Tests/oMLXTests/Fixtures/models.json index a1cfdbe87..5fee67423 100644 --- a/apps/omlx-mac/Tests/oMLXTests/Fixtures/models.json +++ b/apps/omlx-mac/Tests/oMLXTests/Fixtures/models.json @@ -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, diff --git a/apps/omlx-mac/Tests/oMLXTests/LocalizationSmokeTests.swift b/apps/omlx-mac/Tests/oMLXTests/LocalizationSmokeTests.swift index c2eddcfb0..e8993f54f 100644 --- a/apps/omlx-mac/Tests/oMLXTests/LocalizationSmokeTests.swift +++ b/apps/omlx-mac/Tests/oMLXTests/LocalizationSmokeTests.swift @@ -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", diff --git a/apps/omlx-mac/Tests/oMLXTests/ModelSettingsScreenVMTests.swift b/apps/omlx-mac/Tests/oMLXTests/ModelSettingsScreenVMTests.swift index dc5321dd2..03f7c1b8a 100644 --- a/apps/omlx-mac/Tests/oMLXTests/ModelSettingsScreenVMTests.swift +++ b/apps/omlx-mac/Tests/oMLXTests/ModelSettingsScreenVMTests.swift @@ -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, diff --git a/omlx/admin/benchmark.py b/omlx/admin/benchmark.py index 3e754d7a3..8d3156ef2 100644 --- a/omlx/admin/benchmark.py +++ b/omlx/admin/benchmark.py @@ -301,6 +301,7 @@ def _derive_feature_flags(model_settings: Any) -> list[dict]: "model_type_override", "index_cache_freq", "turboquant_kv_enabled", + "turboquant_mid_prefill", "turboquant_kv_bits", "turboquant_skip_last", "specprefill_enabled", @@ -361,13 +362,15 @@ def _filter_uploaded_settings(model_settings: Any) -> Optional[dict]: if len(json.dumps(filtered, separators=(",", ":"))) > _MAX_UPLOADED_SETTINGS_BYTES: logger.warning( "Benchmark: model settings snapshot exceeded " - f"{_MAX_UPLOADED_SETTINGS_BYTES} bytes, uploading accelerator flags only" + f"{_MAX_UPLOADED_SETTINGS_BYTES} bytes, uploading accelerator provenance only" ) filtered = { spec.attr: filtered[spec.attr] for spec in _FEATURE_FLAG_SPECS if spec.attr in filtered } + if "turboquant_mid_prefill" in raw: + filtered["turboquant_mid_prefill"] = bool(raw["turboquant_mid_prefill"]) return filtered diff --git a/omlx/admin/i18n/en.json b/omlx/admin/i18n/en.json index 036d6140d..bf1db36cf 100644 --- a/omlx/admin/i18n/en.json +++ b/omlx/admin/i18n/en.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "Truncate large tool results (e.g. file reads) to a token limit", "modal.model_settings.limit_tool_placeholder": "e.g. 2000", "modal.model_settings.turboquant_kv": "TurboQuant KV Cache", - "modal.model_settings.turboquant_kv_hint": "Compress KV cache using vector quantization. Lower bits = more compression, higher bits = better quality. Supports 2 to 8 bits.", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "Bits per channel", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "Drafts several tokens per step with the model's built-in MTP head. Up to ~1.5x faster decoding. (Qwen 3.5/3.6, DeepSeek-V4, GLM-5.2)", diff --git a/omlx/admin/i18n/es.json b/omlx/admin/i18n/es.json index 7c6ff757d..0b095b73d 100644 --- a/omlx/admin/i18n/es.json +++ b/omlx/admin/i18n/es.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "Recortar resultados grandes de herramientas (ej. lecturas de archivos) a un límite de tokens", "modal.model_settings.limit_tool_placeholder": "ej. 2000", "modal.model_settings.turboquant_kv": "Caché KV TurboQuant", - "modal.model_settings.turboquant_kv_hint": "Comprimir caché KV usando cuantización vectorial. Bits más bajos = más compresión, bits más altos = mejor calidad. Soporta de 2 a 8 bits.", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "Bits por canal", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "Drafts several tokens per step with the model's built-in MTP head. Up to ~1.5x faster decoding. (Qwen 3.5/3.6, DeepSeek-V4, GLM-5.2)", diff --git a/omlx/admin/i18n/fr.json b/omlx/admin/i18n/fr.json index 9124fef9a..3e65961d2 100644 --- a/omlx/admin/i18n/fr.json +++ b/omlx/admin/i18n/fr.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "Tronquez les résultats d'outil volumineux (par ex. lectures de fichiers) à une limite de tokens", "modal.model_settings.limit_tool_placeholder": "par ex. 2000", "modal.model_settings.turboquant_kv": "Cache KV TurboQuant", - "modal.model_settings.turboquant_kv_hint": "Compressez le cache KV en utilisant la quantification vectorielle. Bits inférieurs = plus de compression, bits supérieurs = meilleure qualité. Supporte 2 à 8 bits.", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "Bits par canal", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "Drafts several tokens per step with the model's built-in MTP head. Up to ~1.5x faster decoding. (Qwen 3.5/3.6, DeepSeek-V4, GLM-5.2)", diff --git a/omlx/admin/i18n/ja.json b/omlx/admin/i18n/ja.json index bc4eb963e..1fc1aa84b 100644 --- a/omlx/admin/i18n/ja.json +++ b/omlx/admin/i18n/ja.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "大きなツール結果(ファイル読み込みなど)をトークン制限に合わせて切り詰めます", "modal.model_settings.limit_tool_placeholder": "例: 2000", "modal.model_settings.turboquant_kv": "TurboQuant KV Cache", - "modal.model_settings.turboquant_kv_hint": "Compress KV cache using vector quantization. Reduces memory ~60-75% with near-lossless quality for long context.", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "Bits per channel", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "Drafts several tokens per step with the model's built-in MTP head. Up to ~1.5x faster decoding. (Qwen 3.5/3.6, DeepSeek-V4, GLM-5.2)", diff --git a/omlx/admin/i18n/ko.json b/omlx/admin/i18n/ko.json index d1ab9c9d6..c5b258fbf 100644 --- a/omlx/admin/i18n/ko.json +++ b/omlx/admin/i18n/ko.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "Tool calling(예: 파일 읽기) 결과를 정해진 크기로 자릅니다", "modal.model_settings.limit_tool_placeholder": "예: 2000", "modal.model_settings.turboquant_kv": "TurboQuant KV Cache", - "modal.model_settings.turboquant_kv_hint": "Compress KV cache using vector quantization. Reduces memory ~60-75% with near-lossless quality for long context.", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "Bits per channel", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "Drafts several tokens per step with the model's built-in MTP head. Up to ~1.5x faster decoding. (Qwen 3.5/3.6, DeepSeek-V4, GLM-5.2)", diff --git a/omlx/admin/i18n/pt-BR.json b/omlx/admin/i18n/pt-BR.json index 185a54ead..e991c0af8 100644 --- a/omlx/admin/i18n/pt-BR.json +++ b/omlx/admin/i18n/pt-BR.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "Truncar resultados grandes de ferramentas (ex: leituras de arquivo) para um limite de tokens", "modal.model_settings.limit_tool_placeholder": "ex: 2000", "modal.model_settings.turboquant_kv": "Cache KV TurboQuant", - "modal.model_settings.turboquant_kv_hint": "Comprimir o cache KV usando quantização vetorial. Menos bits = mais compressão, mais bits = melhor qualidade. Suporta de 2 a 8 bits.", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "Bits por canal", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "Drafts several tokens per step with the model's built-in MTP head. Up to ~1.5x faster decoding. (Qwen 3.5/3.6, DeepSeek-V4, GLM-5.2)", diff --git a/omlx/admin/i18n/ru.json b/omlx/admin/i18n/ru.json index b5f01f3b0..82b9ab942 100644 --- a/omlx/admin/i18n/ru.json +++ b/omlx/admin/i18n/ru.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "Обрезать большие результаты инструментов, например чтение файлов, до лимита токенов", "modal.model_settings.limit_tool_placeholder": "например, 2000", "modal.model_settings.turboquant_kv": "TurboQuant KV-кэш", - "modal.model_settings.turboquant_kv_hint": "Сжимайте KV-кэш с помощью векторного квантования. Меньшее число бит = сильнее сжатие, большее = лучше качество. Поддерживает от 2 до 8 бит.", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "Битов на канал", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "Использует встроенную MTP-голову модели для черновой генерации нескольких токенов за шаг. Декодирование до ~1,5× быстрее. (Qwen 3.5/3.6, DeepSeek-V4, GLM-5.2)", diff --git a/omlx/admin/i18n/zh-TW.json b/omlx/admin/i18n/zh-TW.json index b270e0a8a..4eed7d94d 100644 --- a/omlx/admin/i18n/zh-TW.json +++ b/omlx/admin/i18n/zh-TW.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "將過長的回傳結果(如檔案讀取)截斷至指定 Token 數量上限", "modal.model_settings.limit_tool_placeholder": "例如 2000", "modal.model_settings.turboquant_kv": "TurboQuant KV Cache", - "modal.model_settings.turboquant_kv_hint": "使用向量量化壓縮 KV 快取。較低位元 = 更多壓縮,較高位元 = 更佳品質。支援 2 到 8 位元。", + "modal.model_settings.turboquant_kv_hint": "Compress the KV cache with vector quantization after prefill for generation. Ordinary TurboQuant does not convert during cold prefill. Lower bits use less memory; higher bits preserve more quality.", + "modal.model_settings.turboquant_mid_prefill": "Convert under prefill pressure", + "modal.model_settings.turboquant_mid_prefill_hint": "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.", "modal.model_settings.turboquant_kv_bits_label": "每通道位元數", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "使用模型內建的 MTP 頭,每步產生多個 token。解碼速度最快可達約 1.5 倍。(Qwen 3.5/3.6、DeepSeek-V4、GLM-5.2)", diff --git a/omlx/admin/i18n/zh.json b/omlx/admin/i18n/zh.json index 234ae23f9..3faf888ae 100644 --- a/omlx/admin/i18n/zh.json +++ b/omlx/admin/i18n/zh.json @@ -559,7 +559,9 @@ "modal.model_settings.limit_tool_result_hint": "截断过长的工具结果(如文件读取)到 Token 限制", "modal.model_settings.limit_tool_placeholder": "例如 2000", "modal.model_settings.turboquant_kv": "TurboQuant KV 缓存", - "modal.model_settings.turboquant_kv_hint": "使用向量量化压缩 KV Cache。位数越低压缩越多,位数越高质量越好。支持 2 到 8 位。", + "modal.model_settings.turboquant_kv_hint": "在预填充完成后,使用向量量化压缩生成阶段的 KV 缓存。普通 TurboQuant 不会在冷预填充期间转换缓存。位数越低,内存占用越少;位数越高,质量保留越好。", + "modal.model_settings.turboquant_mid_prefill": "预填充内存压力下转换", + "modal.model_settings.turboquant_mid_prefill_hint": "当完整的预填充块无法放入内存时,将增长中的 KV 缓存转换一次,然后使用 TurboQuant 继续预填充。此功能要求它是唯一已加载的模型,会造成一次暂停,并可能减慢剩余的预填充。", "modal.model_settings.turboquant_kv_bits_label": "每通道位数", "modal.model_settings.lightning_mtp": "Lightning MTP", "modal.model_settings.lightning_mtp_hint": "使用模型内置 MTP 头每步预测多个 Token。解码速度提升约 1.5 倍。(Qwen 3.5/3.6、DeepSeek-V4、GLM-5.2)", diff --git a/omlx/admin/oq_manager.py b/omlx/admin/oq_manager.py index 835a7395b..713779b40 100644 --- a/omlx/admin/oq_manager.py +++ b/omlx/admin/oq_manager.py @@ -12,21 +12,38 @@ import logging import time import uuid +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Optional +from typing import Optional try: import mlx.core as mx - HAS_MLX = True + HAS_MLX = mx is not None except ImportError: HAS_MLX = False from ..model_discovery import _has_vision_subconfig +from ..utils.metal_sync import _conversion_coordinator, _sync_and_clear_cache logger = logging.getLogger(__name__) +def _run_process_guarded_metal( + fn: Callable[..., object], + /, + *args: object, + **kwargs: object, +) -> object: + """Run independent oQ Metal work only when no mid-prefill owner exists.""" + with _conversion_coordinator.background_metal_operation(): + if HAS_MLX: + _sync_and_clear_cache() + try: + return fn(*args, **kwargs) + finally: + if HAS_MLX: + _sync_and_clear_cache() class _QuantCancelled(Exception): """Raised by progress callback when task is cancelled.""" @@ -479,15 +496,6 @@ async def cancel_quantization(self, task_id: str) -> bool: except (asyncio.CancelledError, Exception): pass - # GPU cleanup after thread is done - if HAS_MLX: - for _attempt in range(3): - try: - mx.synchronize() - mx.clear_cache() - break - except Exception: - await asyncio.sleep(1.0) logger.info(f"oQ quantization cancelled: {task.model_name} (task_id={task_id})") return True @@ -525,16 +533,6 @@ async def _run_quantization(self, task_id: str) -> None: if task_id in self._cancelled: return - # Ensure GPU is clean before starting (previous task may have been cancelled) - # Metal command buffers need full sync + cache clear after cancellation - if HAS_MLX: - for _ in range(3): - try: - mx.synchronize() - mx.clear_cache() - break - except Exception: - await asyncio.sleep(1.0) # Phase 1: Loading task.status = QuantStatus.LOADING @@ -571,6 +569,7 @@ def _progress_cb( from ..oq import quantize_oq_streaming await asyncio.to_thread( + _run_process_guarded_metal, quantize_oq_streaming, task.model_path, task.output_path, @@ -600,6 +599,7 @@ def _progress_cb( _progress_cb("saving", 97.0, "Merging MTP head...") await asyncio.to_thread( + _run_process_guarded_metal, combine_mtp_into_output, task.output_path, task.mtp_assistant_model_path, diff --git a/omlx/admin/routes.py b/omlx/admin/routes.py index e41f168dc..f73df7619 100644 --- a/omlx/admin/routes.py +++ b/omlx/admin/routes.py @@ -126,6 +126,7 @@ class ModelSettingsRequest(BaseModel): thinking_budget_tokens: int | None = None # TurboQuant KV cache (mlx-vlm backend) turboquant_kv_enabled: bool | None = None + turboquant_mid_prefill: bool | None = None turboquant_kv_bits: float | None = None # SpecPrefill (experimental) specprefill_enabled: bool | None = None @@ -516,6 +517,7 @@ def _sanitize_diffusion_settings_dict(settings: dict) -> None: settings["thinking_budget_enabled"] = False settings["guided_grammar_enabled"] = False settings["turboquant_kv_enabled"] = False + settings["turboquant_mid_prefill"] = False settings["turboquant_kv_bits"] = 4 settings["turboquant_skip_last"] = True settings["specprefill_enabled"] = False @@ -591,6 +593,7 @@ def _sanitize_diffusion_model_settings(settings) -> None: settings.index_cache_freq = None settings.turboquant_kv_enabled = False + settings.turboquant_mid_prefill = False settings.turboquant_kv_bits = 4 settings.turboquant_skip_last = True settings.specprefill_enabled = False @@ -2238,6 +2241,10 @@ async def update_model_settings( # TurboQuant KV cache settings if "turboquant_kv_enabled" in sent: current_settings.turboquant_kv_enabled = request.turboquant_kv_enabled or False + if "turboquant_mid_prefill" in sent: + current_settings.turboquant_mid_prefill = ( + request.turboquant_mid_prefill or False + ) if "turboquant_kv_bits" in sent: current_settings.turboquant_kv_bits = request.turboquant_kv_bits or 4 # SpecPrefill settings diff --git a/omlx/admin/static/js/dashboard.js b/omlx/admin/static/js/dashboard.js index 2afb9e610..a477d61e6 100644 --- a/omlx/admin/static/js/dashboard.js +++ b/omlx/admin/static/js/dashboard.js @@ -25,6 +25,7 @@ 'max_tool_result_tokens', 'index_cache_freq', 'turboquant_kv_enabled', + 'turboquant_mid_prefill', 'turboquant_kv_bits', 'turboquant_skip_last', 'specprefill_enabled', @@ -1156,6 +1157,10 @@ } continue; } + if (k === 'turboquant_mid_prefill') { + out.turboquant_mid_prefill = !!ms.turboquant_mid_prefill; + continue; + } if (k === 'guided_grammar_enabled') { out.guided_grammar_enabled = !!ms.guided_grammar_enabled; continue; @@ -1516,6 +1521,7 @@ enableIndexCache: !!(s.index_cache_freq), index_cache_freq: s.index_cache_freq || null, turboquant_kv_enabled: s.turboquant_kv_enabled || false, + turboquant_mid_prefill: s.turboquant_mid_prefill || false, turboquant_kv_bits: s.turboquant_kv_bits || 4, specprefill_enabled: s.specprefill_enabled || false, specprefill_draft_model: s.specprefill_draft_model || '', @@ -2033,6 +2039,7 @@ forced_ct_kwargs: forcedCtKwargs.length > 0 ? forcedCtKwargs : null, turboquant_kv_enabled: this.modelSettings.turboquant_kv_enabled, + turboquant_mid_prefill: !!this.modelSettings.turboquant_mid_prefill, turboquant_kv_bits: this.modelSettings.turboquant_kv_enabled ? (parseFloat(this.modelSettings.turboquant_kv_bits) || 4) : 4, @@ -2117,6 +2124,7 @@ guided_grammar: null, max_tool_result_tokens: 0, turboquant_kv_enabled: false, + turboquant_mid_prefill: false, turboquant_kv_bits: 4, specprefill_enabled: false, specprefill_draft_model: null, @@ -2205,6 +2213,7 @@ this.modelSettings.max_tool_result_tokens = 0; this.modelSettings.ctKwargEntries = []; this.modelSettings.turboquant_kv_enabled = false; + this.modelSettings.turboquant_mid_prefill = false; this.modelSettings.turboquant_kv_bits = 4; this.modelSettings.specprefill_enabled = false; this.modelSettings.specprefill_draft_model = null; diff --git a/omlx/admin/templates/dashboard/_modal_model_settings.html b/omlx/admin/templates/dashboard/_modal_model_settings.html index 88c1cd1ed..01b17bb73 100644 --- a/omlx/admin/templates/dashboard/_modal_model_settings.html +++ b/omlx/admin/templates/dashboard/_modal_model_settings.html @@ -695,10 +695,14 @@

{{
- {{ t('modal.model_settings.turboquant_kv') }} -

{{ t('modal.model_settings.turboquant_kv_hint') }}

+ {{ t('modal.model_settings.turboquant_kv') }} +

{{ t('modal.model_settings.turboquant_kv_hint') }}

-
- - @@ -723,6 +729,24 @@

{{ +
+
+ {{ t('modal.model_settings.turboquant_mid_prefill') }} +

{{ t('modal.model_settings.turboquant_mid_prefill_hint') }}

+
+ +

diff --git a/omlx/cache/paged_ssd_cache.py b/omlx/cache/paged_ssd_cache.py index 57a2fb7ac..9c06d9e8d 100644 --- a/omlx/cache/paged_ssd_cache.py +++ b/omlx/cache/paged_ssd_cache.py @@ -1491,6 +1491,22 @@ def __init__( f"existing_files={self._index.count}{disk_info}" ) + def set_expected_block_payload_bytes(self, payload_bytes: int) -> None: + """Resize the writer queue from one conservative serialized block size.""" + if payload_bytes <= 0: + raise ValueError("expected block payload must be positive") + block_tokens = max(1, int(self._expected_block_size_tokens)) + per_token = max(1, (int(payload_bytes) + block_tokens - 1) // block_tokens) + cap = _compute_max_pending_writes( + block_size_tokens=block_tokens, + kv_bytes_per_token=per_token, + ) + with self._write_queue.mutex: + self._expected_kv_bytes_per_token = per_token + self._max_pending_writes = cap + self._write_queue.maxsize = cap + self._write_queue.not_full.notify_all() + # --- Hot cache helpers --- @staticmethod @@ -2289,7 +2305,6 @@ def save_block( file_path = self._get_file_path(block_hash) try: - # Prepare arrays for safetensors. Three layer_data shapes are # accepted: # - ``('__nstate__', class_name, [elem0, elem1, ...])`` — V3 @@ -2305,9 +2320,7 @@ def save_block( # etc.) has been migrated to emit ``__nstate__`` markers yet. arrays = {} has_pooling_cache_delta = False - cache_list_meta = ( - {} - ) # Per-layer sidecar metadata (sub_count, state_count, etc.) + cache_list_meta = {} # Per-layer sidecar metadata (sub_count, state_count, etc.) # Shim; module-level to avoid a recursive-closure refcount # cycle pinning `arrays` — see _store_nstate_elements_flat. @@ -2987,8 +3000,7 @@ def load_block_with_metadata( self._stats["hits"] += 1 self._stats["hot_cache_hits"] += 1 logger.debug( - f"Loaded block with metadata from hot cache: " - f"{block_hash.hex()[:16]}..." + f"Loaded block with metadata from hot cache: {block_hash.hex()[:16]}..." ) return cache_data, metadata_dict @@ -3326,17 +3338,13 @@ def set_expected_layer_signature( new_signature = list(layer_cache_types) new_canonical = _canonicalize_layer_cache_types(new_signature) - new_bits = ( - float(turboquant_kv_bits) if turboquant_kv_bits is not None else None - ) + new_bits = float(turboquant_kv_bits) if turboquant_kv_bits is not None else None with self._lock: old_signature = self._expected_layer_cache_types old_canonical = _canonicalize_layer_cache_types(old_signature) bits_changed = new_bits != self._expected_turboquant_kv_bits - subtypes_changed = ( - cachelist_subtypes != self._expected_cachelist_subtypes - ) + subtypes_changed = cachelist_subtypes != self._expected_cachelist_subtypes if ( old_canonical == new_canonical and not bits_changed diff --git a/omlx/engine/base.py b/omlx/engine/base.py index 5419b9abd..8a1255025 100644 --- a/omlx/engine/base.py +++ b/omlx/engine/base.py @@ -71,7 +71,7 @@ async def _run_scheduler_preflight_with_cleanup_retry( request_id: str | None, eviction_callback: Any | None, executor: Any | None = None, -) -> None: +) -> bool: """Run route preflight after transient post-request cleanup settles. A finished request can remain resident while its asynchronous cache store @@ -86,6 +86,7 @@ async def _run_scheduler_preflight_with_cleanup_retry( """ deadline = time.monotonic() + _PREFLIGHT_CLEANUP_WAIT_TIMEOUT_S waited_for_cleanup = False + callback_attempted = False while True: eviction_request = scheduler.preflight_eviction_request( @@ -97,7 +98,7 @@ async def _run_scheduler_preflight_with_cleanup_retry( num_prompt_tokens=num_prompt_tokens, request_id=request_id, ) - return + return callback_attempted cleanup_pending_fn = getattr( scheduler, "has_pending_route_preflight_cleanup", None @@ -146,12 +147,13 @@ async def _run_scheduler_preflight_with_cleanup_retry( "Running preflight LRU eviction for request %s", eviction_request.request_id, ) + callback_attempted = True await eviction_callback(eviction_request) scheduler.preflight_or_raise( num_prompt_tokens=num_prompt_tokens, request_id=request_id, ) - return + return callback_attempted @dataclass @@ -410,7 +412,7 @@ async def preflight_chat( tools: Optional[list] = None, request_id: Optional[str] = None, **kwargs, - ) -> None: + ) -> bool | None: """Optional prefill-memory preflight check for chat requests. Default no-op; engines that implement the prefill memory guard @@ -418,6 +420,10 @@ async def preflight_chat( actual estimation logic. The base no-op lets simpler engines (SimpleEngine, embedding/reranker engines, test stubs) be invoked from the server endpoints without additional wrapping. + + An implementation may return ``True`` when route preflight actually + invoked an eviction callback and admission must consume that request's + retry budget. ``None`` retains the legacy no-op contract. """ return None @@ -426,10 +432,12 @@ async def preflight_completion( prompt: str, request_id: Optional[str] = None, **kwargs, - ) -> None: + ) -> bool | None: """Optional prefill-memory preflight check for completion requests. - See :meth:`preflight_chat` for the rationale. + Implementations may report a route-level eviction callback attempt + using the same request-local contract as :meth:`preflight_chat`. + ``None`` retains the legacy no-op behavior. """ return None diff --git a/omlx/engine/batched.py b/omlx/engine/batched.py index 8eaaf2e54..bb334d827 100644 --- a/omlx/engine/batched.py +++ b/omlx/engine/batched.py @@ -25,6 +25,24 @@ logger = logging.getLogger(__name__) +def _configure_turboquant_scheduler(scheduler: Any, model_settings: Any | None) -> None: + """Propagate effective TurboQuant settings to a newly-created scheduler.""" + turboquant_active = bool(getattr(model_settings, "turboquant_kv_enabled", False)) + scheduler._turboquant_mid_prefill = turboquant_active and bool( + getattr(model_settings, "turboquant_mid_prefill", False) + ) + if not turboquant_active: + return + + scheduler._turboquant_kv_bits = float( + getattr(model_settings, "turboquant_kv_bits", 4) + ) + scheduler._turboquant_skip_last = getattr( + model_settings, "turboquant_skip_last", True + ) + scheduler._set_model_info_for_monitor() + + # Optional Harmony adapter import try: from ..adapter.harmony import preprocess_harmony_messages @@ -85,8 +103,8 @@ async def _preflight_or_raise_with_eviction( *, num_prompt_tokens: int, request_id: str | None, - ) -> None: - await _run_scheduler_preflight_with_cleanup_retry( + ) -> bool: + callback_attempted = await _run_scheduler_preflight_with_cleanup_retry( scheduler, num_prompt_tokens=num_prompt_tokens, request_id=request_id, @@ -97,6 +115,7 @@ async def _preflight_or_raise_with_eviction( None, ), ) + return callback_attempted @property def model_name(self) -> str: @@ -433,17 +452,12 @@ def _load_model_sync(): await self._engine.engine.start() - # TurboQuant KV cache: propagate bits to scheduler + # TurboQuant KV cache: propagate effective settings to scheduler scheduler = self._engine.engine.scheduler - if self._model_settings is not None: - tq_enabled = getattr(self._model_settings, "turboquant_kv_enabled", False) - if tq_enabled: - tq_bits = float(getattr(self._model_settings, "turboquant_kv_bits", 4)) - scheduler._turboquant_kv_bits = tq_bits - scheduler._turboquant_skip_last = getattr( - self._model_settings, "turboquant_skip_last", True - ) - scheduler._set_model_info_for_monitor() + scheduler._prefill_eviction_callback_configured = ( + self._prefill_eviction_callback is not None + ) + _configure_turboquant_scheduler(scheduler, self._model_settings) scheduler.refresh_ssd_layer_signature() # SpecPrefill: load draft model and pass to scheduler @@ -737,11 +751,15 @@ async def generate( # stream_generate so the non-streaming path is not silently ignored. specprefill_kwargs = self._pop_specprefill_kwargs(kwargs) tools = kwargs.pop("tools", None) + prefill_eviction_callback_attempted = bool( + kwargs.pop("prefill_eviction_callback_attempted", False) + ) output = await self._engine.generate( prompt=prompt, sampling_params=sampling_params, tools=tools, + prefill_eviction_callback_attempted=prefill_eviction_callback_attempted, **specprefill_kwargs, ) @@ -813,6 +831,9 @@ async def stream_generate( # SpecPrefill: pass per-request overrides to engine specprefill_kwargs = self._pop_specprefill_kwargs(kwargs) tools = kwargs.pop("tools", None) + prefill_eviction_callback_attempted = bool( + kwargs.pop("prefill_eviction_callback_attempted", False) + ) engine = self._engine request_id = await engine.add_request( @@ -820,6 +841,7 @@ async def stream_generate( sampling_params=sampling_params, tools=tools, skip_cache_store=bool(kwargs.get("skip_cache_store", False)), + prefill_eviction_callback_attempted=prefill_eviction_callback_attempted, **specprefill_kwargs, ) @@ -938,7 +960,7 @@ async def preflight_chat( tools: list[dict] | None = None, request_id: str | None = None, **kwargs, - ) -> None: + ) -> bool | None: """Early prefill memory check for chat completions. Tokenizes the templated prompt and asks the scheduler whether the @@ -987,7 +1009,7 @@ async def preflight_chat( if scheduler is None: _warn_scheduler_unreachable_once(self, "preflight_chat") return - await self._preflight_or_raise_with_eviction( + return await self._preflight_or_raise_with_eviction( scheduler, num_prompt_tokens=num_tokens, request_id=request_id ) @@ -996,7 +1018,7 @@ async def preflight_completion( prompt: str, request_id: str | None = None, **kwargs, - ) -> None: + ) -> bool | None: """Early prefill memory check for plain /v1/completions calls. See ``preflight_chat`` for the rationale. @@ -1017,7 +1039,7 @@ async def preflight_completion( if scheduler is None: _warn_scheduler_unreachable_once(self, "preflight_completion") return - await self._preflight_or_raise_with_eviction( + return await self._preflight_or_raise_with_eviction( scheduler, num_prompt_tokens=num_tokens, request_id=request_id ) diff --git a/omlx/engine/dflash.py b/omlx/engine/dflash.py index bedaf30c0..34cec5063 100644 --- a/omlx/engine/dflash.py +++ b/omlx/engine/dflash.py @@ -119,9 +119,7 @@ def _ms(key: str) -> str: # Precision suffixes Poolside uses for drafts trained against a quantized # target ("Laguna-S-2.1-DFlash-NVFP4" etc.). Anything else after "-DFlash-" # (e.g. z-lab's "-b16" block-size suffix) makes no precision claim. -_DRAFT_PRECISION_TAGS = frozenset( - {"NVFP4", "INT4", "INT8", "FP8", "FP4", "MXFP4"} -) +_DRAFT_PRECISION_TAGS = frozenset({"NVFP4", "INT4", "INT8", "FP8", "FP4", "MXFP4"}) def _canonical_precision_tag(value: object) -> str | None: @@ -298,6 +296,8 @@ def __init__( fallback_engine_type: str = "batched", scheduler_config: Any | None = None, omlx_ssd_cache_dir: str | Path | None = None, + prefill_eviction_callback: Any | None = None, + mid_prefill_process_claim_callback: Any | None = None, ): super().__init__() self._model_name = model_name @@ -317,6 +317,8 @@ def __init__( self._omlx_ssd_cache_dir = ( Path(omlx_ssd_cache_dir) if omlx_ssd_cache_dir else None ) + self._prefill_eviction_callback = prefill_eviction_callback + self._mid_prefill_process_claim_callback = mid_prefill_process_claim_callback self._target_model = None self._target_ops = None @@ -725,6 +727,21 @@ def _end_runtime_cache_request(manager: Any | None) -> None: except Exception as exc: logger.debug(f"dflash cache end_request failed: {exc}") + async def _claim_fallback_mid_prefill_process(self) -> None: + scheduler = self.scheduler + if ( + scheduler is None + or getattr(scheduler, "_turboquant_mid_prefill", False) is not True + ): + return + claim_callback = self._mid_prefill_process_claim_callback + if not callable(claim_callback): + raise RuntimeError( + "TurboQuant mid-prefill DFlash fallback requires an " + "EnginePool-owned process-exclusive Metal lane" + ) + await claim_callback(scheduler) + async def _evict_dflash_and_start_fallback(self) -> None: """Evict dflash models from memory, verify release, then start fallback engine.""" from dflash_mlx.cache.manager import shutdown_runtime_cache_manager @@ -788,24 +805,53 @@ async def _evict_dflash_and_start_fallback(self) -> None: else: logger.warning("DFlash model eviction: memory settle timed out") - # Start fallback engine + # Start fallback engine. if self._fallback_engine_type == "vlm": from .vlm import VLMBatchedEngine - self._fallback_engine = VLMBatchedEngine( + fallback_engine: BaseEngine = VLMBatchedEngine( model_name=self._model_name, scheduler_config=self._scheduler_config, model_settings=self._model_settings, + prefill_eviction_callback=self._prefill_eviction_callback, ) else: from .batched import BatchedEngine - self._fallback_engine = BatchedEngine( + fallback_engine = BatchedEngine( model_name=self._model_name, scheduler_config=self._scheduler_config, model_settings=self._model_settings, + prefill_eviction_callback=self._prefill_eviction_callback, ) - await self._fallback_engine.start() + self._fallback_engine = fallback_engine + try: + await fallback_engine.start() + await self._claim_fallback_mid_prefill_process() + except Exception: + scheduler = self.scheduler + process_owner = getattr(scheduler, "_metal_process_owner", None) + try: + await fallback_engine.stop() + except Exception: + logger.warning( + "Failed to stop rejected DFlash fallback engine", + exc_info=True, + ) + finally: + close_process_owner = getattr(process_owner, "close", None) + if callable(close_process_owner): + try: + close_process_owner() + except Exception: + logger.warning( + "Failed to close rejected DFlash fallback owner", + exc_info=True, + ) + self._fallback_engine = None + self._in_fallback_mode = False + self._loaded = False + raise self._in_fallback_mode = True logger.info(f"DFlash fallback engine started: {self._fallback_engine_type}") @@ -930,7 +976,7 @@ async def preflight_chat( tools: list | None = None, request_id: str | None = None, **kwargs, - ) -> None: + ) -> bool | None: """Prefill-memory preflight for chat requests. DFlash bypasses the scheduler, so it implements the front-door guard @@ -943,10 +989,9 @@ async def preflight_chat( if not self._loaded: await self.start() if self._in_fallback_mode and self._fallback_engine is not None: - await self._fallback_engine.preflight_chat( + return await self._fallback_engine.preflight_chat( messages, tools=tools, request_id=request_id, **kwargs ) - return if self._prefill_guard is None: _warn_scheduler_unreachable_once( self, "preflight_chat", "primary-mode prefill guard unavailable" @@ -981,15 +1026,14 @@ async def preflight_completion( prompt: str, request_id: str | None = None, **kwargs, - ) -> None: + ) -> bool | None: """Prefill-memory preflight for plain completions. See ``preflight_chat``.""" if not self._loaded: await self.start() if self._in_fallback_mode and self._fallback_engine is not None: - await self._fallback_engine.preflight_completion( + return await self._fallback_engine.preflight_completion( prompt, request_id=request_id, **kwargs ) - return if self._prefill_guard is None: _warn_scheduler_unreachable_once( self, diff --git a/omlx/engine/vlm.py b/omlx/engine/vlm.py index 75d3c7d49..af0b0f8bc 100644 --- a/omlx/engine/vlm.py +++ b/omlx/engine/vlm.py @@ -62,6 +62,25 @@ logger = logging.getLogger(__name__) + +def _configure_turboquant_scheduler(scheduler: Any, model_settings: Any | None) -> None: + """Propagate effective TurboQuant settings to a newly-created scheduler.""" + turboquant_active = bool(getattr(model_settings, "turboquant_kv_enabled", False)) + scheduler._turboquant_mid_prefill = turboquant_active and bool( + getattr(model_settings, "turboquant_mid_prefill", False) + ) + if not turboquant_active: + return + + scheduler._turboquant_kv_bits = float( + getattr(model_settings, "turboquant_kv_bits", 4) + ) + scheduler._turboquant_skip_last = getattr( + model_settings, "turboquant_skip_last", True + ) + scheduler._set_model_info_for_monitor() + + # OCR model types that require special handling. # unlimited-ocr keeps its dashed config model_type (mlx-vlm resolves it to the # unlimited_ocr package via MODEL_REMAPPING), so key it in the dashed form to @@ -1314,8 +1333,8 @@ async def _preflight_or_raise_with_eviction( *, num_prompt_tokens: int, request_id: str | None, - ) -> None: - await _run_scheduler_preflight_with_cleanup_retry( + ) -> bool: + callback_attempted = await _run_scheduler_preflight_with_cleanup_retry( scheduler, num_prompt_tokens=num_prompt_tokens, request_id=request_id, @@ -1326,6 +1345,7 @@ async def _preflight_or_raise_with_eviction( None, ), ) + return callback_attempted @property def model_name(self) -> str: @@ -1700,21 +1720,19 @@ def _load_vlm_sync(): # TurboQuant KV cache scheduler = self._engine.engine.scheduler - if self._model_settings is not None: - tq_enabled = getattr(self._model_settings, "turboquant_kv_enabled", False) - if tq_enabled: - from ..patches.turboquant_attention import ( - apply_turboquant_attention_patch, - ) + scheduler._prefill_eviction_callback_configured = ( + self._prefill_eviction_callback is not None + ) + tq_enabled = bool(getattr(self._model_settings, "turboquant_kv_enabled", False)) + if tq_enabled: + from ..patches.turboquant_attention import ( + apply_turboquant_attention_patch, + ) - apply_turboquant_attention_patch() - tq_bits = float(getattr(self._model_settings, "turboquant_kv_bits", 4)) - scheduler._turboquant_kv_bits = tq_bits - scheduler._turboquant_skip_last = getattr( - self._model_settings, "turboquant_skip_last", True - ) - scheduler._set_model_info_for_monitor() - logger.info(f"TurboQuant KV cache enabled for VLM: {tq_bits} bits") + apply_turboquant_attention_patch() + tq_bits = float(getattr(self._model_settings, "turboquant_kv_bits", 4)) + logger.info(f"TurboQuant KV cache enabled for VLM: {tq_bits} bits") + _configure_turboquant_scheduler(scheduler, self._model_settings) # head_dim=256 long-context prefill -> O(L) tiled SDPA kernel. See # batched.py for rationale. Passthrough-safe; strictly gated route. @@ -3113,6 +3131,9 @@ async def generate( # SpecPrefill: forward per-request overrides to the engine, mirroring # stream_generate so the non-streaming path is not silently ignored. specprefill_kwargs = self._pop_specprefill_kwargs(kwargs) + prefill_eviction_callback_attempted = bool( + kwargs.pop("prefill_eviction_callback_attempted", False) + ) output = await self._engine.generate( prompt=prompt, @@ -3122,6 +3143,7 @@ async def generate( vlm_image_hash=vlm_image_hash, vlm_cache_key_start=vlm_cache_key_start, vlm_cache_key_ranges=vlm_cache_key_ranges, + prefill_eviction_callback_attempted=prefill_eviction_callback_attempted, **specprefill_kwargs, ) @@ -3222,6 +3244,9 @@ async def stream_generate( # SpecPrefill: pass per-request overrides specprefill_kwargs = self._pop_specprefill_kwargs(kwargs) + prefill_eviction_callback_attempted = bool( + kwargs.pop("prefill_eviction_callback_attempted", False) + ) engine = self._engine request_id = await engine.add_request( @@ -3233,6 +3258,7 @@ async def stream_generate( vlm_cache_key_start=vlm_cache_key_start, vlm_cache_key_ranges=vlm_cache_key_ranges, skip_cache_store=bool(kwargs.get("skip_cache_store", False)), + prefill_eviction_callback_attempted=prefill_eviction_callback_attempted, **specprefill_kwargs, ) @@ -3349,7 +3375,7 @@ async def preflight_chat( tools: list[dict] | None = None, request_id: str | None = None, **kwargs, - ) -> None: + ) -> bool: """Early prefill memory check for chat completions (VLM path). The actual VLM prompt is built by ``_process_chat_messages`` → @@ -3381,6 +3407,10 @@ async def preflight_chat( ``BatchedEngine.preflight_chat`` for the upstream rationale (avoiding the ``StreamingResponse`` 200 commit so HTTP 400 actually reaches the client). + + Returns ``True`` only when this preflight actually invoked the async + eviction callback, allowing the route to consume the request's one + scheduler retry without storing cross-request state. """ if not self._loaded: await self.start() @@ -3392,7 +3422,7 @@ async def preflight_chat( stop=kwargs.get("stop"), kwargs=kwargs, ) - return + return False template_tools = convert_tools_for_template(tools) if tools else None ct_kwargs = kwargs.get("chat_template_kwargs") partial = kwargs.get("is_partial") @@ -3429,7 +3459,7 @@ async def preflight_chat( "surface the error", type(e).__name__, ) - return + return False # Count images from the ORIGINAL messages (the stripped # ``text_messages`` no longer has the image content-parts). num_tokens += _count_image_tokens_real( @@ -3442,8 +3472,8 @@ async def preflight_chat( scheduler = getattr(getattr(self._engine, "engine", None), "scheduler", None) if scheduler is None: _warn_scheduler_unreachable_once(self, "preflight_chat") - return - await self._preflight_or_raise_with_eviction( + return False + return await self._preflight_or_raise_with_eviction( scheduler, num_prompt_tokens=num_tokens, request_id=request_id ) @@ -3452,8 +3482,13 @@ async def preflight_completion( prompt: str, request_id: str | None = None, **kwargs, - ) -> None: - """Early prefill memory check for plain /v1/completions calls (VLM).""" + ) -> bool: + """Early prefill memory check for plain /v1/completions calls (VLM). + + Returns ``True`` only when this preflight completed after invoking the + async eviction callback. The route carries that request-local result to + admission so the scheduler cannot invoke the same callback twice. + """ if not self._loaded: await self.start() if self.is_diffusion_model: @@ -3461,7 +3496,7 @@ async def preflight_completion( stop=kwargs.get("stop"), kwargs=kwargs, ) - return + return False try: num_tokens = len(self._tokenizer.encode(prompt)) except Exception as e: @@ -3471,12 +3506,12 @@ async def preflight_completion( "path will surface the error", type(e).__name__, ) - return + return False scheduler = getattr(getattr(self._engine, "engine", None), "scheduler", None) if scheduler is None: _warn_scheduler_unreachable_once(self, "preflight_completion") - return - await self._preflight_or_raise_with_eviction( + return False + return await self._preflight_or_raise_with_eviction( scheduler, num_prompt_tokens=num_tokens, request_id=request_id ) diff --git a/omlx/engine_core.py b/omlx/engine_core.py index e025b316a..eac5ea1a2 100644 --- a/omlx/engine_core.py +++ b/omlx/engine_core.py @@ -50,6 +50,7 @@ ) from .utils.fatal import FATAL_TEARDOWN_TIMEOUT_S, fatal_exit from .utils.hardware import format_bytes +from .utils.metal_sync import _conversion_coordinator logger = logging.getLogger(__name__) @@ -128,6 +129,25 @@ def _init_mlx_thread() -> None: logger.info(f"MLX executor thread initialized: generation_stream = {stream}") +class _ProcessGuardedMLXExecutor(concurrent.futures.ThreadPoolExecutor): + """Reject global Metal work while a mid-prefill engine owns the process.""" + + @staticmethod + def _run_guarded(fn: Callable[..., Any], args: tuple, kwargs: dict) -> Any: + _conversion_coordinator.assert_background_metal_allowed() + return fn(*args, **kwargs) + + def submit( + self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any + ) -> concurrent.futures.Future[Any]: + return super().submit(self._run_guarded, fn, args, kwargs) + + +def _claim_turboquant_mid_prefill_process(owner: Any) -> None: + """Drain the global executor stream, then make ``owner`` process-exclusive.""" + mx.synchronize() + _conversion_coordinator.claim_process_exclusive(owner) + def get_mlx_executor() -> concurrent.futures.ThreadPoolExecutor: """Get or create the global MLX executor (lazy singleton). @@ -139,7 +159,7 @@ def get_mlx_executor() -> concurrent.futures.ThreadPoolExecutor: """ global _global_mlx_executor if _global_mlx_executor is None: - _global_mlx_executor = concurrent.futures.ThreadPoolExecutor( + _global_mlx_executor = _ProcessGuardedMLXExecutor( max_workers=1, thread_name_prefix="mlx-global", initializer=_init_mlx_thread, @@ -222,34 +242,60 @@ def __init__( self._engine_id = engine_id or str(uuid.uuid4()) self._owns_model = False self._closed = False - - # Acquire model ownership + self._metal_registration_active = False + self._mlx_executor: concurrent.futures.ThreadPoolExecutor | None = None + _conversion_coordinator.register_engine(self) + self._metal_registration_active = True + + # Acquire model ownership and finish construction while the process + # registration blocks a concurrent mid-prefill claim. Any constructor + # failure must release that registration immediately; a traceback can + # otherwise retain this half-built engine and block all later loads. registry = get_registry() - registry.acquire( - model=model, - engine=self, - engine_id=self._engine_id, - force=force_model_ownership, - ) - self._owns_model = True - - # Per-engine executor with dedicated mx.Stream (#1248). - # Each EngineCore gets its own thread + GPU stream so different - # models can run scheduler.step() concurrently. - self._mlx_stream = mx.new_thread_local_stream(mx.default_device()) - self._mlx_executor = concurrent.futures.ThreadPoolExecutor( - max_workers=1, - thread_name_prefix=f"mlx-engine-{self._engine_id[:8]}", - ) + try: + registry.acquire( + model=model, + engine=self, + engine_id=self._engine_id, + force=force_model_ownership, + ) + self._owns_model = True + + # Per-engine executor with dedicated mx.Stream (#1248). + # Each EngineCore gets its own thread + GPU stream so different + # models can run scheduler.step() concurrently. + self._mlx_stream = mx.new_thread_local_stream(mx.default_device()) + self._mlx_executor = concurrent.futures.ThreadPoolExecutor( + max_workers=1, + thread_name_prefix=f"mlx-engine-{self._engine_id[:8]}", + ) - # Create scheduler with per-engine stream - scheduler_config = self.config.scheduler_config or SchedulerConfig() - self.scheduler = Scheduler( - model=model, - tokenizer=tokenizer, - config=scheduler_config, - stream=self._mlx_stream, - ) + # Create scheduler with per-engine stream + scheduler_config = self.config.scheduler_config or SchedulerConfig() + self.scheduler = Scheduler( + model=model, + tokenizer=tokenizer, + config=scheduler_config, + stream=self._mlx_stream, + ) + self.scheduler._metal_process_owner = self + except BaseException: + if self._mlx_executor is not None: + self._mlx_executor.shutdown(wait=False) + self._mlx_executor = None + if self._owns_model: + try: + registry.release(self.model, self._engine_id) + except Exception: + logger.warning( + "Engine %s: model release failed during initialization", + self._engine_id, + exc_info=True, + ) + self._owns_model = False + _conversion_coordinator.unregister_engine(self) + self._metal_registration_active = False + raise # Output collectors for low-latency streaming (vLLM pattern) self._output_collectors: Dict[str, RequestOutputCollector] = {} @@ -304,6 +350,15 @@ async def stop(self) -> None: self._loop = None logger.info("Engine stopped") + async def claim_turboquant_mid_prefill_process(self) -> None: + """Claim process-exclusive Metal access after all load work completes.""" + loop = asyncio.get_running_loop() + await loop.run_in_executor( + get_mlx_executor(), + _claim_turboquant_mid_prefill_process, + self, + ) + def is_running(self) -> bool: """Check if engine is running.""" return self._running @@ -546,6 +601,7 @@ async def add_request( specprefill_system_end: Optional[int] = None, skip_cache_store: bool = False, tools: list[dict[str, Any]] | None = None, + prefill_eviction_callback_attempted: bool = False, ) -> str: """ Add a request for processing. @@ -562,6 +618,8 @@ async def add_request( specprefill: Per-request SpecPrefill override (True/False/None) specprefill_keep_pct: Per-request keep rate override specprefill_threshold: Per-request threshold override (min tokens) + prefill_eviction_callback_attempted: Whether route preflight already + invoked the async LRU callback for this request. Returns: The request ID @@ -585,6 +643,7 @@ async def add_request( vlm_cache_key_start=vlm_cache_key_start, vlm_cache_key_ranges=vlm_cache_key_ranges, skip_cache_store=skip_cache_store, + prefill_eviction_retries=int(prefill_eviction_callback_attempted), ) # SpecPrefill: resolve per-request settings. @@ -1224,6 +1283,10 @@ def close(self) -> None: _immortal_mlx_streams.append(self._mlx_stream) self._mlx_executor = None + if getattr(self, "_metal_registration_active", False): + _conversion_coordinator.unregister_engine(self) + self._metal_registration_active = False + logger.debug(f"Engine {self._engine_id} closed") def __del__(self): diff --git a/omlx/engine_pool.py b/omlx/engine_pool.py index ee9880617..330bbf0c2 100644 --- a/omlx/engine_pool.py +++ b/omlx/engine_pool.py @@ -44,6 +44,7 @@ ModelNotFoundError, ModelTooLargeError, ModelUnavailableError, + TurboQuantProcessExclusiveError, describe_ceiling_binding, ) from .model_discovery import discover_models, format_size @@ -334,6 +335,10 @@ def add(key: str, value: object) -> None: turboquant_active = bool(data.get("turboquant_kv_enabled", False)) add("turboquant_kv_enabled", turboquant_active) if turboquant_active: + add( + "turboquant_mid_prefill", + bool(data.get("turboquant_mid_prefill", False)), + ) add("turboquant_kv_bits", data.get("turboquant_kv_bits", 4)) add("turboquant_skip_last", data.get("turboquant_skip_last", True)) @@ -818,6 +823,14 @@ async def get_engine( entry = self._entries.get(model_id) if not entry: raise ModelNotFoundError(model_id, list(self._entries.keys())) + try: + self._raise_if_other_mid_prefill_model_owns_process(model_id) + except TurboQuantProcessExclusiveError as exc: + raise ModelLoadingError( + model_id, + f"Model '{model_id}' cannot load while process-exclusive " + f"Metal access is unavailable: {exc}", + ) from exc expected_signature = self._engine_runtime_signature( model_id, runtime_settings, @@ -895,9 +908,7 @@ async def get_engine( # only its language weights, so admit it by the text-only estimate # instead of the vision-inclusive file size (#2385). admission_size = entry.estimated_size - if entry.text_only_size and ( - force_lm or entry.engine_type == "batched" - ): + if entry.text_only_size and (force_lm or entry.engine_type == "batched"): admission_size = entry.text_only_size ceiling = self._current_ceiling() @@ -907,9 +918,7 @@ async def get_engine( best_effort = ceiling > 0 if ceiling > 0: soft_target = self._admission_soft_target() - evict_target = ( - min(soft_target, ceiling) if soft_target > 0 else ceiling - ) + evict_target = min(soft_target, ceiling) if soft_target > 0 else ceiling evicted_any = unloaded_for_admission while True: # Consult the tracked accumulator alongside live memory: @@ -1200,6 +1209,53 @@ def _resolve_scheduler_from_engine(engine: object) -> object | None: except AttributeError: return None + def _raise_if_other_mid_prefill_model_owns_process( + self, + model_id: str, + ) -> None: + for other_id, entry in self._entries.items(): + if other_id == model_id or entry.engine is None: + continue + scheduler = self._resolve_scheduler_from_engine(entry.engine) + if ( + scheduler is not None + and getattr(scheduler, "_turboquant_mid_prefill", False) is True + ): + raise TurboQuantProcessExclusiveError( + "TurboQuant mid-prefill requires process-exclusive Metal " + f"access; unload '{other_id}' before loading '{model_id}'" + ) + + async def _claim_turboquant_mid_prefill_process( + self, + model_id: str, + scheduler: object, + ) -> None: + other_ids = [ + other_id + for other_id, entry in self._entries.items() + if other_id != model_id and entry.engine is not None + ] + if other_ids: + raise TurboQuantProcessExclusiveError( + "TurboQuant mid-prefill requires process-exclusive Metal " + "access; unload all other engines before enabling it " + f"(loaded: {', '.join(sorted(other_ids))})" + ) + + process_owner = getattr(scheduler, "_metal_process_owner", None) + claim_process = getattr( + process_owner, + "claim_turboquant_mid_prefill_process", + None, + ) + if not callable(claim_process): + raise RuntimeError( + "TurboQuant mid-prefill requires an EngineCore-owned " + "process-exclusive Metal lane" + ) + await claim_process() + def _is_idle_for_prefill_eviction(self, entry: EngineEntry) -> bool: engine = entry.engine if engine is None or entry.is_pinned or entry.is_loading or entry.in_use > 0: @@ -1429,11 +1485,28 @@ async def _unload_engine(self, model_id: str) -> None: logger.info(f"Unloading model: {model_id} (immediate abort)") pre_unload_active = mx.get_active_memory() + scheduler = self._resolve_scheduler_from_engine(entry.engine) + process_owner = ( + getattr(scheduler, "_metal_process_owner", None) + if scheduler is not None + else None + ) try: await entry.engine.stop() except Exception as e: logger.warning(f"Error stopping engine for {model_id}: {e}") + finally: + close_process_owner = getattr(process_owner, "close", None) + if callable(close_process_owner): + try: + close_process_owner() + except Exception: + logger.warning( + "Error closing engine owner for %s", + model_id, + exc_info=True, + ) # #1595: the immediate-abort stop() above tears the engine down without the normal # per-request completion callbacks, so a non-streaming engine's active_requests @@ -1607,13 +1680,18 @@ async def _reclaim() -> None: # barrier's small-model tolerance floor. target = pre_load_memory + 2 * 1024**3 current = 0 + blocked_rounds = 0 for _round in range(6): await asyncio.sleep(0.5 if _round == 0 else 1.0) gc.collect() - await loop.run_in_executor( - get_mlx_executor(), - lambda: (mx.synchronize(), mx.clear_cache()), - ) + try: + await loop.run_in_executor( + get_mlx_executor(), + lambda: (mx.synchronize(), mx.clear_cache()), + ) + except TurboQuantProcessExclusiveError: + blocked_rounds += 1 + continue current = max(mx.get_active_memory(), get_phys_footprint()) if current <= target: logger.info( @@ -1623,6 +1701,15 @@ async def _reclaim() -> None: ) self._wake_process_memory_enforcer() return + if blocked_rounds == 6: + logger.warning( + "Post-failed-load reclaim for '%s' remained blocked for " + "all 6 rounds by process-exclusive TurboQuant Metal " + "ownership; deferred buffers remain until exclusivity ends.", + model_id, + ) + self._wake_process_memory_enforcer() + return logger.warning( f"Post-failed-load reclaim for '{model_id}' did not settle: " f"current={format_size(current)} " @@ -1695,6 +1782,35 @@ async def _load_engine( # model families; let VLMBatchedEngine handle MTP-enabled VLMs. pass + async def prefill_eviction_callback( + eviction_request: object, + *, + _model_id: str = model_id, + ) -> bool: + return await self._evict_idle_lru_for_prefill( + exclude_model_id=_model_id, + eviction_request=eviction_request, + ) + + async def mid_prefill_process_claim_callback( + scheduler: object, + *, + _model_id: str = model_id, + ) -> None: + async with self._lock: + try: + await self._claim_turboquant_mid_prefill_process( + _model_id, + scheduler, + ) + except TurboQuantProcessExclusiveError as exc: + raise ModelLoadingError( + _model_id, + f"Model '{_model_id}' cannot switch to its " + "mid-prefill fallback while process-exclusive " + f"Metal access is unavailable: {exc}", + ) from exc + # Check if DFlash is enabled -- takes priority over engine type # since DFlash has its own model loading pipeline engine = None @@ -1736,10 +1852,16 @@ async def _load_engine( omlx_ssd_cache_dir=getattr( self._scheduler_config, "paged_ssd_cache_dir", None ), + prefill_eviction_callback=prefill_eviction_callback, + mid_prefill_process_claim_callback=( + mid_prefill_process_claim_callback + ), ) logger.info( f"DFlash enabled for {model_id}, draft={dflash_draft}" ) + except TurboQuantProcessExclusiveError: + raise except ImportError: logger.warning( f"DFlash enabled for {model_id} but dflash-mlx is not installed. " @@ -1761,16 +1883,6 @@ async def _load_engine( else False ) - async def prefill_eviction_callback( - eviction_request: object, - *, - _model_id: str = model_id, - ) -> bool: - return await self._evict_idle_lru_for_prefill( - exclude_model_id=_model_id, - eviction_request=eviction_request, - ) - # Create engine based on engine type (if DFlash not active) if engine is None: if effective_type == "embedding": @@ -1818,6 +1930,8 @@ async def prefill_eviction_callback( try: await engine.start() + except TurboQuantProcessExclusiveError: + raise except Exception as start_error: if _is_dflash_engine: # DFlash engine failed to start -- fall back to the @@ -1855,6 +1969,8 @@ async def prefill_eviction_callback( ) try: await engine.start() + except TurboQuantProcessExclusiveError: + raise except Exception as fallback_error: raise RuntimeError( f"DFlash load failed: {start_error}; " @@ -1893,6 +2009,8 @@ async def prefill_eviction_callback( ) try: await engine.start() + except TurboQuantProcessExclusiveError: + raise except Exception as fallback_error: raise RuntimeError( f"LM load failed (force_lm=True): {start_error}; " @@ -1929,6 +2047,8 @@ async def prefill_eviction_callback( ) try: await engine.start() + except TurboQuantProcessExclusiveError: + raise except Exception as fallback_error: raise RuntimeError( f"VLM load failed: {start_error}; " @@ -1938,7 +2058,7 @@ async def prefill_eviction_callback( entry.model_type = "llm" entry.engine_type = "batched" logger.info( - f"Successfully loaded {model_id} as LLM " f"(fallback from VLM)" + f"Successfully loaded {model_id} as LLM (fallback from VLM)" ) else: raise @@ -2069,6 +2189,49 @@ def _load_drafter_sync(path: str = drafter_path): "the request.", ) + scheduler = self._resolve_scheduler_from_engine(engine) + if ( + scheduler is not None + and getattr(scheduler, "_turboquant_mid_prefill", False) is True + ): + process_owner = getattr( + scheduler, + "_metal_process_owner", + None, + ) + try: + await self._claim_turboquant_mid_prefill_process( + model_id, + scheduler, + ) + except Exception: + entry.engine = None + self._current_model_memory = max( + 0, + self._current_model_memory - entry.estimated_size, + ) + load_completed = False + try: + await engine.stop() + except Exception: + logger.warning( + "Failed to stop non-exclusive mid-prefill engine %s", + model_id, + exc_info=True, + ) + finally: + close_process_owner = getattr(process_owner, "close", None) + if callable(close_process_owner): + try: + close_process_owner() + except Exception: + logger.warning( + "Failed to close non-exclusive mid-prefill " + "owner for %s", + model_id, + exc_info=True, + ) + raise logger.info( f"Loaded model: {model_id} " f"(actual: {format_size(entry.actual_size)}, " @@ -2086,6 +2249,12 @@ def _load_drafter_sync(path: str = drafter_path): # inflated and the memory-ceiling admission check rejects all # subsequent loads until a server restart. self._schedule_failed_load_reclaim(model_id, pre_load_memory) + if isinstance(exc, TurboQuantProcessExclusiveError): + raise ModelLoadingError( + model_id, + f"Model '{model_id}' cannot load while process-exclusive " + f"Metal access is unavailable: {exc}", + ) from exc if not entry.abort_loading and not entry_detached: self._mark_load_failure(entry, exc) logger.exception( diff --git a/omlx/exceptions.py b/omlx/exceptions.py index c19dce323..cc45dac4b 100644 --- a/omlx/exceptions.py +++ b/omlx/exceptions.py @@ -542,6 +542,10 @@ def __init__(self, required: int, current: int, message: str): super().__init__(message) +class TurboQuantProcessExclusiveError(RuntimeError): + """Raised when temporary process-wide Metal activity blocks exclusivity.""" + + class ModelLoadingError(EnginePoolError): """Raised when a model load is unavailable, blocked, or invalid.""" diff --git a/omlx/memory_monitor.py b/omlx/memory_monitor.py index 976aa466e..37cc5eb04 100644 --- a/omlx/memory_monitor.py +++ b/omlx/memory_monitor.py @@ -90,9 +90,7 @@ def register_attention_bias_transient(dtype_size: float | None) -> None: model load/swap: the setting is process-wide, like the tiled head_dim registry above.""" global _ATTENTION_BIAS_TRANSIENT_DTYPE_SIZE - _ATTENTION_BIAS_TRANSIENT_DTYPE_SIZE = ( - float(dtype_size) if dtype_size else None - ) + _ATTENTION_BIAS_TRANSIENT_DTYPE_SIZE = float(dtype_size) if dtype_size else None def estimate_unfused_sdpa_call_bytes( @@ -542,19 +540,34 @@ def estimate_block_memory( Estimate memory usage for a KV cache block. Args: - block_size: Number of tokens in the block - num_layers: Override stored num_layers - num_kv_heads: Override stored num_kv_heads - head_dim: Override stored head_dim - dtype_size: Override stored dtype_size + block_size: Number of tokens in the block. + num_layers: Override the number of full-attention KV cache layers. + num_kv_heads: Override stored num_kv_heads. + head_dim: Override stored head_dim. + dtype_size: Override stored dtype_size. Returns: Estimated memory in bytes for one block. """ - layers = num_layers or self._num_layers or 32 # Default for ~7B model - kv_heads = num_kv_heads or self._num_kv_heads or 8 - dim = head_dim or self._head_dim or 128 - dtype = dtype_size or self._dtype_size + if num_layers is not None: + layers = num_layers + elif self._num_kv_cache_layers is not None: + layers = self._num_kv_cache_layers + elif self._num_layers is not None: + layers = self._num_layers + else: + layers = 32 # Default for ~7B model + kv_heads = ( + num_kv_heads + if num_kv_heads is not None + else (self._num_kv_heads if self._num_kv_heads is not None else 8) + ) + dim = ( + head_dim + if head_dim is not None + else (self._head_dim if self._head_dim is not None else 128) + ) + dtype = self._dtype_size if dtype_size is None else dtype_size if ( self._kv_bytes_per_token_override is not None @@ -572,7 +585,32 @@ def estimate_block_memory( return total - def estimate_prompt_kv_bytes(self, num_tokens: int) -> float: + def estimate_paged_writer_block_memory(self, block_size: int) -> float: + """Estimate one queued SSD block, including boundary snapshot state.""" + if block_size <= 0: + return 0 + + total = self.estimate_block_memory(block_size) + if self._prefill_memory_profile is not None: + profile_bytes = self._prefill_memory_profile.estimate_resident_kv_bytes( + block_size, + chunk_tokens=block_size, + ) + total = max(total, profile_bytes) + + if self._rotating_layer_specs: + kv_heads = self._num_kv_heads or 0 + dim = self._head_dim or 0 + if kv_heads and dim: + per_token = kv_heads * dim * self._score_dtype_size * 2 + for count, window in self._rotating_layer_specs: + total += count * (window + block_size - 1) * per_token + + return total + self._fixed_state_bytes + + def estimate_prompt_kv_bytes( + self, num_tokens: int, *, dtype_size: float | None = None + ) -> float: """ Estimate KV cache memory for a prompt of given length. @@ -581,6 +619,8 @@ def estimate_prompt_kv_bytes(self, num_tokens: int) -> float: Args: num_tokens: Number of prompt tokens. + dtype_size: Optional stored-KV bytes per element for this request + phase. Defaults to the model-level cache width. Returns: Estimated KV cache memory in bytes. @@ -599,7 +639,7 @@ def estimate_prompt_kv_bytes(self, num_tokens: int) -> float: layers = self._num_layers or 0 kv_heads = self._num_kv_heads or 0 dim = self._head_dim or 0 - dtype = self._dtype_size + dtype = self._dtype_size if dtype_size is None else float(dtype_size) if not (layers and kv_heads and dim): return 0 @@ -612,7 +652,11 @@ def estimate_prompt_kv_bytes(self, num_tokens: int) -> float: return num_tokens * per_token def estimate_resident_kv_bytes( - self, num_tokens: int, *, chunk_tokens: int = 1 + self, + num_tokens: int, + *, + chunk_tokens: int = 1, + dtype_size: float | None = None, ) -> float: """Exact-shape resident KV bytes a prefill of ``num_tokens`` adds. @@ -639,7 +683,7 @@ def estimate_resident_kv_bytes( return self._prefill_memory_profile.estimate_resident_kv_bytes( num_tokens, chunk_tokens=chunk_tokens ) - total = self.estimate_prompt_kv_bytes(num_tokens) + total = self.estimate_prompt_kv_bytes(num_tokens, dtype_size=dtype_size) if self._rotating_layer_specs: kv_heads = self._num_kv_heads or 0 @@ -704,7 +748,9 @@ def _estimate_sdpa_activation_bytes(self, query_tokens: int, kv_len: int) -> int and query_tokens > 1 and kv_len >= _SDPA_TILED_MIN_KV_LEN ): - tile_scores = n_q * query_tokens * min(kv_tile, kv_len) * self._score_dtype_size + tile_scores = ( + n_q * query_tokens * min(kv_tile, kv_len) * self._score_dtype_size + ) return output + tile_scores + bias return ( @@ -715,7 +761,12 @@ def _estimate_sdpa_activation_bytes(self, query_tokens: int, kv_len: int) -> int ) def estimate_prefill_peak_bytes( - self, new_tokens: int, chunk_size: int, *, cached_tokens: int = 0 + self, + new_tokens: int, + chunk_size: int, + *, + cached_tokens: int = 0, + dtype_size: float | None = None, ) -> float: """ Estimate per-request prefill peak memory contribution (KV + SDPA). @@ -749,6 +800,8 @@ def estimate_prefill_peak_bytes( still typecheck — but they get the under-counting behavior this method was designed to fix, so always pass it when the value is available. + dtype_size: Optional stored-KV bytes per element for this request + phase. SDPA activation width remains the compute dtype. Returns: Per-request peak contribution in bytes (KV + SDPA). Returns 0 if @@ -778,7 +831,11 @@ def estimate_prefill_peak_bytes( # The cached portion is already counted in the caller's current-usage # baseline. Resident math includes window-capped sliding-window # layers and measured fixed state, not just full-attention KVCache. - kv = self.estimate_resident_kv_bytes(new_tokens, chunk_tokens=eff_chunk) + kv = self.estimate_resident_kv_bytes( + new_tokens, + chunk_tokens=eff_chunk, + dtype_size=dtype_size, + ) return attn + kv def estimate_chunk_transient_bytes(self, n_tokens: int, kv_len: int) -> int: @@ -803,6 +860,29 @@ def estimate_chunk_transient_bytes(self, n_tokens: int, kv_len: int) -> int: ) return self._estimate_sdpa_activation_bytes(n_tokens, kv_len) + def estimate_turboquant_prefill_attention_bytes( + self, + query_tokens: int, + kv_len: int, + *, + bits: float, + ) -> int: + """Return the source-structural long-prefill TurboQuant workspace.""" + from .turboquant_kv import ( + estimate_turboquant_prefill_attention_workspace_bytes, + ) + + return estimate_turboquant_prefill_attention_workspace_bytes( + query_tokens=query_tokens, + kv_len=kv_len, + num_query_heads=self._num_attention_heads or 0, + num_kv_heads=self._num_kv_heads or 0, + head_dim=self._head_dim or 0, + bits=bits, + compute_dtype_size=self._score_dtype_size, + causal=True, + ) + def estimate_blocks_to_free(self, bytes_to_free: int, block_size: int) -> int: """ Estimate number of blocks to evict to free the given bytes. @@ -1156,9 +1236,7 @@ def make_prefill_memory_profile( ) # Fixed-state recurrent caches (GDN/Mamba). Matches # Scheduler._cache_tree_has_arrays_cache plus mlx-lm's MambaCache. -_ARRAYS_CACHE_CLASS_NAMES = frozenset( - {"ArraysCache", "SizedArraysCache", "MambaCache"} -) +_ARRAYS_CACHE_CLASS_NAMES = frozenset({"ArraysCache", "SizedArraysCache", "MambaCache"}) def collect_kv_layer_specs( @@ -1487,7 +1565,10 @@ def raise_if_prefill_exceeds( request_id = f"preflight-{_uuid.uuid4().hex[:8]}" logger.warning( "Preflight rejected (%d tokens, cached=%d, request_id=%s): %s", - num_prompt_tokens, cached_tokens, request_id, message, + num_prompt_tokens, + cached_tokens, + request_id, + message, ) raise PrefillMemoryExceededError( message=message, diff --git a/omlx/model_profiles.py b/omlx/model_profiles.py index 4afffb853..aaaf2c18d 100644 --- a/omlx/model_profiles.py +++ b/omlx/model_profiles.py @@ -43,6 +43,7 @@ # Model-specific fields — eligible for per-model profiles only (never templates). MODEL_SPECIFIC_PROFILE_FIELDS = ( "turboquant_kv_enabled", + "turboquant_mid_prefill", "turboquant_kv_bits", "turboquant_skip_last", "dflash_enabled", diff --git a/omlx/model_settings.py b/omlx/model_settings.py index 87e935bed..36ee95044 100644 --- a/omlx/model_settings.py +++ b/omlx/model_settings.py @@ -107,6 +107,7 @@ class ModelSettings: guided_grammar_enabled: Whether a default guided grammar is active. guided_grammar: Default EBNF grammar for constrained decoding. turboquant_kv_enabled: Enable TurboQuant KV cache compression. + turboquant_mid_prefill: Convert the growing KV cache when prefill cannot fit. turboquant_kv_bits: TurboQuant bit depth (2/2.5/3/3.5/4/6/8). turboquant_skip_last: Skip last KVCache layer to prevent corruption. specprefill_enabled: Enable SpecPrefill (experimental sparse prefill for MoE). @@ -194,6 +195,7 @@ class ModelSettings: # TurboQuant KV cache (mlx-vlm backend) turboquant_kv_enabled: bool = False + turboquant_mid_prefill: bool = False turboquant_kv_bits: float = 4 # 2, 2.5, 3, 3.5, 4, 6, 8 turboquant_skip_last: bool = ( True # Skip last KVCache layer (prevents corruption on sensitive models) diff --git a/omlx/patches/turboquant_attention.py b/omlx/patches/turboquant_attention.py index 40a92df91..280cff45e 100644 --- a/omlx/patches/turboquant_attention.py +++ b/omlx/patches/turboquant_attention.py @@ -18,13 +18,18 @@ from typing import Optional import mlx.core as mx +from ..turboquant_kv import ( + TURBOQUANT_PREFILL_KEY_CHUNK_TOKENS, + TURBOQUANT_PREFILL_QUERY_BLOCK_TOKENS, +) + logger = logging.getLogger(__name__) _PATCHED = False _LONG_PREFILL_QUANTIZED_THRESHOLD = 8192 -_LONG_PREFILL_QUERY_BLOCK_SIZE = 256 -_LONG_PREFILL_KEY_CHUNK_SIZE = 16384 +_LONG_PREFILL_QUERY_BLOCK_SIZE = TURBOQUANT_PREFILL_QUERY_BLOCK_TOKENS +_LONG_PREFILL_KEY_CHUNK_SIZE = TURBOQUANT_PREFILL_KEY_CHUNK_TOKENS # MTP verify is a decode-shaped multi-row call (q_len = 1 + draft depth <= 9). # Above this floor a multi-row call is genuine (chunked) prefill. _DECODE_MULTIROW_MAX_Q_LEN = 15 @@ -621,10 +626,11 @@ def patched_sdpa( total_tokens = _state_length(keys_state) except Exception: total_tokens = 0 - if ( - total_tokens > _LONG_PREFILL_QUANTIZED_THRESHOLD - and hasattr(real_cache, "quantized_attention") - ): + if total_tokens > _LONG_PREFILL_QUANTIZED_THRESHOLD: + if not hasattr(real_cache, "quantized_attention"): + raise RuntimeError( + "Long TurboQuant prefill requires quantized attention" + ) old_query_block_size = getattr( real_cache, "prefill_query_block_size", None ) @@ -643,12 +649,6 @@ def patched_sdpa( scale=scale, mask=mask, ) - except Exception: - logger.debug( - "TurboQuant quantized prefill attention failed; " - "falling back to dequantize+SDPA", - exc_info=True, - ) finally: if old_query_block_size is not None: real_cache.prefill_query_block_size = old_query_block_size diff --git a/omlx/request.py b/omlx/request.py index a30b85043..5ee309cdc 100644 --- a/omlx/request.py +++ b/omlx/request.py @@ -210,6 +210,12 @@ def vlm_extra_key_ranges_for_cache( prefill_eviction_retries: int = ( 0 # Per-request prefill-headroom eviction phase counter ) + prefill_started_at: float | None = ( + None # First external/chunked prefill attempt; survives LRU retries + ) + turboquant_mid_prefill_attempted: bool = ( + False # Durable across prefill-OOM requeues; one attempt per request + ) # Request-scoped tool schemas used by protocol output parsers. tools: list[dict[str, Any]] | None = None diff --git a/omlx/scheduler.py b/omlx/scheduler.py index 6dd211b1c..2d16ca14f 100644 --- a/omlx/scheduler.py +++ b/omlx/scheduler.py @@ -16,9 +16,11 @@ import gc import importlib import logging +import math import os import threading import time +import traceback from array import array from collections import OrderedDict, defaultdict, deque from collections.abc import Callable @@ -68,10 +70,18 @@ run_vlm_mtp_decode, vlm_mtp_positioned_sampling_available, ) +from .turboquant_kv import ( + TURBOQUANT_CONVERSION_SLICE_TOKENS, + TurboQuantConversionStats, + convert_kv_cache_sliced, + estimate_turboquant_conversion_peak_bytes, + turboquant_mse_bytes_per_element, +) from .utils.fatal import FATAL_TEARDOWN_TIMEOUT_S, fatal_exit from .utils.generation_config import load_generation_config_token_ids from .utils.hardware import format_bytes from .utils.metal_sync import ( + _conversion_coordinator, _default_generation_stream, _mx_buffer_access_lock, _sync_and_clear_cache, @@ -405,10 +415,74 @@ def __init__(self, aborted_uids: list[int], processed_tokens: int): self.aborted_uids = aborted_uids self.processed_tokens = processed_tokens super().__init__( - f"Prefill aborted for UIDs {aborted_uids} " f"at {processed_tokens} tokens" + f"Prefill aborted for UIDs {aborted_uids} at {processed_tokens} tokens" ) +def _format_and_detach_exception(exc: BaseException) -> str: + """Format an exception, then remove every traceback/frame reference.""" + formatted = "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ).rstrip() + current: BaseException | None = exc + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + next_exception = current.__cause__ or current.__context__ + current_traceback = current.__traceback__ + current.__traceback__ = None + current.__cause__ = None + current.__context__ = None + if current_traceback is not None: + traceback.clear_frames(current_traceback) + current = next_exception + return formatted + + +class _PrefillKVPhase(Enum): + """Resident full-attention KV representation during prefill.""" + + DENSE = "dense" + TURBOQUANT = "turboquant" + UNSUPPORTED = "unsupported" + INVALID_PARTIAL = "invalid_partial" + + +@dataclass +class _PrefillContext: + """Request-scoped phase and telemetry for one prefill.""" + + request_id: str + loop_label: str + phase: _PrefillKVPhase + conversion_eligible: bool + started_at: float | None = None + conversion_attempted: bool = False + mid_triggered: bool = False + trigger_tokens: int | None = None + conversion_seconds: float = 0.0 + converted_layers: int = 0 + conversion_slices: int = 0 + skipped_dense_layers: int = 0 + memory_before_bytes: int = 0 + memory_after_bytes: int = 0 + post_trigger_tokens: int = 0 + conversion_completed_at: float | None = None + summary_logged: bool = False + + +@dataclass(frozen=True, slots=True) +class _GuardedTurboQuantConversion: + """Successful conversion samples and telemetry.""" + + stats: TurboQuantConversionStats + before_bytes: int + after_bytes: int + estimated_peak_bytes: int + conversion_seconds: float + completed_at: float + + @dataclass class PrefillEvictionRequest: """Internal request for async LRU model eviction before prefill.""" @@ -420,6 +494,7 @@ class PrefillEvictionRequest: predicted_transient_bytes: int requested_tokens: int reason: str + processed_tokens: int = 0 class _PrefillEvictionNeeded(Exception): @@ -483,6 +558,7 @@ class _PrefillState: boundary_enabled: bool # Whether boundary snapshots are active block_size: int # Copied from config.paged_cache_block_size total_length: int # len(original tokens) for completeness + prefill_context: _PrefillContext | None = None # Pre-built insert-time params (set by _schedule_waiting before enqueuing) sampler: Any = None sm: Any = None @@ -1096,7 +1172,7 @@ def _ckvcache_extend_passthrough(self, other): # Surface which ones so a regression in Llama-4 batching is visible # to operators without diffing the patch against installed mlx_lm. logger.info( - "ChunkedKVCache patch: methods already present upstream, " "skipped: %s", + "ChunkedKVCache patch: methods already present upstream, skipped: %s", ", ".join(_ckvcache_methods_skipped), ) except ImportError: @@ -1595,6 +1671,12 @@ def __init__( # TurboQuant KV cache (set by engine if model_settings has it enabled) self._turboquant_kv_bits: float | None = None self._turboquant_skip_last: bool = True + self._turboquant_mid_prefill: bool = False + self._prefill_dense_kv_dtype_size: float | None = None + self._prefill_tq_kv_dtype_size: float | None = None + # Route-level preflight can only defer a dense-memory rejection when + # model cache construction has confirmed a convertible dense layout. + self._turboquant_preflight_conversion_eligible: bool | None = None # Memoized MLA-architecture detection (see _model_uses_mla / #1613). self._mla_model: bool | None = None self._glm_dsa_adaptive_prefill = None @@ -1742,6 +1824,10 @@ def __init__( # default level even while it ran an order of magnitude slower; one # line per request keeps that visible without a line per chunk. self._throttle_notified_requests: set[str] = set() + # Engine wrappers set this after construction. ``None`` keeps direct + # Scheduler users on the legacy one-shot path; ``False`` avoids a + # pointless pause when no async eviction callback exists. + self._prefill_eviction_callback_configured: bool | None = None self._pending_prefill_eviction_request: PrefillEvictionRequest | None = None self._memory_admission_blocked_request_id: str | None = None self._memory_admission_blocked_since: float = 0.0 @@ -1755,6 +1841,9 @@ def __init__( self._prefill_transient_tracker = PrefillTransientTracker( model_id=_tracker_model_id ) + self._prefill_tq_transient_tracker = PrefillTransientTracker( + model_id=_tracker_model_id + ) # One-shot probe of the GDN/Mamba fixed recurrent-state footprint, # armed by _set_model_info_for_monitor when ArraysCache layers exist # and taken after the first prefill chunk's eval. @@ -1947,9 +2036,9 @@ def __init__( # Streaming detokenizers for proper UTF-8 handling (one per active request) # NOTE: No pooling - each request gets a fresh instance to prevent state contamination - self._request_detokenizers: dict[str, Any] = ( - {} - ) # request_id → active detokenizer + self._request_detokenizers: dict[ + str, Any + ] = {} # request_id → active detokenizer # Protocol-specific output parser support (e.g. Harmony, Gemma 4) self._output_parser_factory: OutputParserFactory | None = None @@ -2947,14 +3036,10 @@ def _ok(c: Any) -> bool: return False if isinstance(c, CacheList): # A KVCache member inside a CacheList converts fine at - # runtime, but the prefix/SSD store paths dispatch on the - # layer class ("CacheList") and have no TurboQuant - # sub-state serialization: the converted member's - # NamedTuple state is flattened to an anonymous tuple on - # store and rebuilt as a corrupt dense cache on restore. - # Until CacheList-level TQ serialization exists, exclude - # composite layers that contain a convertible KVCache - # (e.g. inkling's CacheList(KVCache, ArraysCache)). + # runtime, but prefix/SSD storage dispatches on the outer + # CacheList class and cannot serialize TurboQuant sub-state. + # Flattening that state would restore a corrupt dense cache, + # so reject this layout until CacheList has a TQ-aware format. if any(type(inner) is KVCache for inner in c.caches): if not getattr(self, "_tq_cachelist_guard_logged", False): self._tq_cachelist_guard_logged = True @@ -2969,6 +3054,240 @@ def _ok(c: Any) -> bool: return bool(prompt_cache) and all(_ok(c) for c in prompt_cache) + def _confirm_turboquant_preflight_conversion_eligibility( + self, + prompt_cache: list[Any], + ) -> bool: + """Return True only when prompt_cache is an all-dense convertible layout.""" + if not isinstance(prompt_cache, list) or not prompt_cache: + return False + if not self._turboquant_eligible(prompt_cache): + return False + family_targets = [ + cache_obj + for cache_obj in prompt_cache + if _is_turboquant_kv_family_cache(cache_obj) + ] + if not family_targets: + return False + return all(isinstance(target, _MLXKVCache) for target in family_targets) + + def _classify_prefill_cache( + self, prompt_cache: list[Any] + ) -> tuple[_PrefillKVPhase, bool]: + """Classify only complete dense or complete configured-TQ cache states.""" + raw_bits = getattr(self, "_turboquant_kv_bits", None) + if not isinstance(raw_bits, (int, float)) or isinstance(raw_bits, bool): + return _PrefillKVPhase.UNSUPPORTED, False + if not prompt_cache or not self._turboquant_eligible(prompt_cache): + return _PrefillKVPhase.UNSUPPORTED, False + + family_indices = [ + index + for index, cache_obj in enumerate(prompt_cache) + if _is_turboquant_kv_family_cache(cache_obj) + ] + if not family_indices: + return _PrefillKVPhase.UNSUPPORTED, False + + skip_last = ( + bool(getattr(self, "_turboquant_skip_last", True)) + and len(family_indices) > 1 + ) + skipped_index = family_indices[-1] if skip_last else None + expected_tokens = _cache_layer_token_count(prompt_cache[family_indices[0]]) + dense_targets = 0 + turboquant_targets = 0 + target_count = len(family_indices) - (1 if skipped_index is not None else 0) + expected_bits = float(raw_bits) + + for index in family_indices: + cache_obj = prompt_cache[index] + if _cache_layer_token_count(cache_obj) != expected_tokens: + return _PrefillKVPhase.INVALID_PARTIAL, False + is_turboquant = _is_turboquant_kv_cache(cache_obj) + if index == skipped_index: + if is_turboquant or not isinstance(cache_obj, _MLXKVCache): + return _PrefillKVPhase.INVALID_PARTIAL, False + continue + if is_turboquant: + cache_bits = getattr(cache_obj, "bits", None) + if not isinstance(cache_bits, (int, float)) or isinstance( + cache_bits, bool + ): + return _PrefillKVPhase.INVALID_PARTIAL, False + if not math.isclose( + float(cache_bits), expected_bits, rel_tol=0.0, abs_tol=1e-6 + ): + return _PrefillKVPhase.INVALID_PARTIAL, False + turboquant_targets += 1 + elif isinstance(cache_obj, _MLXKVCache): + dense_targets += 1 + else: + return _PrefillKVPhase.INVALID_PARTIAL, False + + if dense_targets == target_count and turboquant_targets == 0: + return _PrefillKVPhase.DENSE, True + if turboquant_targets == target_count and dense_targets == 0: + return _PrefillKVPhase.TURBOQUANT, False + return _PrefillKVPhase.INVALID_PARTIAL, False + + def _has_populated_turboquant_target( + self, + prompt_cache: list[Any], + ) -> bool: + """Return whether a mid-prefill conversion has dense history to keep.""" + family_indices = [ + index + for index, cache_obj in enumerate(prompt_cache) + if _is_turboquant_kv_family_cache(cache_obj) + ] + skipped_index = ( + family_indices[-1] + if bool(getattr(self, "_turboquant_skip_last", True)) + and len(family_indices) > 1 + else None + ) + return any( + index != skipped_index + and isinstance(prompt_cache[index], _MLXKVCache) + and _cache_layer_token_count(prompt_cache[index]) > 0 + for index in family_indices + ) + + def _discard_failed_prefill_cache( + self, request: "Request", prompt_cache: list[Any] + ) -> None: + """Drop every reference to a cache that may be partly converted.""" + if getattr(request, "prompt_cache", None) is prompt_cache: + request.prompt_cache = None + prompt_cache.clear() + _sync_and_clear_cache(self._stream) + + def _reclaim_failed_turboquant_conversion( + self, + request: "Request", + prompt_cache: list[Any], + ) -> None: + """Drop failed conversion state, collect it, then clear Metal buffers.""" + if getattr(request, "prompt_cache", None) is prompt_cache: + request.prompt_cache = None + prompt_cache.clear() + try: + gc.collect() + except Exception as exc: + reclaim_traceback = _format_and_detach_exception(exc) + del exc + logger.error( + "TurboQuant failure GC failed for %s:\n%s", + request.request_id, + reclaim_traceback, + ) + try: + _sync_and_clear_cache(self._stream) + except Exception as exc: + reclaim_traceback = _format_and_detach_exception(exc) + del exc + logger.error( + "TurboQuant failure Metal clear failed for %s:\n%s", + request.request_id, + reclaim_traceback, + ) + + def _new_prefill_context( + self, + request: "Request", + prompt_cache: list[Any], + *, + loop_label: str, + ) -> _PrefillContext: + """Create request-local phase state and reject partial caches.""" + mid_prefill_enabled = bool(getattr(self, "_turboquant_mid_prefill", False)) + if not mid_prefill_enabled and not any( + _is_turboquant_kv_cache(cache_obj) for cache_obj in prompt_cache + ): + return _PrefillContext( + request_id=request.request_id, + loop_label=loop_label, + phase=_PrefillKVPhase.DENSE, + conversion_eligible=False, + started_at=None, + conversion_attempted=False, + ) + phase, conversion_eligible = self._classify_prefill_cache(prompt_cache) + if not mid_prefill_enabled: + conversion_eligible = False + if phase is _PrefillKVPhase.UNSUPPORTED: + phase = _PrefillKVPhase.DENSE + if phase is _PrefillKVPhase.INVALID_PARTIAL: + current = self._current_usage_bytes() + limit = self._prefill_abort_cap() or None + self._discard_failed_prefill_cache(request, prompt_cache) + raise PrefillMemoryExceededError( + message=( + "TurboQuant prefill cache was partially converted or used " + "an incompatible bit width; the request cache was discarded" + ), + request_id=request.request_id, + estimated_bytes=current, + limit_bytes=limit, + ) + if not mid_prefill_enabled: + return _PrefillContext( + request_id=request.request_id, + loop_label=loop_label, + phase=phase, + conversion_eligible=False, + started_at=None, + conversion_attempted=False, + ) + started_at = request.prefill_started_at + telemetry_eligible = bool( + getattr(self, "_turboquant_mid_prefill", False) + and getattr(self, "_turboquant_kv_bits", None) is not None + and phase is _PrefillKVPhase.DENSE + and conversion_eligible + ) + if telemetry_eligible and started_at is None: + started_at = time.perf_counter() + request.prefill_started_at = started_at + return _PrefillContext( + request_id=request.request_id, + loop_label=loop_label, + phase=phase, + conversion_eligible=conversion_eligible, + started_at=started_at, + conversion_attempted=request.turboquant_mid_prefill_attempted, + ) + + def _prefill_phase_dtype_size(self, phase: _PrefillKVPhase) -> float | None: + """Return the full-attention KV width for one complete cache phase.""" + if phase is _PrefillKVPhase.TURBOQUANT: + width = getattr(self, "_prefill_tq_kv_dtype_size", None) + else: + width = getattr(self, "_prefill_dense_kv_dtype_size", None) + if isinstance(width, (int, float)) and not isinstance(width, bool): + return float(width) + return None + + def _prefill_transient_tracker_for_phase( + self, phase: _PrefillKVPhase + ) -> PrefillTransientTracker | None: + """Return the history trained under the same resident KV width.""" + if phase is _PrefillKVPhase.TURBOQUANT: + return getattr(self, "_prefill_tq_transient_tracker", None) + return getattr(self, "_prefill_transient_tracker", None) + + def _raise_if_prefill_cancelled( + self, request_id: str, processed_tokens: int + ) -> None: + """Raise the existing prefill-abort signal at a conversion boundary.""" + if request_id not in self._pending_abort_ids: + return + uid = self.request_id_to_uid.get(request_id) + aborted_uids = [uid] if uid is not None else [] + raise _PrefillAbortedError(aborted_uids, processed_tokens) + def _apply_turboquant_kv_empty(self, prompt_cache: list[Any]) -> None: """Replace empty KVCache layers with empty TurboQuantKVCache. @@ -3012,47 +3331,429 @@ def _apply_turboquant_kv_empty(self, prompt_cache: list[Any]) -> None: ) def _apply_turboquant_kv_convert(self, prompt_cache: list[Any]) -> None: - """Convert populated KVCache data to TurboQuantKVCache via from_cache(). - - Called AFTER fp16 prefill completes (or on an SSD-restored fp16 - cache): the completed full-precision KV is quantized once, so prefill - hidden states stay exact and quantization error only enters at - decode-time reads. This is the key difference from #717/#771, which - quantized on the fly during prefill and corrupted hidden states. - """ + """Convert populated KVCache data with the ordinary final-only path.""" from mlx_lm.models.cache import CacheList, KVCache from mlx_vlm.turboquant import TurboQuantKVCache kv_indices = [ - i for i, c in enumerate(prompt_cache) if _is_turboquant_kv_family_cache(c) + index + for index, cache_obj in enumerate(prompt_cache) + if _is_turboquant_kv_family_cache(cache_obj) ] skip_last = self._turboquant_skip_last and len(kv_indices) > 1 - last_kv_idx = kv_indices[-1] if skip_last else -1 + last_kv_index = kv_indices[-1] if skip_last else -1 converted = 0 bits = float(self._turboquant_kv_bits) - for i, cache_obj in enumerate(prompt_cache): + for index, cache_obj in enumerate(prompt_cache): if isinstance(cache_obj, KVCache): - if i == last_kv_idx: + if index == last_kv_index: continue - prompt_cache[i] = TurboQuantKVCache.from_cache(cache_obj, bits=bits) + prompt_cache[index] = TurboQuantKVCache.from_cache( + cache_obj, + bits=bits, + ) converted += 1 elif isinstance(cache_obj, CacheList): new_caches = [] - for c in cache_obj.caches: - if isinstance(c, KVCache): - new_caches.append(TurboQuantKVCache.from_cache(c, bits=bits)) + for inner_cache in cache_obj.caches: + if isinstance(inner_cache, KVCache): + new_caches.append( + TurboQuantKVCache.from_cache(inner_cache, bits=bits) + ) converted += 1 else: - new_caches.append(c) + new_caches.append(inner_cache) cache_obj.caches = tuple(new_caches) if converted > 0: - skip_msg = ", skipped last KVCache layer" if skip_last else "" + skip_message = ", skipped last KVCache layer" if skip_last else "" logger.info( - f"TurboQuant: converted {converted}/{len(prompt_cache)} " - f"cache layers to {bits}-bit{skip_msg}" + "TurboQuant: converted %d/%d cache layers to %s-bit%s", + converted, + len(prompt_cache), + bits, + skip_message, ) + def _apply_turboquant_kv_convert_sliced( + self, + prompt_cache: list[Any], + *, + check_cancelled: Callable[[], None] | None = None, + log_result: bool = True, + ) -> TurboQuantConversionStats: + """Convert populated dense KV in bounded slices, one layer at a time.""" + raw_bits = getattr(self, "_turboquant_kv_bits", None) + if not isinstance(raw_bits, (int, float)) or isinstance(raw_bits, bool): + raise ValueError("TurboQuant KV bit width is not configured") + stats = convert_kv_cache_sliced( + prompt_cache, + bits=float(raw_bits), + skip_last=bool(getattr(self, "_turboquant_skip_last", True)), + slice_tokens=TURBOQUANT_CONVERSION_SLICE_TOKENS, + stream=getattr(self, "_stream", None), + check_cancelled=check_cancelled, + ) + if log_result and stats.converted_layers > 0: + skip_message = ( + ", skipped last KVCache layer" if stats.skipped_dense_layers > 0 else "" + ) + logger.info( + "TurboQuant: converted %d/%d cache layers to %s-bit%s", + stats.converted_layers, + len(prompt_cache), + float(raw_bits), + skip_message, + ) + return stats + + def _run_guarded_turboquant_conversion( + self, + *, + request: "Request", + prompt_cache: list[Any], + processed_tokens: int, + safety_cap: int, + conversion_label: str, + log_result: bool, + ) -> _GuardedTurboQuantConversion: + """Reserve and run one conversion under the process-wide exclusive gate.""" + result: _GuardedTurboQuantConversion | None = None + with ( + _conversion_coordinator.conversion( + process_owner=getattr(self, "_metal_process_owner", None) + ) as conversion_owner, + _mx_buffer_access_lock, + ): + before = self._current_usage_bytes() + conversion_peak = 0 + estimate_traceback: str | None = None + try: + raw_bits = getattr(self, "_turboquant_kv_bits", None) + if not isinstance(raw_bits, (int, float)) or isinstance(raw_bits, bool): + raise ValueError("TurboQuant KV bit width is not configured") + conversion_peak = estimate_turboquant_conversion_peak_bytes( + prompt_cache, + bits=float(raw_bits), + skip_last=bool(getattr(self, "_turboquant_skip_last", True)), + slice_tokens=TURBOQUANT_CONVERSION_SLICE_TOKENS, + ) + except Exception as exc: + estimate_traceback = _format_and_detach_exception(exc) + del exc + + if estimate_traceback is not None: + logger.error( + "TurboQuant %s conversion estimate failed for %s:\n%s", + conversion_label, + request.request_id, + estimate_traceback, + ) + self._reclaim_failed_turboquant_conversion( + request, + prompt_cache, + ) + raise PrefillMemoryExceededError( + message=( + f"TurboQuant {conversion_label} conversion could not " + "be bounded; the request cache was discarded" + ), + request_id=request.request_id, + estimated_bytes=before, + limit_bytes=safety_cap or None, + ) from None + + accepted, estimated_peak = _conversion_coordinator.try_reserve( + conversion_owner, + current_bytes=before, + peak_bytes=conversion_peak, + limit_bytes=safety_cap, + ) + if not accepted: + self._reclaim_failed_turboquant_conversion( + request, + prompt_cache, + ) + raise PrefillMemoryExceededError( + message=( + f"TurboQuant {conversion_label} conversion would " + f"exceed the prefill safety cap at " + f"{processed_tokens} tokens" + ), + request_id=request.request_id, + estimated_bytes=int(estimated_peak), + limit_bytes=int(safety_cap), + ) from None + + if conversion_label == "mid-prefill": + logger.info( + "TurboQuant mid-prefill trigger for %s at %d tokens " + "(usage=%.3fGiB, conversion_peak=%.3fGiB, " + "safety_cap=%.3fGiB)", + request.request_id, + processed_tokens, + before / 1024**3, + conversion_peak / 1024**3, + safety_cap / 1024**3, + ) + + def _check_cancelled() -> None: + self._raise_if_prefill_cancelled( + request.request_id, + processed_tokens, + ) + + started = time.perf_counter() + stats: TurboQuantConversionStats | None = None + completed_at: float | None = None + after = before + failure_traceback: str | None = None + failure_message: str | None = None + aborted_uids: list[int] | None = None + aborted_tokens = processed_tokens + try: + stats = self._apply_turboquant_kv_convert_sliced( + prompt_cache, + check_cancelled=_check_cancelled, + log_result=log_result, + ) + converted_phase, _ = self._classify_prefill_cache(prompt_cache) + if converted_phase is not _PrefillKVPhase.TURBOQUANT: + raise RuntimeError( + f"conversion ended in incomplete cache phase " + f"{converted_phase.value}" + ) + gc.collect() + _sync_and_clear_cache(self._stream) + completed_at = time.perf_counter() + _conversion_coordinator.release_reservation(conversion_owner) + after = self._current_usage_bytes() + if safety_cap > 0 and after > safety_cap: + failure_message = ( + f"TurboQuant {conversion_label} converted cache " + "remained above the prefill safety cap" + ) + except _PrefillAbortedError as exc: + aborted_uids = list(exc.aborted_uids) + aborted_tokens = int(exc.processed_tokens) + _format_and_detach_exception(exc) + del exc + except Exception as exc: + failure_traceback = _format_and_detach_exception(exc) + failure_message = f"TurboQuant {conversion_label} conversion failed" + del exc + + if aborted_uids is not None: + stats = None + completed_at = None + self._reclaim_failed_turboquant_conversion( + request, + prompt_cache, + ) + raise _PrefillAbortedError( + aborted_uids, + aborted_tokens, + ) from None + + if failure_message is not None: + if failure_traceback is not None: + logger.error( + "TurboQuant %s conversion failed for %s:\n%s", + conversion_label, + request.request_id, + failure_traceback, + ) + stats = None + completed_at = None + self._reclaim_failed_turboquant_conversion( + request, + prompt_cache, + ) + raise PrefillMemoryExceededError( + message=(f"{failure_message}; the request cache was discarded"), + request_id=request.request_id, + estimated_bytes=int(estimated_peak), + limit_bytes=safety_cap or None, + ) from None + + if stats is None or completed_at is None: + raise RuntimeError("TurboQuant conversion produced no result") + result = _GuardedTurboQuantConversion( + stats=stats, + before_bytes=before, + after_bytes=after, + estimated_peak_bytes=int(estimated_peak), + conversion_seconds=completed_at - started, + completed_at=completed_at, + ) + + if result is None: + raise RuntimeError("TurboQuant conversion gate produced no result") + return result + + def _attempt_mid_prefill_conversion( + self, + *, + request: "Request", + prompt_cache: list[Any], + context: _PrefillContext, + processed_tokens: int, + safety_cap: int, + ) -> None: + """Run the request's sole pressure conversion or fail the request.""" + request.turboquant_mid_prefill_attempted = True + context.conversion_attempted = True + context.mid_triggered = True + context.trigger_tokens = processed_tokens + result = self._run_guarded_turboquant_conversion( + request=request, + prompt_cache=prompt_cache, + processed_tokens=processed_tokens, + safety_cap=safety_cap, + conversion_label="mid-prefill", + log_result=False, + ) + context.phase = _PrefillKVPhase.TURBOQUANT + context.conversion_eligible = False + context.converted_layers = result.stats.converted_layers + context.conversion_slices = result.stats.slices + context.skipped_dense_layers = result.stats.skipped_dense_layers + context.memory_before_bytes = result.before_bytes + context.memory_after_bytes = result.after_bytes + context.conversion_seconds = result.conversion_seconds + context.conversion_completed_at = result.completed_at + + def _mid_prefill_conversion_available( + self, + context: _PrefillContext | None, + prompt_cache: list[Any] | None, + request: "Request | None", + ) -> bool: + """Return whether this dense request still owns its one trigger.""" + if context is None: + return False + return bool( + getattr(self, "_turboquant_mid_prefill", False) + and getattr(self, "_turboquant_kv_bits", None) is not None + and context.phase is _PrefillKVPhase.DENSE + and context.conversion_eligible + and not context.conversion_attempted + and _conversion_coordinator.process_exclusive( + getattr(self, "_metal_process_owner", None) + ) + and request is not None + and not request.turboquant_mid_prefill_attempted + and ( + prompt_cache is None + or self._has_populated_turboquant_target(prompt_cache) + ) + ) + + def _can_defer_mid_prefill_preflight( + self, + phase: _PrefillKVPhase, + *, + request: "Request | None" = None, + conversion_eligible: bool | None = None, + ) -> bool: + """Let an unspent eligible conversion decide dense peak rejection.""" + return bool( + getattr(self, "_turboquant_mid_prefill", False) + and getattr(self, "_turboquant_kv_bits", None) is not None + and getattr(self, "_prefill_tq_kv_dtype_size", None) is not None + and _conversion_coordinator.process_exclusive( + getattr(self, "_metal_process_owner", None) + ) + and phase is _PrefillKVPhase.DENSE + and conversion_eligible is True + and (request is None or not request.turboquant_mid_prefill_attempted) + ) + + def _finalize_turboquant_prefill_cache( + self, + request: "Request", + prompt_cache: list[Any], + *, + processed_tokens: int, + context: _PrefillContext | None = None, + ) -> TurboQuantConversionStats | None: + """Complete ordinary post-prefill TurboQuant conversion.""" + if getattr(self, "_turboquant_kv_bits", None) is None: + return None + if not getattr(self, "_turboquant_mid_prefill", False): + if self._turboquant_eligible(prompt_cache): + self._apply_turboquant_kv_convert(prompt_cache) + return None + phase, _ = self._classify_prefill_cache(prompt_cache) + if phase is _PrefillKVPhase.UNSUPPORTED: + return None + if phase is _PrefillKVPhase.TURBOQUANT: + if context is not None: + context.phase = phase + return None + if phase is _PrefillKVPhase.INVALID_PARTIAL: + self._discard_failed_prefill_cache(request, prompt_cache) + raise PrefillMemoryExceededError( + message=( + "TurboQuant prefill cache was partially converted; the " + "request cache was discarded" + ), + request_id=request.request_id, + estimated_bytes=self._current_usage_bytes(), + limit_bytes=self._prefill_abort_cap() or None, + ) + + result = self._run_guarded_turboquant_conversion( + request=request, + prompt_cache=prompt_cache, + processed_tokens=processed_tokens, + safety_cap=self._prefill_abort_cap(), + conversion_label="post-prefill", + log_result=True, + ) + if context is not None: + context.phase = _PrefillKVPhase.TURBOQUANT + context.conversion_eligible = False + return result.stats + + def _log_mid_prefill_summary(self, context: _PrefillContext) -> None: + """Emit one completion record for an actual pressure conversion.""" + if not context.mid_triggered or context.summary_logged: + return + context.summary_logged = True + completed_at = time.perf_counter() + total_wall = ( + completed_at - context.started_at if context.started_at is not None else 0.0 + ) + post_trigger_wall = ( + completed_at - context.conversion_completed_at + if context.conversion_completed_at is not None + else 0.0 + ) + throughput = ( + context.post_trigger_tokens / post_trigger_wall + if post_trigger_wall > 0 + else 0.0 + ) + logger.info( + "TurboQuant mid-prefill complete for %s " + "(trigger_tokens=%d, conversion_pause=%.3fs, " + "post_trigger_tokens=%d, post_trigger_prefill_tps=%.2f tok/s, " + "total_wall=%.3fs, memory_before=%.3fGiB, memory_after=%.3fGiB, " + "layers=%d, slices=%d, skipped_dense=%d)", + context.request_id, + context.trigger_tokens or 0, + context.conversion_seconds, + context.post_trigger_tokens, + throughput, + total_wall, + context.memory_before_bytes / 1024**3, + context.memory_after_bytes / 1024**3, + context.converted_layers, + context.conversion_slices, + context.skipped_dense_layers, + ) + def _do_external_prefill( self, request: "Request", @@ -3095,7 +3796,11 @@ def _do_external_prefill( if existing_cache is None: self._apply_turboquant_kv_empty(cache) else: - self._apply_turboquant_kv_convert(cache) + self._finalize_turboquant_prefill_cache( + request, + cache, + processed_tokens=0, + ) return cache, tokens # Create or reuse cache @@ -3103,6 +3808,11 @@ def _do_external_prefill( prompt_cache = existing_cache else: prompt_cache = make_prompt_cache(self.model) + prefill_context = self._new_prefill_context( + request, + prompt_cache, + loop_label="external", + ) # Fresh TurboQuant requests run fp16 during the cold prefill loop and # are quantized once at the end. Restored TurboQuant prefix caches stay @@ -3214,6 +3924,7 @@ def _do_external_prefill( request_id=request.request_id, loop_label="external", kv_len=base_size + processed_tokens, + prefill_context=prefill_context, ) # Pre-chunk safety guard: NEVER submit a chunk whose predicted peak @@ -3228,6 +3939,9 @@ def _do_external_prefill( progress=processed_tokens, loop_label="external", request_id=request.request_id, + request=request, + prompt_cache=prompt_cache, + prefill_context=prefill_context, ) _throttle_pre = get_phys_footprint() @@ -3249,6 +3963,10 @@ def _do_external_prefill( model_kwargs["vlm_extra_kwargs"] = _slice_vlm_extra( extra_kwargs, n_to_process ) + measure_post_trigger = ( + prefill_context.mid_triggered + and prefill_context.phase is _PrefillKVPhase.TURBOQUANT + ) self.model( input_arr[:, :n_to_process], cache=prompt_cache, @@ -3269,7 +3987,10 @@ def _do_external_prefill( loop_label="external", kv_len=base_size + processed_tokens, requested_step=prefill_step_size, + phase=prefill_context.phase, ) + if measure_post_trigger: + prefill_context.post_trigger_tokens += n_to_process self._maybe_record_fixed_state_bytes(prompt_cache) # Enforcer-requested hard-pressure drain. The flag's normal # consumption point is the end-of-step cleanup, but this loop @@ -3353,9 +4074,7 @@ def _do_external_prefill( # band by design — the per-chunk notice is DEBUG there, # not a warning about an unexpected state. _log = ( - logger.debug - if self._prefill_speed_priority - else logger.warning + logger.debug if self._prefill_speed_priority else logger.warning ) _log( f"Prefill above max_bytes at " @@ -3419,14 +4138,17 @@ def _do_external_prefill( # format is unchanged. _merge_caches() then builds a # BatchTurboQuantKVCache when this request is inserted. Gated to dense # KVCache models — chunked/rotating caches stay fp16. - if self._turboquant_kv_bits is not None and self._turboquant_eligible( - prompt_cache - ): - self._apply_turboquant_kv_convert(prompt_cache) + self._finalize_turboquant_prefill_cache( + request, + prompt_cache, + processed_tokens=processed_tokens, + context=prefill_context, + ) if getattr(request, "cached_tokens", 0) > 0: with mx.stream(self._stream): _materialize_cache_storage(prompt_cache) + self._log_mid_prefill_summary(prefill_context) return prompt_cache, last_token @@ -3460,7 +4182,13 @@ def _do_external_prefill( _MEMORY_ADMISSION_STALL_TIMEOUT_S: float = 60.0 _STORE_CACHE_ADMISSION_STALL_TIMEOUT_S: float = 60.0 - def _predicted_chunk_transient(self, n_tokens: int, kv_len: int) -> float: + def _predicted_chunk_transient( + self, + n_tokens: int, + kv_len: int, + *, + phase: _PrefillKVPhase = _PrefillKVPhase.DENSE, + ) -> float: """Conservative predicted Metal peak growth for one prefill chunk. The per-chunk SDPA/MoE transient scales with ``query_len * kv_len``, so @@ -3478,7 +4206,17 @@ def _predicted_chunk_transient(self, n_tokens: int, kv_len: int) -> float: if n_tokens <= 0: return 0.0 per_token = 0.0 - tracker = self._prefill_transient_tracker + tracker_selector = getattr( + self, + "_prefill_transient_tracker_for_phase", + None, + ) + if callable(tracker_selector): + tracker = tracker_selector(phase) + elif phase is _PrefillKVPhase.TURBOQUANT: + tracker = getattr(self, "_prefill_tq_transient_tracker", None) + else: + tracker = getattr(self, "_prefill_transient_tracker", None) if tracker is not None: if tracker.last_n_tokens > 0 and tracker.last_delta_bytes > 0: per_token = max( @@ -3490,11 +4228,42 @@ def _predicted_chunk_transient(self, n_tokens: int, kv_len: int) -> float: static = self.memory_monitor.estimate_chunk_transient_bytes( n_tokens, kv_len + n_tokens ) - static += self.memory_monitor.estimate_prompt_kv_bytes(n_tokens) + if phase is _PrefillKVPhase.TURBOQUANT: + raw_bits = getattr(self, "_turboquant_kv_bits", None) + tq_workspace_estimator = getattr( + self.memory_monitor, + "estimate_turboquant_prefill_attention_bytes", + None, + ) + if ( + isinstance(raw_bits, (int, float)) + and not isinstance(raw_bits, bool) + and callable(tq_workspace_estimator) + ): + static = max( + static, + tq_workspace_estimator( + n_tokens, + kv_len + n_tokens, + bits=float(raw_bits), + ), + ) + width_selector = getattr(self, "_prefill_phase_dtype_size", None) + dtype_size = width_selector(phase) if callable(width_selector) else None + static += self.memory_monitor.estimate_prompt_kv_bytes( + n_tokens, + dtype_size=dtype_size, + ) per_token = max(per_token, float(static) / n_tokens) return per_token * n_tokens * self._PREFILL_TRANSIENT_SAFETY - def _admission_transient_bound(self, n_tokens: int, kv_len: int) -> float: + def _admission_transient_bound( + self, + n_tokens: int, + kv_len: int, + *, + phase: _PrefillKVPhase = _PrefillKVPhase.DENSE, + ) -> float: """Transient charge for admission and the guard's pass/abort gates. The largest FLOOR-SIZE chunk transient observed this session is a @@ -3514,8 +4283,22 @@ def _admission_transient_bound(self, n_tokens: int, kv_len: int) -> float: shrink arithmetic stay on ``_predicted_chunk_transient``; a flat size-invariant bound would zero out their proportional response. """ - bound = self._predicted_chunk_transient(n_tokens, kv_len) - tracker = self._prefill_transient_tracker + bound = self._predicted_chunk_transient( + n_tokens, + kv_len, + phase=phase, + ) + tracker_selector = getattr( + self, + "_prefill_transient_tracker_for_phase", + None, + ) + if callable(tracker_selector): + tracker = tracker_selector(phase) + elif phase is _PrefillKVPhase.TURBOQUANT: + tracker = getattr(self, "_prefill_tq_transient_tracker", None) + else: + tracker = getattr(self, "_prefill_transient_tracker", None) if tracker is not None: bound = max(bound, float(tracker.observed_max_bytes)) return bound @@ -3530,6 +4313,20 @@ def _prefill_abort_cap(self) -> int: cap = self._memory_abort_limit_bytes or self._memory_hard_limit_bytes return int(cap * self._prefill_abort_margin) if cap > 0 else 0 + def _prefill_sizing_target(self) -> int: + """Return the existing reserve-aware chunk-sizing pressure boundary.""" + hard_cap = self._memory_hard_limit_bytes + if hard_cap <= 0: + return 0 + headroom_safety = getattr( + self, + "_prefill_headroom_safety", + self._PREFILL_HEADROOM_SAFETY, + ) + target = int(hard_cap * headroom_safety) + abort_cap = self._prefill_abort_cap() + return min(target, abort_cap) if abort_cap > 0 else target + def _admission_limit_bytes(self) -> int: """The line admission estimates must stay under. @@ -3581,13 +4378,7 @@ def _sdpa256_unfused_headroom(self) -> int: ) return _SDPA256_UNBOUNDED_HEADROOM return -1 - headroom_safety = getattr( - self, "_prefill_headroom_safety", self._PREFILL_HEADROOM_SAFETY - ) - target = int(hard_cap * headroom_safety) - abort_cap = self._prefill_abort_cap() - if abort_cap > 0: - target = min(target, abort_cap) + target = self._prefill_sizing_target() return target - self._current_usage_bytes() _MAX_PREFILL_EVICTION_RETRIES = 1 @@ -3601,11 +4392,14 @@ def _raise_prefill_eviction_if_available( predicted_transient: int, requested_tokens: int, reason: str, + processed_tokens: int = 0, ) -> None: """Pause a request once so EngineCore can evict idle LRU models.""" request = self.requests.get(request_id) if request is None: return + if getattr(self, "_prefill_eviction_callback_configured", None) is False: + return max_retries = getattr( self, "_MAX_PREFILL_EVICTION_RETRIES", @@ -3626,6 +4420,7 @@ def _raise_prefill_eviction_if_available( predicted_transient_bytes=int(predicted_transient), requested_tokens=int(requested_tokens), reason=reason, + processed_tokens=max(0, int(processed_tokens)), ) logger.info( "Request %s needs prefill headroom before throttling " @@ -3646,34 +4441,125 @@ def _guard_prefill_chunk( progress: int, loop_label: str, request_id: str | None = None, + request: "Request | None" = None, + prompt_cache: list[Any] | None = None, + prefill_context: _PrefillContext | None = None, ) -> int: - """Clamp/abort a prefill chunk so its predicted peak can never reach - the physical Metal cap (the uncatchable async OOM crash). - - Returns a chunk size whose predicted peak fits under the margined cap - (possibly shrunk from ``n_tokens``). If even the minimum chunk would - not fit after a reclaim, raises a clean RuntimeError — the context is - genuinely too large for available memory. That message intentionally - does NOT contain "Memory limit exceeded", so ``_requeue_or_fail_prefill`` - fails it fast with a clear error rather than looping a doomed retry. - """ + """Return a chunk whose predicted peak stays below the physical cap.""" base_cap, cap, margin = self._prefill_abort_description() if cap <= 0: return n_tokens - # Speed priority: no shrinking — the floor IS the full chunk, so the - # abort gate below charges the full-step transient and the shrink - # math degenerates to returning n_tokens unchanged. + trigger_target = self._prefill_sizing_target() or cap if self._prefill_speed_priority: min_chunk = n_tokens else: min_chunk = max(1, self._prefill_min_chunk_tokens) + phase = ( + prefill_context.phase + if prefill_context is not None + else _PrefillKVPhase.DENSE + ) current = self._current_usage_bytes() - if current + self._admission_transient_bound(n_tokens, kv_len) <= cap: + full_transient = self._admission_transient_bound( + n_tokens, + kv_len, + phase=phase, + ) + if current + full_transient <= trigger_target: return n_tokens - # Predicted to breach — reclaim transients and re-measure once. + # Reclaim before either conversion or a final-cap decision. current = self._reclaim_prefill_headroom() - min_transient = self._admission_transient_bound(min_chunk, kv_len) + if current + full_transient <= trigger_target: + return n_tokens + + request_obj = request + if request_obj is None and request_id is not None: + request_obj = getattr(self, "requests", {}).get(request_id) + conversion_available = getattr( + self, + "_mid_prefill_conversion_available", + None, + ) + if callable(conversion_available) and conversion_available( + prefill_context, + prompt_cache, + request_obj, + ): + maybe_raise_eviction = getattr( + self, "_raise_prefill_eviction_if_available", None + ) + if request_id is not None and callable(maybe_raise_eviction): + maybe_raise_eviction( + request_id=request_id, + current=current, + target_cap=trigger_target, + predicted_transient=int(full_transient), + requested_tokens=n_tokens, + reason="turboquant_mid_prefill", + processed_tokens=progress, + ) + if ( + request_obj is not None + and prompt_cache is not None + and prefill_context is not None + ): + self._attempt_mid_prefill_conversion( + request=request_obj, + prompt_cache=prompt_cache, + context=prefill_context, + processed_tokens=progress, + safety_cap=cap, + ) + resized = self._adaptive_chunk_size( + n_tokens, + request_id=request_id or prefill_context.request_id, + loop_label=loop_label, + kv_len=kv_len, + prefill_context=prefill_context, + ) + return self._guard_prefill_chunk( + resized, + kv_len=kv_len, + progress=progress, + loop_label=loop_label, + request_id=request_id, + request=request_obj, + prompt_cache=prompt_cache, + prefill_context=prefill_context, + ) + + if current + full_transient <= cap: + # Adaptive sizing may have preserved this candidate solely so the + # guard could convert it. If the live cache is still empty or no + # longer eligible, restore ordinary target-based sizing. + resized = self._adaptive_chunk_size( + n_tokens, + request_id=request_id + or (prefill_context.request_id if prefill_context is not None else ""), + loop_label=loop_label, + kv_len=kv_len, + prefill_context=prefill_context, + defer_mid_prefill_conversion=False, + ) + if resized < n_tokens: + return self._guard_prefill_chunk( + resized, + kv_len=kv_len, + progress=progress, + loop_label=loop_label, + request_id=request_id, + request=request_obj, + prompt_cache=prompt_cache, + prefill_context=prefill_context, + ) + return n_tokens + + min_transient = self._admission_transient_bound( + min_chunk, + kv_len, + phase=phase, + ) if current + min_transient > cap: maybe_raise_eviction = getattr( self, "_raise_prefill_eviction_if_available", None @@ -3701,8 +4587,6 @@ def _guard_prefill_chunk( ) binding_str, advice = describe_ceiling_binding( static=self._memory_static_ceiling_bytes, - # See _preflight_safety_rejection: the abort limit this cap - # derives from is min(static, metal_cap) by design. dynamic=( 0 if self._memory_abort_limit_bytes @@ -3729,13 +4613,16 @@ def _guard_prefill_chunk( limit_bytes=int(cap), ) - # The floor fits — pick the largest chunk that still fits under the cap. - per_token = self._predicted_chunk_transient(n_tokens, kv_len) / n_tokens + per_token = ( + self._predicted_chunk_transient( + n_tokens, + kv_len, + phase=phase, + ) + / n_tokens + ) safe_n = int((cap - current) / per_token) if per_token > 0 else n_tokens n_fit = max(min_chunk, min(n_tokens, safe_n)) - # Same quantization as the adaptive throttle: an off-grid size here - # would reintroduce the near-miss buffers _snap_chunk_size exists to - # avoid. n_fit = self._snap_chunk_size(n_fit, n_tokens) if n_fit < n_tokens: logger.debug( @@ -3785,6 +4672,8 @@ def _adaptive_chunk_size( request_id: str, loop_label: str, kv_len: int = 0, + prefill_context: _PrefillContext | None = None, + defer_mid_prefill_conversion: bool = True, ) -> int: """Size the next prefill chunk so its predicted peak stays under a safety margin below the hard cap. @@ -3841,18 +4730,24 @@ def _adaptive_chunk_size( # safety) — see _predicted_chunk_transient. Anchored on the most recent # measurement so it tracks growth with kv_len instead of lagging behind # a long-run average. - per_token = self._predicted_chunk_transient(requested, kv_len) / requested + phase = ( + prefill_context.phase + if prefill_context is not None + else _PrefillKVPhase.DENSE + ) + per_token = ( + self._predicted_chunk_transient( + requested, + kv_len, + phase=phase, + ) + / requested + ) predictor = "measured" if per_token > 0 else "none" - # Keep each chunk's predicted peak under the LOWER of the dynamic - # throttle target and the prefill safety cap, so the peak can never - # reach the Metal wall (the uncatchable async OOM). - headroom_safety = getattr( - self, "_prefill_headroom_safety", self._PREFILL_HEADROOM_SAFETY - ) - safe_target = int(hard_cap * headroom_safety) - abort_cap = self._prefill_abort_cap() - target = min(safe_target, abort_cap) if abort_cap > 0 else safe_target + # Use the same reserve-aware target as the mid-prefill trigger. The + # stable abort cap remains the final pass/reject boundary. + target = self._prefill_sizing_target() soft_watermark = int(soft_base * self._prefill_safe_zone_ratio) if per_token <= 0: @@ -3884,6 +4779,24 @@ def _adaptive_chunk_size( requested_tokens=requested, reason="adaptive_prefill_throttle", ) + conversion_available = getattr( + self, + "_mid_prefill_conversion_available", + None, + ) + if ( + defer_mid_prefill_conversion + and callable(conversion_available) + and conversion_available( + prefill_context, + None, + getattr(self, "requests", {}).get(request_id), + ) + ): + # The one-shot LRU call above has had its chance. Preserve the + # full candidate so the common guard can remeasure, require + # populated history, and run the request's sole conversion. + return requested if self._prefill_speed_priority: # Speed priority: never shrink. Idle-model eviction above # still gets its chance to free memory; beyond that, the @@ -3956,7 +4869,7 @@ def _adaptive_chunk_size( predictor, per_token / 1024, current / 1024**3, - safe_target / 1024**3, + target / 1024**3, hard_cap / 1024**3, kv_len, band_ratio, @@ -3998,6 +4911,8 @@ def _current_usage_bytes(self, *, refresh_mlx_active: bool = True) -> int: Scheduler steps run on the MLX executor thread, so they can refresh mx.get_active_memory() safely. Event-loop callers such as early preflight use the cached executor sample and phys_footprint instead. + Every caller also sees a process-wide conversion peak reservation; + the conversion holder releases its reservation before its post sample. """ active = self._last_mlx_active_memory_bytes if refresh_mlx_active: @@ -4009,7 +4924,7 @@ def _current_usage_bytes(self, *, refresh_mlx_active: bool = True) -> int: else: hot_cache_bytes = Scheduler._hot_cache_cpu_bytes(self) phys = max(0, int(get_phys_footprint()) - hot_cache_bytes) - return max(active, phys) + return max(active, phys) + _conversion_coordinator.outstanding_bytes() def get_active_hot_cache_block_hashes(self) -> set[bytes]: """Return hot-cache block hashes owned by active in-flight requests.""" @@ -4216,6 +5131,7 @@ def _record_chunk_transient( loop_label: str, kv_len: int = 0, requested_step: int | None = None, + phase: _PrefillKVPhase = _PrefillKVPhase.DENSE, ) -> None: """Feed one chunk's measured transient into the EWMA tracker. @@ -4274,9 +5190,20 @@ def _record_chunk_transient( requested_step, ) return - self._prefill_transient_tracker.update( - n_tokens, delta, floor_sample=n_tokens <= min_chunk + tracker_selector = getattr( + self, + "_prefill_transient_tracker_for_phase", + None, ) + if callable(tracker_selector): + tracker = tracker_selector(phase) + elif phase is _PrefillKVPhase.TURBOQUANT: + tracker = getattr(self, "_prefill_tq_transient_tracker", None) + else: + tracker = getattr(self, "_prefill_transient_tracker", None) + if tracker is None: + return + tracker.update(n_tokens, delta, floor_sample=n_tokens <= min_chunk) logger.debug( "[throttle:%s] measure rid=%s n=%d kv_len=%d transient=%.2fMB per_token=%.1fKB ewma=%.1fKB observed_max=%.1fMB samples=%d", loop_label, @@ -4285,9 +5212,9 @@ def _record_chunk_transient( kv_len, delta / 1024**2, (delta / max(n_tokens, 1)) / 1024, - self._prefill_transient_tracker.bytes_per_token / 1024, - self._prefill_transient_tracker.observed_max_bytes / 1024**2, - self._prefill_transient_tracker.samples, + tracker.bytes_per_token / 1024, + tracker.observed_max_bytes / 1024**2, + tracker.samples, ) def _maybe_record_fixed_state_bytes(self, cache_list: Any) -> None: @@ -4322,6 +5249,18 @@ def _walk(c: Any) -> int: self._fixed_state_recorded = True if total > 0: self.memory_monitor.set_fixed_state_bytes(total) + manager = getattr(self, "paged_ssd_cache_manager", None) + resize_writer_queue = getattr( + manager, + "set_expected_block_payload_bytes", + None, + ) + if callable(resize_writer_queue): + block_tokens = max(1, int(self.config.paged_cache_block_size)) + payload = self.memory_monitor.estimate_paged_writer_block_memory( + block_tokens + ) + resize_writer_queue(math.ceil(payload)) logger.debug( "Fixed recurrent state measured: %.1fMB per sequence", total / 1024**2, @@ -4404,6 +5343,11 @@ def _begin_prefill( if existing_cache is not None else make_prompt_cache(self.model) ) + prefill_context = self._new_prefill_context( + request, + prompt_cache, + loop_label="chunked_step", + ) block_size = self.config.paged_cache_block_size boundary_enabled = ( @@ -4444,6 +5388,7 @@ def _begin_prefill( boundary_enabled=boundary_enabled, block_size=block_size, total_length=len(tokens), + prefill_context=prefill_context, ) def _step_prefill_chunk(self, state: _PrefillState) -> bool: @@ -4489,6 +5434,7 @@ def _step_prefill_chunk(self, state: _PrefillState) -> bool: request_id=state.request.request_id, loop_label="chunked_step", kv_len=state.base_size + state.tokens_processed, + prefill_context=state.prefill_context, ) # Pre-chunk safety guard (mirrors the external loop): never submit a @@ -4499,6 +5445,9 @@ def _step_prefill_chunk(self, state: _PrefillState) -> bool: progress=state.tokens_processed, loop_label="chunked_step", request_id=state.request.request_id, + request=state.request, + prompt_cache=state.cache, + prefill_context=state.prefill_context, ) _throttle_pre = get_phys_footprint() @@ -4509,18 +5458,37 @@ def _step_prefill_chunk(self, state: _PrefillState) -> bool: with mx.stream(self._stream): chunk = state.tokens_remaining[:, :n] state.tokens_remaining = state.tokens_remaining[:, n:] + measure_post_trigger = ( + state.prefill_context is not None + and state.prefill_context.mid_triggered + and state.prefill_context.phase is _PrefillKVPhase.TURBOQUANT + ) self.model(chunk, cache=state.cache) mx.eval([c.state for c in state.cache]) _throttle_post = get_phys_footprint() - self._record_chunk_transient( - n, - _throttle_pre, - _throttle_post, - request_id=state.request.request_id, - loop_label="chunked_step", - kv_len=state.base_size + state.tokens_processed, - requested_step=prefill_step_size, - ) + if state.prefill_context is None: + self._record_chunk_transient( + n, + _throttle_pre, + _throttle_post, + request_id=state.request.request_id, + loop_label="chunked_step", + kv_len=state.base_size + state.tokens_processed, + requested_step=prefill_step_size, + ) + else: + self._record_chunk_transient( + n, + _throttle_pre, + _throttle_post, + request_id=state.request.request_id, + loop_label="chunked_step", + kv_len=state.base_size + state.tokens_processed, + requested_step=prefill_step_size, + phase=state.prefill_context.phase, + ) + if measure_post_trigger and state.prefill_context is not None: + state.prefill_context.post_trigger_tokens += n self._maybe_record_fixed_state_bytes(state.cache) state.tokens_processed += n @@ -4546,11 +5514,7 @@ def _step_prefill_chunk(self, state: _PrefillState) -> bool: state.request.request_id, state.tokens_processed, state.total_length - 1, - ( - self.config.model_name - if self.config.model_name - else "" - ), + (self.config.model_name if self.config.model_name else ""), ) # Memory monitoring — use max(active, phys_footprint) so MLX cache @@ -4603,9 +5567,7 @@ def _step_prefill_chunk(self, state: _PrefillState) -> bool: # Speed priority runs full chunks through this caution band # by design — the per-chunk notice is DEBUG there, not a # warning about an unexpected state. - _log = ( - logger.debug if self._prefill_speed_priority else logger.warning - ) + _log = logger.debug if self._prefill_speed_priority else logger.warning _log( f"Chunked prefill above max_bytes at " f"{state.tokens_processed} tokens: " @@ -4634,15 +5596,22 @@ def _emit_final_boundary_if_needed(self, state: _PrefillState) -> None: ) def _finalize_chunked_prefill_cache_for_insert( - self, request: "Request", prompt_cache: list[Any] | None + self, + request: "Request", + prompt_cache: list[Any] | None, + *, + processed_tokens: int = 0, + prefill_context: _PrefillContext | None = None, ) -> None: - """Mirror external prefill's post-prefill cache epilogue.""" - if not prompt_cache or self._turboquant_kv_bits is None: - return - if not self._turboquant_eligible(prompt_cache): + """Mirror external prefill's typed post-prefill cache epilogue.""" + if not prompt_cache: return - - self._apply_turboquant_kv_convert(prompt_cache) + self._finalize_turboquant_prefill_cache( + request, + prompt_cache, + processed_tokens=processed_tokens, + context=prefill_context, + ) if getattr(request, "cached_tokens", 0) > 0: with mx.stream(self._stream): _materialize_cache_storage(prompt_cache) @@ -4699,9 +5668,18 @@ def _insert_prefilled_request( request.request_id, vlm_mtp_uid, ) + if state.prefill_context is not None: + self._log_mid_prefill_summary(state.prefill_context) return - self._finalize_chunked_prefill_cache_for_insert(request, state.cache) + self._finalize_chunked_prefill_cache_for_insert( + request, + state.cache, + processed_tokens=state.tokens_processed, + prefill_context=state.prefill_context, + ) + if state.prefill_context is not None: + self._log_mid_prefill_summary(state.prefill_context) per_row_lps = state.per_row_lps if state.per_row_lps is not None else [] # insert() merges the prompt cache into the batch KV caches with lazy @@ -4794,7 +5772,7 @@ def _advance_chunked_prefills( still_prefilling.append(request) still_prefilling.extend(pending_prefills[index + 1 :]) logger.info( - "Paused chunked prefill request %s for LRU eviction " "(reason=%s)", + "Paused chunked prefill request %s for LRU eviction (reason=%s)", rid, e.request.reason, ) @@ -4864,7 +5842,24 @@ def _advance_chunked_prefills( # Clean up the prefill-progress tracker entry. get_prefill_tracker().remove(rid) - self._insert_prefilled_request(request, state, scheduled) + try: + self._insert_prefilled_request(request, state, scheduled) + except _PrefillAbortedError: + _sync_and_clear_cache(self._stream) + self._cleanup_prefill_abort_request(request) + continue + except PrefillMemoryExceededError as exc: + logger.error( + "Chunked prefill completion capacity rejected for %s: %s", + rid, + exc, + ) + self._release_paged_cache_for_request(rid) + self.requests.pop(rid, None) + self._clear_request_admission_bookkeeping(rid) + _sync_and_clear_cache(self._stream) + rejected.append(_prefill_memory_exception_output(rid, exc)) + continue self.prefilling = still_prefilling @@ -4934,8 +5929,7 @@ def _buffer_stop_sequence_output( pending_tokens = tuple(token for token, _ in pending) reported_match = tuple( - int(token) - for token in (getattr(response, "match_sequence", None) or ()) + int(token) for token in (getattr(response, "match_sequence", None) or ()) ) matched_sequence = ( reported_match @@ -4967,7 +5961,7 @@ def strip_matched_prefix(text: str) -> str: suppressed_stream_text ): terminal_output.output_text = terminal_output.output_text[ - :-len(suppressed_stream_text) + : -len(suppressed_stream_text) ] else: matched_prefix_tokens = matched_sequence[:-1] @@ -4980,7 +5974,7 @@ def strip_matched_prefix(text: str) -> str: and request.request_id not in self._output_parser_sessions ): terminal_output.output_text = self.tokenizer.decode( - output_token_ids[:-len(matched_prefix_tokens)] + output_token_ids[: -len(matched_prefix_tokens)] ) request.output_text = terminal_output.output_text @@ -5657,6 +6651,7 @@ def _extract_prefill_snapshot_states( pre-extracted marker alongside raw decode-path snapshots (those are already decoupled copies from ``extract_cache``). """ + def _copy_containers(value: Any) -> Any: # ArraysCache.state returns its live slot LIST (not a copy); # the model rebinds slots in place, so container structure @@ -6329,9 +7324,9 @@ def _validate_cache(self, cache: Any) -> bool: expected_cache = make_prompt_cache(self.model) except Exception: expected_cache = None - if isinstance(expected_cache, (list, tuple)) and len( - expected_cache - ) == len(cache): + if isinstance(expected_cache, (list, tuple)) and len(expected_cache) == len( + cache + ): arrays_names = {"ArraysCache", "SizedArraysCache"} for layer_cache, expected_layer in zip(cache, expected_cache): if ( @@ -7246,7 +8241,9 @@ def set_specprefill_draft_model( draft_cache_list, model_name=name, ) - draft_layer_cache_types = draft_model_cache_config.get_type_names() + draft_layer_cache_types = ( + draft_model_cache_config.get_type_names() + ) except Exception as e: logger.debug( "Could not infer SpecPrefill draft cache layout: %s", e @@ -7383,9 +8380,7 @@ def _route_to_vlm_mtp( "logits processors without vlm_mtp support (%s); falling " "back to BatchGenerator", request.request_id, - ", ".join( - type(proc).__name__ for proc in unsupported_processors - ), + ", ".join(type(proc).__name__ for proc in unsupported_processors), ) return None @@ -8052,8 +9047,7 @@ def _process_pending_reclaim(self) -> None: logger.warning(f"Idle reclaim failed: {e}") return logger.info( - "Idle reclaim: trimmed Metal transients between turns " - "(%.1fGB -> %.1fGB)", + "Idle reclaim: trimmed Metal transients between turns (%.1fGB -> %.1fGB)", before / 1024**3, after / 1024**3, ) @@ -8211,9 +9205,7 @@ def has_pending_route_preflight_cleanup(self) -> bool: Active requests are intentionally not included: their memory is live and must remain charged to a concurrent admission. """ - return bool( - self._pending_async_removes or self._deferred_clear_at is not None - ) + return bool(self._pending_async_removes or self._deferred_clear_at is not None) def refresh_route_preflight_usage(self) -> int: """Publish a fresh MLX memory sample for route-level retry. @@ -8387,12 +9379,23 @@ def _preflight_memory_check( prompt_tokens = request.num_prompt_tokens cached_tokens = request.cached_tokens or 0 + phase = _PrefillKVPhase.DENSE + conversion_eligible = self._turboquant_preflight_conversion_eligible + if getattr(self, "_turboquant_mid_prefill", False): + prompt_cache = getattr(request, "prompt_cache", None) + if isinstance(prompt_cache, list): + cache_phase, conversion_eligible = self._classify_prefill_cache( + prompt_cache + ) + if cache_phase is _PrefillKVPhase.TURBOQUANT: + phase = cache_phase current = self._current_usage_bytes() est = self._admission_estimate( num_prompt_tokens=prompt_tokens, cached_tokens=cached_tokens, current=current, + phase=phase, ) if est is None: return None # can't estimate, skip @@ -8415,6 +9418,12 @@ def _preflight_memory_check( requested_tokens=est.floor_chunk, reason="prefill_preflight", ) + if self._can_defer_mid_prefill_preflight( + phase, + request=request, + conversion_eligible=conversion_eligible, + ): + return None message = self._format_rejection_message( estimated=est.estimated, @@ -8442,6 +9451,12 @@ def _preflight_memory_check( requested_tokens=est.floor_chunk, reason="prefill_safety_cap", ) + if self._can_defer_mid_prefill_preflight( + phase, + request=request, + conversion_eligible=conversion_eligible, + ): + return None return safety_rejection return None @@ -8451,6 +9466,7 @@ def _admission_estimate( num_prompt_tokens: int, cached_tokens: int, current: int, + phase: _PrefillKVPhase = _PrefillKVPhase.DENSE, ) -> _AdmissionEstimate | None: """Deterministic admission estimate shared by every preflight path. @@ -8501,9 +9517,19 @@ def _admission_estimate( floor_chunk = min(charge_tokens, prefill_tokens) kv_len = max(int(num_prompt_tokens) - 1 - floor_chunk, 0) kv_exact = int( - monitor.estimate_resident_kv_bytes(new_tokens, chunk_tokens=floor_chunk) + monitor.estimate_resident_kv_bytes( + new_tokens, + chunk_tokens=floor_chunk, + dtype_size=self._prefill_phase_dtype_size(phase), + ) + ) + transient = int( + self._admission_transient_bound( + floor_chunk, + kv_len, + phase=phase, + ) ) - transient = int(self._admission_transient_bound(floor_chunk, kv_len)) if kv_exact <= 0 and transient <= 0: return None return _AdmissionEstimate( @@ -8598,6 +9624,11 @@ def preflight_or_raise( admission_limit = self._admission_limit_bytes() if est.estimated > admission_limit: + if self._can_defer_mid_prefill_preflight( + _PrefillKVPhase.DENSE, + conversion_eligible=self._turboquant_preflight_conversion_eligible, + ): + return message = self._format_rejection_message( estimated=est.estimated, current=current, @@ -8625,10 +9656,14 @@ def preflight_or_raise( ) if safety_rejection is None: return + if self._can_defer_mid_prefill_preflight( + _PrefillKVPhase.DENSE, + conversion_eligible=self._turboquant_preflight_conversion_eligible, + ): + return logger.warning( - "Preflight safety-cap rejected (%d tokens, cached=%d, " - "request_id=%s): %s", + "Preflight safety-cap rejected (%d tokens, cached=%d, request_id=%s): %s", num_prompt_tokens, cached_tokens, request_id, @@ -9150,7 +10185,9 @@ def _report_sparse_progress(processed: int, total: int) -> None: check_abort=_check_specprefill_abort, report_system_progress=_report_system_progress, report_sparse_progress=_report_sparse_progress, - sync_and_clear_cache=lambda: _sync_and_clear_cache(self._stream), + sync_and_clear_cache=lambda: _sync_and_clear_cache( + self._stream + ), log=logger, extract_cache_states=self._extract_cache_states, # Preserve an ordinary cache hit that already extends @@ -9237,9 +10274,24 @@ def _report_sparse_progress(processed: int, total: int) -> None: ): sm = self._build_state_machine(request) per_row_lps = list(logits_processors) if logits_processors else [] - state = self._begin_prefill( - request, tokens_to_process, cache_to_use - ) + try: + state = self._begin_prefill( + request, tokens_to_process, cache_to_use + ) + except PrefillMemoryExceededError as e: + logger.error( + "Chunked prefill setup capacity rejected for %s: %s", + request.request_id, + e, + ) + self._release_paged_cache_for_request(request.request_id) + self.requests.pop(request.request_id, None) + self._clear_request_admission_bookkeeping(request.request_id) + get_prefill_tracker().remove(request.request_id) + rejected_outputs.append( + _prefill_memory_exception_output(request.request_id, e) + ) + continue state.sampler = sampler state.sm = sm state.per_row_lps = per_row_lps @@ -9313,7 +10365,31 @@ def _report_sparse_progress(processed: int, total: int) -> None: self._emit_final_boundary_if_needed(state) _sync_and_clear_cache(self._stream) get_prefill_tracker().remove(request.request_id) - self._insert_prefilled_request(request, state, scheduled) + try: + self._insert_prefilled_request(request, state, scheduled) + except _PrefillAbortedError: + _sync_and_clear_cache(self._stream) + self._cleanup_prefill_abort_request(request) + continue + except PrefillMemoryExceededError as e: + logger.error( + "Chunked prefill completion capacity rejected " + "for %s: %s", + request.request_id, + e, + ) + self._release_paged_cache_for_request(request.request_id) + self.requests.pop(request.request_id, None) + self._clear_request_admission_bookkeeping( + request.request_id + ) + rejected_outputs.append( + _prefill_memory_exception_output( + request.request_id, + e, + ) + ) + continue else: self.prefilling.append(request) self._prefill_states[request.request_id] = state @@ -9342,6 +10418,9 @@ def _report_sparse_progress(processed: int, total: int) -> None: self.request_id_to_uid.pop(request.request_id, None) self._release_paged_cache_for_request(request.request_id) get_prefill_tracker().remove(request.request_id) + self._reset_partial_external_prefill_for_eviction( + request, e.request + ) self._pause_for_prefill_eviction(request, e.request) break except PrefillMemoryExceededError as e: @@ -9972,11 +11051,7 @@ def _cleanup_finished(self, finished_ids: set[str]) -> None: ) ) if intermediate_snapshots is not None: - for ( - snapshot_cache - ) in ( - intermediate_snapshots.iter_in_memory_extracted() - ): + for snapshot_cache in intermediate_snapshots.iter_in_memory_extracted(): pre_eval_arrays.extend( self._collect_arrays_from_extracted_cache( snapshot_cache @@ -10600,6 +11675,38 @@ def _requeue_or_fail_prefill(self, request: "Request", error: Exception) -> bool ) return True + def _reset_partial_external_prefill_for_eviction( + self, + request: "Request", + eviction: PrefillEvictionRequest, + ) -> None: + """Drop a restored prefix mutated before an external-prefill pause.""" + if eviction.processed_tokens <= 0 or request.prompt_cache is None: + return + + saved = getattr(request, "_prefill_saved_rope_deltas", None) + if saved is not None: + lm = getattr(self.model, "_language_model", None) + if lm is not None and hasattr(lm, "_rope_deltas"): + lm._rope_deltas = saved + request._prefill_saved_rope_deltas = None + + request.prompt_cache = None + request.cached_tokens = 0 + request.remaining_tokens = request.prompt_token_ids + request.block_table = None + request.shared_prefix_blocks = 0 + request._extracted_cache = None + request._model_cache_config = None + self._prefix_cache_prepared.discard(request.request_id) + _sync_and_clear_cache(self._stream) + logger.info( + "Discarded partially advanced prefix for %s before prefill retry " + "(processed_tokens=%d)", + request.request_id, + eviction.processed_tokens, + ) + def _pause_for_prefill_eviction( self, request: "Request", @@ -10607,11 +11714,9 @@ def _pause_for_prefill_eviction( ) -> None: """Hold a request until EngineCore can evict idle models asynchronously. - The request's prefix-cache state (prompt_cache, block_table, - cached_tokens, remaining_tokens) is deliberately left untouched so - a reconstructed prefix survives the pause and the retry prefills - only the uncached suffix instead of recomputing the prompt cold - (#2180). + An unmodified reconstructed prefix survives a pause before the first + forward. The external-prefill catch resets any prefix already extended + by completed chunks so retry cannot replay the same suffix twice. """ self._pending_prefill_eviction_request = eviction request.status = RequestStatus.WAITING @@ -10666,8 +11771,15 @@ def step(self) -> SchedulerOutput: if self.prefilling: self._advance_chunked_prefills(chunked_scheduled, chunked_rejected) - # Schedule waiting requests - scheduled, rejected = self._schedule_waiting() + # A chunked prefill may have paused for async LRU eviction. Deliver + # that request before inspecting the waiting queue: admitting a + # second pressured request here could overwrite the single pending + # hand-off before EngineCore observes it. The pending request is + # consumed below, so waiting admission resumes on the next step. + if self._pending_prefill_eviction_request is None: + scheduled, rejected = self._schedule_waiting() + else: + scheduled, rejected = [], [] # Merge chunked-prefill completions into the scheduled list. if chunked_scheduled: scheduled = chunked_scheduled + scheduled @@ -10791,8 +11903,7 @@ def step(self) -> SchedulerOutput: finished=True, finish_reason="error", error=( - f"Cache corruption not recoverable " - f"after retries: {e}" + f"Cache corruption not recoverable after retries: {e}" ), ) ) @@ -10835,7 +11946,7 @@ def step(self) -> SchedulerOutput: import traceback logger.error( - f"Error in batch generation step: {e}\n" f"{traceback.format_exc()}" + f"Error in batch generation step: {e}\n{traceback.format_exc()}" ) raise @@ -11133,6 +12244,30 @@ def _set_model_info_for_monitor(self) -> None: if self.memory_monitor is None: return + cache_list_for_tq: list[Any] | None = None + self._turboquant_preflight_conversion_eligible = None + try: + cache_factory = getattr(self.model, "make_cache", None) + except Exception: + cache_factory = None + cache_factory_available = callable(cache_factory) + if cache_factory_available: + try: + cache_candidate = cache_factory() + except Exception: + pass + else: + if isinstance(cache_candidate, list): + cache_list_for_tq = cache_candidate + try: + self._turboquant_preflight_conversion_eligible = ( + self._confirm_turboquant_preflight_conversion_eligibility( + cache_candidate + ) + ) + except Exception: + self._turboquant_preflight_conversion_eligible = None + try: # Try to get model config config = None @@ -11195,6 +12330,8 @@ def _cfg_get(obj: Any, key: str, default: Any = None) -> Any: elif self.model.dtype == mx.bfloat16: base_dtype_size = 2 dtype_size = base_dtype_size + self._prefill_dense_kv_dtype_size = base_dtype_size + self._prefill_tq_kv_dtype_size = None # Extract num_attention_heads (query heads) for SDPA peak estimation num_attention_heads = ( @@ -11204,19 +12341,17 @@ def _cfg_get(obj: Any, key: str, default: Any = None) -> Any: ) # Classify layer cache types for hybrid models - cache_list_for_tq = None + # Reuse the one-shot make_cache probe performed above. actual_kv_cache_layers = None num_kv_cache_layers = num_layers rotating_layer_specs: list[tuple[int, int]] = [] arrays_cache_layers = 0 - if not hasattr(self.model, "make_cache"): + if not cache_factory_available: actual_kv_cache_layers = num_layers - elif collect_kv_layer_specs is not None: + elif cache_list_for_tq is not None and collect_kv_layer_specs is not None: try: - cache_list = self.model.make_cache() - cache_list_for_tq = cache_list full_layers, rotating_layer_specs, arrays_cache_layers = ( - collect_kv_layer_specs(cache_list) + collect_kv_layer_specs(cache_list_for_tq) ) actual_kv_cache_layers = full_layers num_kv_cache_layers = full_layers @@ -11249,7 +12384,10 @@ def _cfg_get(obj: Any, key: str, default: Any = None) -> Any: ) ) ): - tq_dtype_size = float(self._turboquant_kv_bits) / 8.0 + (2.0 / head_dim) + tq_dtype_size = turboquant_mse_bytes_per_element( + head_dim, + float(self._turboquant_kv_bits), + ) if ( self._turboquant_skip_last and not isinstance(actual_kv_cache_layers, bool) @@ -11260,6 +12398,7 @@ def _cfg_get(obj: Any, key: str, default: Any = None) -> Any: ) / actual_kv_cache_layers else: dtype_size = tq_dtype_size + self._prefill_tq_kv_dtype_size = dtype_size kv_bytes_per_token = ( estimate_mla_kv_bytes_per_token( @@ -11519,12 +12658,15 @@ def _init_tiered_cache(self) -> bool: # happy path here is ``has_model_info() is True``; this # else branch only fires for skeletal test fixtures. if self.memory_monitor is not None and self.memory_monitor.has_model_info(): - # ``estimate_block_memory(1)`` returns all-layers K+V - # bytes for a single token at the dtype the monitor was - # configured with — exactly the per-token cost the - # queue cap needs to weigh. - expected_kv_bytes_per_token = self.memory_monitor.estimate_block_memory( - 1 + # SSD boundary blocks also retain rotating windows and fixed + # recurrent snapshots. Convert that whole-block estimate back + # to the manager's effective per-token constructor input. + block_tokens = max(1, int(self.config.paged_cache_block_size)) + block_payload = self.memory_monitor.estimate_paged_writer_block_memory( + block_tokens + ) + expected_kv_bytes_per_token = max( + 1, math.ceil(block_payload / block_tokens) ) else: expected_kv_bytes_per_token = 200_000 # PagedSSDCacheManager default diff --git a/omlx/server.py b/omlx/server.py index abafcfb0c..f8a966500 100644 --- a/omlx/server.py +++ b/omlx/server.py @@ -1466,6 +1466,43 @@ async def _ensure_tokenizer_for_system_probe( await engine.start() +async def _preflight_chat_with_eviction_budget( + engine: BaseEngine, + messages: list, + chat_kwargs: dict, + *, + request_id: str | None, +) -> None: + """Run route preflight and carry a real callback attempt to admission. + + The marker lives only in this HTTP request's kwargs, so concurrent routes + cannot consume each other's retry and cancellation leaves no keyed state. + """ + callback_attempted = await engine.preflight_chat( + messages, + request_id=request_id, + **chat_kwargs, + ) + if callback_attempted is True: + chat_kwargs["prefill_eviction_callback_attempted"] = True + + +async def _preflight_completion_with_eviction_budget( + engine: BaseEngine, + prompt: str, + generation_kwargs: dict[str, bool], + *, + request_id: str | None, +) -> None: + """Carry a completed route eviction attempt to this prompt's admission.""" + callback_attempted = await engine.preflight_completion( + prompt, + request_id=request_id, + ) + if callback_attempted is True: + generation_kwargs["prefill_eviction_callback_attempted"] = True + + def _unsupported_mid_system_policy() -> str: settings = _server_state.global_settings preserve_cache = True @@ -2960,7 +2997,7 @@ async def create_completion( request: CompletionRequest, http_request: FastAPIRequest, _: bool = Depends(verify_api_key), -): +) -> StreamingResponse: """Create a text completion.""" if _server_state.oq_manager and _server_state.oq_manager.is_quantizing: raise HTTPException( @@ -2992,9 +3029,17 @@ async def create_completion( # log line and the FastAPI handler trace correlate with whatever # the client is using on its side. upstream_request_id = http_request.headers.get("x-request-id") + completion_preflight_kwargs_by_prompt: list[dict[str, bool]] = [ + {} for _ in prompts + ] await _raise_if_llm_lease_abort_requested(lease) - for prompt in prompts: - await engine.preflight_completion(prompt, request_id=upstream_request_id) + for i, prompt in enumerate(prompts): + await _preflight_completion_with_eviction_budget( + engine, + prompt, + completion_preflight_kwargs_by_prompt[i], + request_id=upstream_request_id, + ) await _raise_if_llm_lease_abort_requested(lease) if request.stream: @@ -3013,6 +3058,12 @@ async def create_completion( prompt_token_ids=prompt_token_ids_by_prompt[0], resolved_model=resolved_model, response_id=response_id, + prefill_eviction_callback_attempted=( + completion_preflight_kwargs_by_prompt[0].get( + "prefill_eviction_callback_attempted", + False, + ) + ), ), http_request=http_request, keepalive_chunk=keepalive, @@ -3024,7 +3075,7 @@ async def create_completion( ) # Non-streaming response with keepalive during prefill - async def _build_completion(): + async def _build_completion() -> str: await _raise_if_llm_lease_abort_requested(lease) start_time = time.perf_counter() choices = [] @@ -3081,6 +3132,7 @@ async def _build_completion(): xtc_threshold=xtc_threshold, stop=request.stop, seed=request.seed, + **completion_preflight_kwargs_by_prompt[i], **gen_kwargs, ) if i == 0: @@ -3506,10 +3558,11 @@ async def create_chat_completion( # an incomplete chunked read. Running the check here lets # prefill_memory_exceeded_handler return a clean HTTP 400. await _raise_if_llm_lease_abort_requested(lease) - await engine.preflight_chat( + await _preflight_chat_with_eviction_budget( + engine, messages, + chat_kwargs, request_id=http_request.headers.get("x-request-id"), - **chat_kwargs, ) await _raise_if_llm_lease_abort_requested(lease) @@ -4097,6 +4150,7 @@ async def stream_completion( prompt_token_ids: list[int] | None = None, resolved_model: str | None = None, response_id: str | None = None, + prefill_eviction_callback_attempted: bool = False, ) -> AsyncIterator[str]: """Stream completion response.""" response_id = response_id or f"cmpl-{uuid.uuid4().hex[:8]}" @@ -4138,6 +4192,8 @@ async def stream_completion( thinking_budget = _resolve_thinking_budget(request, request.model) if thinking_budget is not None: gen_kwargs["thinking_budget"] = thinking_budget + if prefill_eviction_callback_attempted: + gen_kwargs["prefill_eviction_callback_attempted"] = True try: async for output in engine.stream_generate( prompt=prompt, @@ -5419,10 +5475,11 @@ async def create_anthropic_message( # Pre-flight prefill memory guard — must precede any StreamingResponse # return so PrefillMemoryExceededError can be mapped to HTTP 400. await _raise_if_llm_lease_abort_requested(lease) - await engine.preflight_chat( + await _preflight_chat_with_eviction_budget( + engine, messages, + chat_kwargs, request_id=http_request.headers.get("x-request-id"), - **chat_kwargs, ) await _raise_if_llm_lease_abort_requested(lease) @@ -5901,10 +5958,11 @@ async def create_response( # Pre-flight prefill memory guard — must precede any StreamingResponse # return so PrefillMemoryExceededError can be mapped to HTTP 400. await _raise_if_llm_lease_abort_requested(lease) - await engine.preflight_chat( + await _preflight_chat_with_eviction_budget( + engine, messages, + chat_kwargs, request_id=http_request.headers.get("x-request-id"), - **chat_kwargs, ) await _raise_if_llm_lease_abort_requested(lease) diff --git a/omlx/turboquant_kv.py b/omlx/turboquant_kv.py index 088443c80..d2b01eace 100644 --- a/omlx/turboquant_kv.py +++ b/omlx/turboquant_kv.py @@ -11,7 +11,9 @@ import logging import math -from typing import List, Optional +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any import mlx.core as mx from mlx_lm.models.cache import ( @@ -45,12 +47,508 @@ logger = logging.getLogger(__name__) __all__ = [ + "TURBOQUANT_CONVERSION_SLICE_TOKENS", + "TURBOQUANT_PREFILL_KEY_CHUNK_TOKENS", + "TURBOQUANT_PREFILL_QUERY_BLOCK_TOKENS", + "TurboQuantConversionStats", "TurboQuantKVCache", "BatchTurboQuantKVCache", + "convert_kv_cache_sliced", + "estimate_turboquant_conversion_peak_bytes", + "estimate_turboquant_prefill_attention_workspace_bytes", + "turboquant_mse_bytes_per_element", "turboquant_enabled", ] +TURBOQUANT_CONVERSION_SLICE_TOKENS = 8192 +_CONVERSION_WORKSPACE_ARRAYS_PER_SOURCE = 4 +TURBOQUANT_PREFILL_QUERY_BLOCK_TOKENS = 256 +TURBOQUANT_PREFILL_KEY_CHUNK_TOKENS = 16384 + + +@dataclass(frozen=True, slots=True) +class TurboQuantConversionStats: + """Observed shape and byte counts for one cache-list conversion.""" + + converted_layers: int + already_quantized_layers: int + skipped_dense_layers: int + slices: int + source_bytes: int + converted_bytes: int + + +def _turboquant_family_indices(cache_list: list[Any]) -> list[int]: + """Return full-attention layer indices used by the skip-last rule.""" + return [ + index + for index, cache_obj in enumerate(cache_list) + if isinstance(cache_obj, (KVCache, TurboQuantKVCache)) + ] + + +def _turboquant_target_indices( + cache_list: list[Any], *, skip_last: bool +) -> tuple[set[int], int | None]: + """Return conversion targets and the optional dense skip-last layer.""" + family_indices = _turboquant_family_indices(cache_list) + skipped_index = ( + family_indices[-1] if skip_last and len(family_indices) > 1 else None + ) + targets = set(family_indices) + if skipped_index is not None: + targets.remove(skipped_index) + return targets, skipped_index + + +def _turboquant_mse_bit_widths(bits: float) -> tuple[int, int]: + """Return the integer key/value widths used by the MSE codec.""" + validated_bits = float(_validate_bits(bits)) + if math.isclose(validated_bits, round(validated_bits), abs_tol=1e-6): + width = int(round(validated_bits)) + return width, width + return int(math.floor(validated_bits)), int(math.ceil(validated_bits)) + + +def _quantized_mse_vector_bytes(head_dim: int, bits: int) -> int: + """Return one packed MSE vector's norm and uint32 index bytes.""" + packed_words = (head_dim * bits + 31) // 32 + return mx.float16.size + packed_words * mx.uint32.size + + +def turboquant_mse_bytes_per_element(head_dim: int, bits: float) -> float: + """Return the average packed MSE K/V resident width per element.""" + if not isinstance(head_dim, int) or isinstance(head_dim, bool) or head_dim <= 0: + raise ValueError("head_dim must be a positive integer") + key_bits, value_bits = _turboquant_mse_bit_widths(bits) + key_vector_bytes = _quantized_mse_vector_bytes(head_dim, key_bits) + value_vector_bytes = _quantized_mse_vector_bytes(head_dim, value_bits) + return (key_vector_bytes + value_vector_bytes) / (2 * head_dim) + +def estimate_turboquant_prefill_attention_workspace_bytes( + *, + query_tokens: int, + kv_len: int, + num_query_heads: int, + num_kv_heads: int, + head_dim: int, + bits: float, + compute_dtype_size: float = 2, + causal: bool = True, +) -> int: + """Bound the first chunked Q8-style TurboQuant prefill attention call. + + The long-prefill route retains all completed query-block outputs while it + evaluates one 256-query by 16384-key block at a time. This structural + bound prices those retained outputs, the active score/softmax tensors, + unpack/cast/codebook tensors for K and V, packed state slices, and the + caller-owned query input. It does not rely on allocator fusion or on a + prior TurboQuant transient sample. + """ + dimensions = ( + query_tokens, + kv_len, + num_query_heads, + num_kv_heads, + head_dim, + ) + if any( + not isinstance(value, int) or isinstance(value, bool) or value <= 0 + for value in dimensions + ): + return 0 + if ( + not isinstance(bits, (int, float)) + or isinstance(bits, bool) + or not math.isfinite(float(bits)) + or float(bits) <= 0 + ): + return 0 + if ( + not isinstance(compute_dtype_size, (int, float)) + or isinstance(compute_dtype_size, bool) + or not math.isfinite(float(compute_dtype_size)) + or float(compute_dtype_size) <= 0 + ): + return 0 + + q_block = min(query_tokens, TURBOQUANT_PREFILL_QUERY_BLOCK_TOKENS) + k_block = min(kv_len, TURBOQUANT_PREFILL_KEY_CHUNK_TOKENS) + key_bits, value_bits = _turboquant_mse_bit_widths(float(bits)) + key_words = (head_dim * key_bits + 31) // 32 + value_words = (head_dim * value_bits + 31) // 32 + compute_bytes = float(compute_dtype_size) + + # Full-query buffers: caller input, scaled queries, final compute cast, + # retained float32 blocks, and the concatenated float32 result. + total = (3 * compute_bytes + 8) * num_query_heads * query_tokens * head_dim + # Nine active float32 query/value/accumulator stages. + total += 36 * num_query_heads * q_block * head_dim + # Dots, scaled scores, softmax subtraction, and weights. + total += 16 * num_query_heads * q_block * k_block + # K and V uint32 unpack, int32 cast, and float32 codebook-take tensors. + total += 24 * num_kv_heads * k_block * head_dim + # K/V norm casts plus packed state-slice materialization. + total += 8 * num_kv_heads * k_block + total += ( + num_kv_heads + * k_block + * (4 * (key_words + value_words) + 4) + ) + # Per-query max/denominator and online-softmax state. + total += 48 * num_query_heads * q_block + + if causal: + # One additional masked score result, the causal bool tile, and its + # query/key index vectors. + total += 4 * num_query_heads * q_block * k_block + total += q_block * k_block + total += 8 * (q_block + k_block) + + return int(math.ceil(total)) + + +def _quantized_state_shape_bytes( + *, + batch_size: int, + num_heads: int, + num_tokens: int, + head_dim: int, + bits: int, +) -> int: + """Return packed MSE-state bytes for one key or value tensor.""" + vectors = batch_size * num_heads * num_tokens + return vectors * _quantized_mse_vector_bytes(head_dim, bits) + + +def _layer_conversion_peak_bytes( + keys: mx.array, + values: mx.array, + *, + bits: float, + slice_tokens: int, +) -> int: + """Bound incremental bytes while converting one dense cache layer. + + After the first slice establishes the state type, the destination reserves + final capacity before later slices run. The peak therefore holds the final + state, one quantized slice, and bounded quantization workspace. The + workspace charges four fp32-sized arrays per key/value source tensor. + """ + batch_size = int(keys.shape[0]) + num_heads = int(keys.shape[1]) + num_tokens = int(keys.shape[2]) + key_dim = int(keys.shape[3]) + value_dim = int(values.shape[3]) + key_bits, value_bits = _turboquant_mse_bit_widths(bits) + bounded_tokens = min(num_tokens, slice_tokens) + + def _state_bytes(tokens: int) -> int: + return _quantized_state_shape_bytes( + batch_size=batch_size, + num_heads=num_heads, + num_tokens=tokens, + head_dim=key_dim, + bits=key_bits, + ) + _quantized_state_shape_bytes( + batch_size=batch_size, + num_heads=num_heads, + num_tokens=tokens, + head_dim=value_dim, + bits=value_bits, + ) + + final_state = _state_bytes(num_tokens) + slice_state = _state_bytes(bounded_tokens) + source_elements = batch_size * num_heads * bounded_tokens * (key_dim + value_dim) + workspace = ( + source_elements * mx.float32.size * _CONVERSION_WORKSPACE_ARRAYS_PER_SOURCE + ) + codec_tables = 2 * (key_dim * key_dim + value_dim * value_dim) * mx.float32.size + return final_state + slice_state + workspace + codec_tables + + +def _validate_dense_kv_state(keys: mx.array, values: mx.array) -> int: + """Validate the dense source shape and return its logical token count.""" + if keys.ndim != 4 or values.ndim != 4: + raise ValueError("TurboQuant conversion requires 4-D K/V state") + if keys.shape[:3] != values.shape[:3]: + raise ValueError("TurboQuant conversion requires matching K/V batch shapes") + return int(keys.shape[2]) + + +def _append_turboquant_slice( + cache_obj: TurboQuantKVCache, + keys: mx.array, + values: mx.array, + *, + start: int, + end: int, + stream: Any | None, +) -> None: + """Quantize and materialize one bounded token slice. + + ``TurboQuantKVCache.state`` caches a lazy prefix slice. The converter + ignores the returned proxies, so clear its private prefix immediately + before the dependency constructs each slice update and again after eval. + No converter-owned proxy then survives to the next donation decision. + """ + cache_obj._cached_state = None + cache_obj._cached_state_offset = -1 + try: + if stream is None: + cache_obj.update_and_fetch( + keys[:, :, start:end, :], + values[:, :, start:end, :], + ) + mx.eval(cache_obj.keys, cache_obj.values) + return + with mx.stream(stream): + cache_obj.update_and_fetch( + keys[:, :, start:end, :], + values[:, :, start:end, :], + ) + mx.eval(cache_obj.keys, cache_obj.values) + finally: + cache_obj._cached_state = None + cache_obj._cached_state_offset = -1 + + +def _validate_converted_layer( + cache_obj: TurboQuantKVCache, + *, + expected_tokens: int, + expected_bits: float, +) -> None: + """Reject an incomplete or incompatible converted cache candidate.""" + if cache_obj.offset != expected_tokens: + raise RuntimeError( + f"TurboQuant candidate converted {cache_obj.offset} " + f"of {expected_tokens} tokens" + ) + if not math.isclose(cache_obj.bits, expected_bits, abs_tol=1e-6): + raise RuntimeError( + f"TurboQuant candidate uses {cache_obj.bits} bits, " + f"expected {expected_bits}" + ) + key_state, value_state = cache_obj.state + for label, state in (("key", key_state), ("value", value_state)): + if not isinstance(state, TurboQuantMSEState): + raise RuntimeError( + f"TurboQuant {label} candidate has unsupported " + f"{type(state).__name__} state" + ) + if state.norms.dtype != mx.float16 or state.indices.dtype != mx.uint32: + raise RuntimeError(f"TurboQuant {label} candidate has invalid state dtypes") + if int(state.norms.shape[2]) != expected_tokens: + raise RuntimeError( + f"TurboQuant {label} candidate has incomplete logical state" + ) + + +def estimate_turboquant_conversion_peak_bytes( + cache_list: list[Any], + *, + bits: float, + skip_last: bool, + slice_tokens: int = TURBOQUANT_CONVERSION_SLICE_TOKENS, +) -> int: + """Return a conservative incremental peak for layer-wise conversion.""" + if slice_tokens <= 0: + raise ValueError("slice_tokens must be positive") + _validate_bits(bits) + target_indices, _ = _turboquant_target_indices(cache_list, skip_last=skip_last) + peak = 0 + for index in target_indices: + cache_obj = cache_list[index] + if isinstance(cache_obj, TurboQuantKVCache): + if not math.isclose(cache_obj.bits, bits, abs_tol=1e-6): + raise ValueError( + f"TurboQuant layer {index} uses {cache_obj.bits} bits, " + f"expected {bits}" + ) + continue + if not isinstance(cache_obj, KVCache) or cache_obj.empty(): + continue + keys, values = cache_obj.state + _validate_dense_kv_state(keys, values) + peak = max( + peak, + _layer_conversion_peak_bytes( + keys, + values, + bits=bits, + slice_tokens=slice_tokens, + ), + ) + return peak + + +def convert_kv_cache_sliced( + cache_list: list[Any], + *, + bits: float, + skip_last: bool, + slice_tokens: int = TURBOQUANT_CONVERSION_SLICE_TOKENS, + stream: Any | None = None, + check_cancelled: Callable[[], None] | None = None, +) -> TurboQuantConversionStats: + """Convert dense full-attention layers in bounded token slices. + + Each destination layer is fully evaluated before replacing its dense + source. Prior converted layers are released before the next layer starts. + If a callback raises, the current dense layer remains in place; callers + must discard the whole cache because earlier layers may already be swapped. + """ + from .utils.metal_sync import _sync_and_clear_cache + + if slice_tokens <= 0: + raise ValueError("slice_tokens must be positive") + validated_bits = float(_validate_bits(bits)) + target_indices, skipped_index = _turboquant_target_indices( + cache_list, skip_last=skip_last + ) + converted_layers = 0 + already_quantized_layers = 0 + skipped_dense_layers = 0 + slices = 0 + source_bytes = 0 + converted_bytes = 0 + + for index in _turboquant_family_indices(cache_list): + cache_obj: Any | None = cache_list[index] + keys: mx.array | None = None + values: mx.array | None = None + turbo_cache: TurboQuantKVCache | None = None + converted_current_layer = False + try: + if index == skipped_index: + if isinstance(cache_obj, KVCache): + skipped_dense_layers += 1 + continue + if index not in target_indices: + continue + if isinstance(cache_obj, TurboQuantKVCache): + if not math.isclose(cache_obj.bits, validated_bits, abs_tol=1e-6): + raise ValueError( + f"TurboQuant layer {index} uses {cache_obj.bits} bits, " + f"expected {validated_bits}" + ) + already_quantized_layers += 1 + converted_bytes += int(cache_obj.nbytes) + continue + if not isinstance(cache_obj, KVCache): + continue + if check_cancelled is not None: + check_cancelled() + + turbo_cache = TurboQuantKVCache(bits=validated_bits) + if cache_obj.empty(): + cache_list[index] = turbo_cache + converted_layers += 1 + continue + + keys, values = cache_obj.state + num_tokens = _validate_dense_kv_state(keys, values) + source_bytes += int(keys.nbytes + values.nbytes) + + first_end = min(slice_tokens, num_tokens) + _append_turboquant_slice( + turbo_cache, + keys, + values, + start=0, + end=first_end, + stream=stream, + ) + slices += 1 + _sync_and_clear_cache(stream) + if check_cancelled is not None: + check_cancelled() + + if first_end < num_tokens: + if stream is None: + turbo_cache.keys = _reserve_state_capacity( + turbo_cache.keys, + turbo_cache.offset, + num_tokens, + num_tokens, + ) + turbo_cache.values = _reserve_state_capacity( + turbo_cache.values, + turbo_cache.offset, + num_tokens, + num_tokens, + ) + turbo_cache._cached_state = None + turbo_cache._cached_state_offset = -1 + mx.eval(turbo_cache.keys, turbo_cache.values) + else: + with mx.stream(stream): + turbo_cache.keys = _reserve_state_capacity( + turbo_cache.keys, + turbo_cache.offset, + num_tokens, + num_tokens, + ) + turbo_cache.values = _reserve_state_capacity( + turbo_cache.values, + turbo_cache.offset, + num_tokens, + num_tokens, + ) + turbo_cache._cached_state = None + turbo_cache._cached_state_offset = -1 + mx.eval(turbo_cache.keys, turbo_cache.values) + _sync_and_clear_cache(stream) + if check_cancelled is not None: + check_cancelled() + + for start in range(first_end, num_tokens, slice_tokens): + if check_cancelled is not None: + check_cancelled() + end = min(start + slice_tokens, num_tokens) + _append_turboquant_slice( + turbo_cache, + keys, + values, + start=start, + end=end, + stream=stream, + ) + slices += 1 + _sync_and_clear_cache(stream) + if check_cancelled is not None: + check_cancelled() + + _validate_converted_layer( + turbo_cache, + expected_tokens=num_tokens, + expected_bits=validated_bits, + ) + cache_list[index] = turbo_cache + converted_layers += 1 + converted_bytes += int(turbo_cache.nbytes) + converted_current_layer = True + finally: + cache_obj = None + keys = None + values = None + turbo_cache = None + if converted_current_layer: + _sync_and_clear_cache(stream) + + return TurboQuantConversionStats( + converted_layers=converted_layers, + already_quantized_layers=already_quantized_layers, + skipped_dense_layers=skipped_dense_layers, + slices=slices, + source_bytes=source_bytes, + converted_bytes=converted_bytes, + ) + + # --------------------------------------------------------------------------- # Codec rebuild for SSD cache reconstruction # --------------------------------------------------------------------------- @@ -234,7 +732,9 @@ class BatchTurboQuantKVCache(TurboQuantKVCache): overrides make_mask for per-request left_padding support. """ - def __init__(self, left_padding: List[int], bits: float = 4.0, seed: int = 0): + def __init__( + self, left_padding: list[int], bits: float = 4.0, seed: int = 0 + ) -> None: super().__init__(bits=bits, seed=seed) self.group_size = 0 self.left_padding = mx.array(left_padding) @@ -304,8 +804,8 @@ def make_mask( self, N: int, return_array: bool = False, - window_size: Optional[int] = None, - ): + window_size: int | None = None, + ) -> str | mx.array | None: offset = self.offset if isinstance(offset, int): return create_attention_mask(N, offset, return_array, window_size) @@ -481,7 +981,7 @@ def extract(self, idx: int) -> TurboQuantKVCache: return tq @classmethod - def merge(cls, caches: List[TurboQuantKVCache]) -> "BatchTurboQuantKVCache": + def merge(cls, caches: list[TurboQuantKVCache]) -> BatchTurboQuantKVCache: for cache in caches: if not isinstance(cache, TurboQuantKVCache): raise TypeError( diff --git a/omlx/utils/metal_sync.py b/omlx/utils/metal_sync.py index 1c129b764..bba0fc70f 100644 --- a/omlx/utils/metal_sync.py +++ b/omlx/utils/metal_sync.py @@ -16,10 +16,16 @@ """ import threading +import weakref +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any import mlx.core as mx from mlx_lm.generate import generation_stream +from ..exceptions import TurboQuantProcessExclusiveError + # Module-level alias so callers can fall back to mlx-lm's default stream # when no per-engine stream is provided. _default_generation_stream = generation_stream @@ -33,7 +39,218 @@ _mx_buffer_access_lock = threading.RLock() -def _sync_and_clear_cache(stream=None): +class _ConversionCoordinator: + """Own process-exclusive mid-prefill conversion and peak reservations. + + A mid-prefill engine must be the process's sole ``EngineCore`` and claims + the capability only after the global MLX executor has drained. While that + engine lives, new engines and independent Metal workers fail closed. This + keeps cache-clearing conversion away from streams it cannot drain. + """ + + def __init__(self) -> None: + self._condition = threading.Condition(threading.Lock()) + self._registered_engines: weakref.WeakSet[Any] = weakref.WeakSet() + self._exclusive_owner: weakref.ReferenceType[Any] | None = None + self._background_metal_operations = 0 + self._waiting_conversions = 0 + self._conversion_owner: object | None = None + self._reservation_owner: object | None = None + self._outstanding_bytes = 0 + + def _exclusive_owner_unlocked(self) -> Any | None: + owner_ref = self._exclusive_owner + if owner_ref is None: + return None + owner = owner_ref() + if owner is None: + self._exclusive_owner = None + return owner + + def register_engine(self, owner: Any) -> None: + """Register an EngineCore before it creates a Metal executor.""" + with self._condition: + exclusive_owner = self._exclusive_owner_unlocked() + if exclusive_owner is not None and exclusive_owner is not owner: + raise TurboQuantProcessExclusiveError( + "TurboQuant mid-prefill requires process-exclusive Metal " + "access; unload the mid-prefill model before loading " + "another engine" + ) + self._registered_engines.add(owner) + + def unregister_engine(self, owner: Any) -> None: + """Release an engine after its owning-thread stream has drained.""" + with self._condition: + if self._exclusive_owner_unlocked() is owner: + self._exclusive_owner = None + self._registered_engines.discard(owner) + self._condition.notify_all() + + def claim_process_exclusive(self, owner: Any) -> None: + """Claim the mid-prefill capability for the process's sole engine.""" + with self._condition: + if owner not in self._registered_engines: + raise RuntimeError("mid-prefill owner is not a registered engine") + exclusive_owner = self._exclusive_owner_unlocked() + if exclusive_owner is not None and exclusive_owner is not owner: + raise TurboQuantProcessExclusiveError( + "another engine already owns process-exclusive Metal access" + ) + other_engines = [ + engine for engine in self._registered_engines if engine is not owner + ] + if other_engines: + raise TurboQuantProcessExclusiveError( + "TurboQuant mid-prefill requires process-exclusive Metal " + "access; unload all other engines before enabling it" + ) + if self._background_metal_operations: + raise TurboQuantProcessExclusiveError( + "TurboQuant mid-prefill cannot start while an independent " + "Metal operation is active" + ) + self._exclusive_owner = weakref.ref(owner) + + def process_exclusive(self, owner: Any | None) -> bool: + """Return whether a registered ``owner`` holds the process capability.""" + if owner is None: + return False + with self._condition: + if owner not in self._registered_engines: + return False + return self._exclusive_owner_unlocked() is owner + + def assert_background_metal_allowed(self) -> None: + """Reject a global-executor task while a mid-prefill engine is live.""" + with self._condition: + if self._exclusive_owner_unlocked() is not None: + raise TurboQuantProcessExclusiveError( + "Independent Metal work is unavailable while a " + "TurboQuant mid-prefill engine owns the process" + ) + + @contextmanager + def background_metal_operation(self) -> Iterator[None]: + """Track a non-executor Metal worker such as oQ quantization.""" + with self._condition: + if self._exclusive_owner_unlocked() is not None: + raise TurboQuantProcessExclusiveError( + "Independent Metal work is unavailable while a " + "TurboQuant mid-prefill engine owns the process" + ) + self._background_metal_operations += 1 + try: + yield + finally: + with self._condition: + self._background_metal_operations -= 1 + self._condition.notify_all() + + @contextmanager + def conversion(self, *, process_owner: Any | None = None) -> Iterator[object]: + """Serialize a bounded conversion owned by the exclusive engine.""" + owner = object() + acquired = False + with self._condition: + if ( + process_owner is None + or process_owner not in self._registered_engines + or self._exclusive_owner_unlocked() is not process_owner + ): + raise RuntimeError( + "TurboQuant mid-prefill conversion lacks process-exclusive " + "Metal ownership" + ) + self._waiting_conversions += 1 + try: + while self._conversion_owner is not None: + self._condition.wait() + self._conversion_owner = owner + acquired = True + finally: + self._waiting_conversions -= 1 + if not acquired: + self._condition.notify_all() + try: + yield owner + finally: + with self._condition: + if self._reservation_owner is owner: + self._reservation_owner = None + self._outstanding_bytes = 0 + if self._conversion_owner is not owner: + raise RuntimeError("TurboQuant conversion gate ownership was lost") + self._conversion_owner = None + self._condition.notify_all() + + def try_reserve( + self, + owner: object, + *, + current_bytes: int, + peak_bytes: int, + limit_bytes: int, + ) -> tuple[bool, int]: + """Atomically check headroom and publish an accepted conversion peak.""" + if current_bytes < 0 or peak_bytes < 0 or limit_bytes < 0: + raise ValueError("conversion memory values must be non-negative") + with self._condition: + if self._conversion_owner is not owner: + raise RuntimeError( + "conversion reservation requires exclusive ownership" + ) + if ( + self._reservation_owner is not None + and self._reservation_owner is not owner + ): + raise RuntimeError("another conversion reservation is active") + prior_outstanding = ( + 0 if self._reservation_owner is owner else self._outstanding_bytes + ) + estimated_bytes = current_bytes + prior_outstanding + peak_bytes + if limit_bytes > 0 and estimated_bytes > limit_bytes: + return False, estimated_bytes + self._reservation_owner = owner + self._outstanding_bytes = peak_bytes + self._condition.notify_all() + return True, estimated_bytes + + def release_reservation(self, owner: object) -> None: + """Release the holder's peak before its post-conversion sample.""" + with self._condition: + if self._conversion_owner is not owner: + raise RuntimeError("conversion reservation owner is not active") + if self._reservation_owner is None: + return + if self._reservation_owner is not owner: + raise RuntimeError("conversion reservation ownership was lost") + self._reservation_owner = None + self._outstanding_bytes = 0 + self._condition.notify_all() + + def outstanding_bytes(self, *, exclude_owner: object | None = None) -> int: + """Return bytes reserved by another conversion holder.""" + with self._condition: + if self._reservation_owner is exclude_owner: + return 0 + return self._outstanding_bytes + + def snapshot(self) -> tuple[int, int, bool, int]: + """Return background, waiting, active-writer, and reservation state.""" + with self._condition: + return ( + self._background_metal_operations, + self._waiting_conversions, + self._conversion_owner is not None, + self._outstanding_bytes, + ) + + +_conversion_coordinator = _ConversionCoordinator() + + +def _sync_and_clear_cache(stream: Any | None = None) -> None: """Synchronize in-flight GPU work before clearing the Metal buffer cache. Without synchronization, mx.clear_cache() can release Metal buffers that diff --git a/scripts/validate_turboquant_mid_prefill.py b/scripts/validate_turboquant_mid_prefill.py new file mode 100755 index 000000000..c6cc74402 --- /dev/null +++ b/scripts/validate_turboquant_mid_prefill.py @@ -0,0 +1,2074 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Reproducible, parent-supervised TurboQuant mid-prefill validation.""" + +from __future__ import annotations + +import argparse +import asyncio +import fcntl +import gc +import hashlib +import importlib.metadata +import json +import logging +import math +import os +import platform +import re +import statistics +import subprocess +import sys +import tempfile +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import asdict, dataclass +from difflib import SequenceMatcher +from functools import partial +from pathlib import Path +from types import SimpleNamespace +from typing import Any, TypeVar + +logger = logging.getLogger(__name__) + +DATASET_REPO = "openai/mrcr" +DATASET_REVISION = "f4c69fae7cf81f7ca26b9fee34b392a50f6b8a1d" +DATASET_FILE = "2needle/2needle_0.parquet" +DEFAULT_MATRIX_ROWS: tuple[int, ...] = (104, 109, 136, 301, 311, 328) +PRIMARY_PERFORMANCE_ROWS: Mapping[int, str] = { + 104: "primary_32k", + 311: "primary_64k", +} +ORGANIC_ROW_INDEX = 35 +ORGANIC_PROMPT_TOKENS = 131_071 +FIXED_REPLAY_TOKENS = 256 +ORGANIC_PREFILL_ABORT_MARGIN = 0.95 +ORGANIC_PREFILL_MIN_CHUNK_TOKENS = 32 +SCHEMA_VERSION = 1 +GIB = 1024**3 +TELEMETRY_EXIT_GRACE_SECONDS = 1.0 +_FORCE_ENV_PREFIX = "OMLX_FORCE_" +_MEMORY_TOTAL_RE = re.compile(r"The system has\s+(\d+)\s+\(") +_MEMORY_FREE_RE = re.compile(r"System-wide memory free percentage:\s*(\d+(?:\.\d+)?)%") +_T = TypeVar("_T") + + +class ValidationError(RuntimeError): + """Raised when the validation contract cannot be established.""" + + +class TelemetryError(ValidationError): + """Raised when mandatory supervisor telemetry is invalid.""" + + +@dataclass(frozen=True, slots=True) +class ValidationMode: + """One fixed matrix validation mode.""" + + name: str + conversion: str + bits: float | None + + +@dataclass(frozen=True, slots=True) +class MemoryPressureReading: + """Parsed kernel memory-pressure reading.""" + + total_bytes: int + free_percent: float + headroom_bytes: int + + +@dataclass(slots=True) +class SupervisedOutcome: + """One child result plus mandatory parent-side safety observations.""" + + result: dict[str, Any] | None + telemetry: dict[str, Any] + returncode: int + error: str | None + + +@dataclass(slots=True) +class OrganicPrefillPause: + """First organic prefill attempt paused for the production LRU callback.""" + + baseline: dict[str, int] + context: Any + eviction_request: Any + external_seconds: float + total_started: float + + +def matrix_modes() -> tuple[ValidationMode, ...]: + """Return the complete, ordered five-mode validation matrix.""" + return ( + ValidationMode("dense", "dense", None), + ValidationMode("ordinary-q8", "ordinary", 8.0), + ValidationMode("ordinary-q4", "ordinary", 4.0), + ValidationMode("mid-q8", "mid", 8.0), + ValidationMode("mid-q4", "mid", 4.0), + ) + + +def pinned_dataset_config() -> dict[str, Any]: + """Return immutable MRCR retrieval provenance as JSON-compatible data.""" + return { + "repo_id": DATASET_REPO, + "revision": DATASET_REVISION, + "filename": DATASET_FILE, + "matrix_row_indices": list(DEFAULT_MATRIX_ROWS), + "organic_row_index": ORGANIC_ROW_INDEX, + } + + +def select_rows( + rows: Sequence[Mapping[str, Any]], row_indices: Sequence[int] +) -> list[dict[str, Any]]: + """Select rows by stable positional index and annotate each selected row.""" + selected: list[dict[str, Any]] = [] + seen: set[int] = set() + for raw_index in row_indices: + if isinstance(raw_index, bool) or not isinstance(raw_index, int): + raise ValueError("row indices must be integers") + if raw_index < 0 or raw_index >= len(rows): + raise IndexError(f"row index {raw_index} is outside 0..{len(rows) - 1}") + if raw_index in seen: + raise ValueError(f"duplicate row index {raw_index}") + seen.add(raw_index) + row = dict(rows[raw_index]) + row["row_index"] = raw_index + selected.append(row) + return selected + + +def official_retrieval_score(response: str, answer: str, prefix: str) -> float: + """Apply MRCR's official prefix gate and SequenceMatcher ratio.""" + if not response.startswith(prefix): + return 0.0 + sampled = response.removeprefix(prefix) + expected = answer.removeprefix(prefix) + return float(SequenceMatcher(None, sampled, expected).ratio()) + + +def parse_memory_pressure_output(output: str) -> MemoryPressureReading: + """Parse ``memory_pressure -Q`` output or fail closed.""" + total_match = _MEMORY_TOTAL_RE.search(output) + free_match = _MEMORY_FREE_RE.search(output) + if total_match is None or free_match is None: + raise TelemetryError("memory_pressure output omitted required fields") + total_bytes = int(total_match.group(1)) + free_percent = float(free_match.group(1)) + if total_bytes <= 0: + raise TelemetryError("memory_pressure reported a non-positive total") + if not math.isfinite(free_percent) or not 0.0 <= free_percent <= 100.0: + raise TelemetryError("memory_pressure reported an invalid free percentage") + headroom_bytes = int(total_bytes * free_percent / 100.0) + return MemoryPressureReading(total_bytes, free_percent, headroom_bytes) + + +def safety_violation( + child_phys_bytes: int, + host_headroom_bytes: int, + *, + child_limit_bytes: int, + host_minimum_bytes: int, +) -> str | None: + """Return the fail-closed threshold violation, if any.""" + values = ( + child_phys_bytes, + host_headroom_bytes, + child_limit_bytes, + host_minimum_bytes, + ) + if any(isinstance(value, bool) or not isinstance(value, int) for value in values): + return "invalid safety telemetry type" + if child_phys_bytes <= 0 or host_headroom_bytes < 0: + return "invalid safety telemetry value" + if child_limit_bytes <= 0 or host_minimum_bytes < 0: + return "invalid safety threshold" + if child_phys_bytes >= child_limit_bytes: + return "child physical footprint reached its safety limit" + if host_headroom_bytes < host_minimum_bytes: + return "host headroom fell below its safety minimum" + return None + + +def canonical_json_bytes(value: Any) -> bytes: + """Serialize JSON data deterministically for hashing and atomic output.""" + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def stable_digest(value: Any) -> str: + """Return a SHA-256 digest of canonical JSON data.""" + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def hash_text(value: str) -> str: + """Return the exact UTF-8 SHA-256 of a string.""" + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def hash_token_ids(token_ids: Sequence[int]) -> str: + """Return a stable SHA-256 over an exact token-id sequence.""" + return stable_digest(list(token_ids)) + + +def _enable_verified_no_cache(file_descriptor: int) -> None: + """Enable Darwin F_NOCACHE and require the setter's verified success result.""" + if sys.platform != "darwin": + raise ValidationError("F_NOCACHE validation requires Darwin") + command = getattr(fcntl, "F_NOCACHE", None) + if isinstance(command, bool) or not isinstance(command, int): + raise ValidationError("Python does not expose Darwin F_NOCACHE") + try: + result = fcntl.fcntl(file_descriptor, command, 1) + except OSError as exc: + raise ValidationError( + f"F_NOCACHE failed for source tensor read: {exc}" + ) from exc + if result != 0: + raise ValidationError(f"F_NOCACHE setter returned unverified result {result!r}") + + +def hash_file(path: Path, chunk_bytes: int = 8 * 1024 * 1024) -> str: + """Hash one file through a verified no-cache descriptor.""" + digest = hashlib.sha256() + with path.open("rb", buffering=0) as handle: + _enable_verified_no_cache(handle.fileno()) + while True: + chunk = handle.read(chunk_bytes) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def _relative_manifest_path(path: Path, *, root: Path) -> str: + """Return one manifest path relative to its model root.""" + return path.relative_to(root).as_posix() + + +def build_model_manifest(model_path: Path) -> list[dict[str, Any]]: + """Hash every source safetensors file below a local model directory.""" + root = model_path.expanduser().resolve() + if not root.is_dir(): + raise ValidationError(f"model path is not a directory: {root}") + files = sorted( + (path for path in root.rglob("*.safetensors") if path.is_file()), + key=partial(_relative_manifest_path, root=root), + ) + if not files: + raise ValidationError(f"no source .safetensors files found under {root}") + return [ + { + "path": path.relative_to(root).as_posix(), + "size_bytes": path.stat().st_size, + "sha256": hash_file(path), + } + for path in files + ] + + +def manifests_equal( + before: Sequence[Mapping[str, Any]], after: Sequence[Mapping[str, Any]] +) -> bool: + """Compare source tensor manifests independent of mapping key order.""" + return canonical_json_bytes(list(before)) == canonical_json_bytes(list(after)) + + +def atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None: + """Atomically replace a JSON result and fsync both file and directory.""" + destination = path.expanduser().resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(canonical_json_bytes(dict(payload))) + handle.write(b"\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, destination) + directory_fd = os.open(destination.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if temporary.exists(): + temporary.unlink() + + +def shape_validation_result( + *, + kind: str, + provenance: Mapping[str, Any], + config: Mapping[str, Any], + before_manifest: Sequence[Mapping[str, Any]], + after_manifest: Sequence[Mapping[str, Any]], + results: Sequence[Mapping[str, Any]], + error: str | None, +) -> dict[str, Any]: + """Build the stable top-level result shape used by both public modes.""" + unchanged = manifests_equal(before_manifest, after_manifest) + effective_error = error + if not unchanged: + mutation_error = "source .safetensors manifest changed during validation" + effective_error = ( + mutation_error + if effective_error is None + else f"{effective_error}; {mutation_error}" + ) + return { + "schema_version": SCHEMA_VERSION, + "status": "ok" if effective_error is None else "failed", + "kind": kind, + "provenance": dict(provenance), + "config": dict(config), + "source_tensors": { + "before": list(before_manifest), + "after": list(after_manifest), + "unchanged": unchanged, + }, + "results": [dict(result) for result in results], + "error": effective_error, + } + + +def shape_validation_checkpoint( + *, + kind: str, + provenance: Mapping[str, Any], + config: Mapping[str, Any], + before_manifest: Sequence[Mapping[str, Any]], + results: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Build a restart-safe partial result without claiming final tensor proof.""" + return { + "schema_version": SCHEMA_VERSION, + "status": "running", + "kind": kind, + "provenance": dict(provenance), + "config": dict(config), + "source_tensors": { + "before": list(before_manifest), + "after": None, + "unchanged": None, + }, + "results": [dict(result) for result in results], + "error": None, + } + + +def child_environment(base_environment: Mapping[str, str]) -> dict[str, str]: + """Build a child environment with every forced diagnostic hook removed.""" + return { + key: value + for key, value in base_environment.items() + if not key.startswith(_FORCE_ENV_PREFIX) + } + + +def build_child_command( + script_path: Path, spec_path: Path, result_path: Path +) -> list[str]: + """Build the private child invocation without any trigger controls.""" + return [ + sys.executable, + str(script_path.expanduser().resolve()), + "__child", + "--spec", + str(spec_path.expanduser().resolve()), + "--result", + str(result_path.expanduser().resolve()), + ] + + +def matrix_shared_metadata( + *, + row_index: int, + chunk_size: int, + max_prompt_tokens: int, + greedy_token_limit: int, +) -> dict[str, Any]: + """Build metadata that must be byte-identical across a sample's modes.""" + return { + "dataset": { + "repo_id": DATASET_REPO, + "revision": DATASET_REVISION, + "filename": DATASET_FILE, + "row_index": row_index, + }, + "quality_cell": True, + "performance_cell": PRIMARY_PERFORMANCE_ROWS.get(row_index), + "thinking_enabled": False, + "chunk_size": chunk_size, + "max_prompt_tokens": max_prompt_tokens, + "greedy_token_limit": greedy_token_limit, + "teacher_forced_replay_token_limit": FIXED_REPLAY_TOKENS, + } + + +def validate_matrix_results( + results: Sequence[Mapping[str, Any]], + expected_rows: Sequence[int] | None = None, +) -> None: + """Reject incomplete modes, rows, or cross-mode metadata/input drift.""" + expected_modes = {mode.name for mode in matrix_modes()} + by_row: dict[int, list[Mapping[str, Any]]] = {} + for result in results: + metadata = result.get("metadata") + if not isinstance(metadata, Mapping): + raise ValidationError("matrix child result omitted metadata") + dataset = metadata.get("dataset") + if not isinstance(dataset, Mapping) or not isinstance( + dataset.get("row_index"), int + ): + raise ValidationError("matrix child result omitted its row index") + by_row.setdefault(int(dataset["row_index"]), []).append(result) + if expected_rows is not None and set(by_row) != set(expected_rows): + raise ValidationError( + f"matrix rows {sorted(by_row)} do not match {sorted(expected_rows)}" + ) + for row_index, group in by_row.items(): + modes = {str(item.get("mode")) for item in group} + if len(group) != len(expected_modes) or modes != expected_modes: + raise ValidationError( + f"row {row_index} has modes {sorted(modes)}, expected {sorted(expected_modes)}" + ) + metadata_digests = {stable_digest(item.get("metadata")) for item in group} + if len(metadata_digests) != 1: + raise ValidationError(f"row {row_index} metadata drifted across modes") + input_rows: list[tuple[Any, ...]] = [] + for item in group: + metrics = item.get("metrics") + if not isinstance(metrics, Mapping): + raise ValidationError(f"row {row_index} omitted input metrics") + values = ( + metrics.get("dataset_prompt_sha256"), + metrics.get("rendered_prompt_sha256"), + metrics.get("answer_sha256"), + metrics.get("prompt_token_ids_sha256"), + metrics.get("answer_token_ids_sha256"), + metrics.get("prompt_token_count"), + metrics.get("answer_token_count"), + ) + if any(value is None for value in values): + raise ValidationError(f"row {row_index} omitted exact input identity") + input_rows.append(values) + if len(set(input_rows)) != 1: + raise ValidationError(f"row {row_index} inputs drifted across modes") + + +def _run_text_command(command: Sequence[str], cwd: Path | None = None) -> str | None: + """Run a short provenance command and return stripped stdout on success.""" + try: + completed = subprocess.run( + list(command), + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return completed.stdout.strip() + + +def _parse_optional_int(value: str | None) -> int | None: + """Parse an optional integer provenance value.""" + if value is None: + return None + try: + return int(value) + except ValueError: + return None + + +def collect_provenance(config: Mapping[str, Any]) -> dict[str, Any]: + """Collect git, package, hardware, and exact invocation configuration.""" + repo_root = Path(__file__).resolve().parents[1] + packages: dict[str, str | None] = {} + for package in ( + "omlx", + "mlx", + "mlx-lm", + "mlx-vlm", + "transformers", + "huggingface-hub", + "pyarrow", + ): + try: + packages[package] = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: + packages[package] = None + git_head = _run_text_command(("git", "rev-parse", "HEAD"), repo_root) + git_status = _run_text_command( + ("git", "status", "--porcelain", "--untracked-files=no"), repo_root + ) + physical_memory = _run_text_command(("/usr/sbin/sysctl", "-n", "hw.memsize")) + hardware = { + "platform": platform.platform(), + "machine": platform.machine(), + "processor": platform.processor(), + "macos_version": platform.mac_ver()[0], + "chip": _run_text_command( + ("/usr/sbin/sysctl", "-n", "machdep.cpu.brand_string") + ), + "physical_memory_bytes": _parse_optional_int(physical_memory), + } + return { + "git": { + "head": git_head, + "tracked_worktree_clean": ( + git_status == "" if git_status is not None else None + ), + }, + "packages": packages, + "hardware": hardware, + "config_sha256": stable_digest(config), + } + + +def fetch_mrcr_rows( + row_indices: Sequence[int], cache_dir: Path | None +) -> list[dict[str, Any]]: + """Fetch the pinned parquet revision and return validated positional rows.""" + import pyarrow.parquet as parquet + from huggingface_hub import hf_hub_download + + parquet_path = hf_hub_download( + repo_id=DATASET_REPO, + filename=DATASET_FILE, + revision=DATASET_REVISION, + repo_type="dataset", + cache_dir=str(cache_dir) if cache_dir is not None else None, + ) + table = parquet.read_table( + parquet_path, + columns=["prompt", "answer", "random_string_to_prepend"], + ) + selected: list[dict[str, Any]] = [] + seen: set[int] = set() + for index in row_indices: + if isinstance(index, bool) or not isinstance(index, int): + raise ValidationError("MRCR row indices must be integers") + if index < 0 or index >= table.num_rows: + raise ValidationError( + f"MRCR row {index} is outside pinned file length {table.num_rows}" + ) + if index in seen: + raise ValidationError(f"duplicate MRCR row {index}") + seen.add(index) + selected.append( + { + "row_index": index, + "prompt": table["prompt"][index].as_py(), + "answer": table["answer"][index].as_py(), + "random_string_to_prepend": table["random_string_to_prepend"][ + index + ].as_py(), + } + ) + for row in selected: + prompt = row.get("prompt") + answer = row.get("answer") + prefix = row.get("random_string_to_prepend") + if ( + not isinstance(prompt, str) + or not isinstance(answer, str) + or not isinstance(prefix, str) + ): + raise ValidationError(f"MRCR row {row['row_index']} has invalid fields") + messages = json.loads(prompt) + if not isinstance(messages, list) or not all( + isinstance(message, dict) for message in messages + ): + raise ValidationError( + f"MRCR row {row['row_index']} prompt is not a message list" + ) + row["messages"] = messages + row["dataset_prompt_sha256"] = hash_text(prompt) + del row["prompt"] + return selected + + +def probe_memory_pressure() -> MemoryPressureReading: + """Run the mandatory kernel memory-pressure probe.""" + try: + completed = subprocess.run( + ("/usr/bin/memory_pressure", "-Q"), + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise TelemetryError(f"memory_pressure probe failed: {exc}") from exc + if completed.returncode != 0: + raise TelemetryError( + f"memory_pressure exited with status {completed.returncode}" + ) + return parse_memory_pressure_output(completed.stdout) + + +def probe_child_footprint(pid: int) -> int: + """Read a child's kernel phys_footprint or fail closed.""" + from omlx.utils.proc_memory import get_phys_footprint + + try: + value = get_phys_footprint(pid) + except Exception as exc: + raise TelemetryError(f"phys_footprint probe failed: {exc}") from exc + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise TelemetryError("phys_footprint probe returned no valid data") + return value + + +def _terminate_child( + process: subprocess.Popen[bytes], grace_seconds: float = 5.0 +) -> None: + """Terminate a supervised child and escalate only after a bounded grace.""" + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=grace_seconds) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=grace_seconds) + + +def supervise_child( + *, + command: Sequence[str], + environment: Mapping[str, str], + result_path: Path, + poll_interval_seconds: float, + child_limit_bytes: int, + host_minimum_bytes: int, +) -> SupervisedOutcome: + """Run one child while requiring valid safety telemetry on every poll.""" + if poll_interval_seconds <= 0: + raise ValueError("poll interval must be positive") + process = subprocess.Popen(list(command), env=dict(environment)) + peak_child = 0 + minimum_headroom: int | None = None + samples = 0 + error: str | None = None + try: + while True: + returncode = process.poll() + if returncode is not None: + if samples == 0: + error = "child exited before mandatory safety telemetry was sampled" + break + try: + child_phys = probe_child_footprint(process.pid) + pressure = probe_memory_pressure() + samples += 1 + peak_child = max(peak_child, child_phys) + minimum_headroom = ( + pressure.headroom_bytes + if minimum_headroom is None + else min(minimum_headroom, pressure.headroom_bytes) + ) + violation = safety_violation( + child_phys, + pressure.headroom_bytes, + child_limit_bytes=child_limit_bytes, + host_minimum_bytes=host_minimum_bytes, + ) + if violation is not None: + error = violation + _terminate_child(process) + break + except TelemetryError as exc: + # Darwin can stop serving proc_pid_rusage just before waitpid + # observes a normal exit. Allow only a bounded teardown grace. + try: + process.wait(timeout=TELEMETRY_EXIT_GRACE_SECONDS) + except subprocess.TimeoutExpired: + error = str(exc) + _terminate_child(process) + else: + if samples == 0: + error = ( + "child exited before mandatory safety telemetry was sampled" + ) + break + time.sleep(poll_interval_seconds) + + returncode = process.wait() + result: dict[str, Any] | None = None + if error is None: + if not result_path.is_file(): + error = "child exited without an atomic result" + else: + try: + decoded = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + error = f"child result is invalid: {exc}" + else: + if not isinstance(decoded, dict): + error = "child result is not an object" + else: + result = decoded + if decoded.get("status") != "ok": + error = str( + decoded.get("error") or "child reported failure" + ) + if returncode != 0 and error is None: + error = f"child exited with status {returncode}" + telemetry = { + "samples": samples, + "peak_child_phys_footprint_bytes": peak_child, + "minimum_host_headroom_bytes": minimum_headroom, + "child_limit_bytes": child_limit_bytes, + "host_minimum_bytes": host_minimum_bytes, + "poll_interval_seconds": poll_interval_seconds, + } + return SupervisedOutcome(result, telemetry, returncode, error) + finally: + if process.poll() is None: + _terminate_child(process) + + +def _apply_chat_template(tokenizer: Any, messages: list[dict[str, Any]]) -> str: + """Render a model chat template while explicitly disabling thinking.""" + if not hasattr(tokenizer, "apply_chat_template"): + raise ValidationError("model tokenizer has no chat template") + rendered = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + enable_thinking=False, + ) + if not isinstance(rendered, str): + raise ValidationError("chat template did not return text") + return rendered + + +def _encode_prompt(tokenizer: Any, text: str) -> list[int]: + """Encode already-templated prompt text using the model tokenizer.""" + encoded = tokenizer.encode(text) + return [int(token) for token in encoded] + + +def _encode_answer(tokenizer: Any, text: str) -> list[int]: + """Encode answer text without introducing a fresh sequence BOS token.""" + try: + encoded = tokenizer.encode(text, add_special_tokens=False) + except TypeError: + encoded = tokenizer.encode(text) + return [int(token) for token in encoded] + + +def _decode_tokens(tokenizer: Any, token_ids: Sequence[int]) -> str: + """Decode generated token IDs without retaining model outputs.""" + try: + decoded = tokenizer.decode(list(token_ids), skip_special_tokens=True) + except TypeError: + decoded = tokenizer.decode(list(token_ids)) + if not isinstance(decoded, str): + raise ValidationError("tokenizer.decode did not return text") + return decoded + + +def _extract_logits(output: Any) -> Any: + """Normalize mlx-lm array and model-output return forms.""" + return output.logits if hasattr(output, "logits") else output + + +def _timed_synchronized(mx: Any, operation: Callable[[], _T]) -> tuple[_T, float]: + """Fence one timed MLX phase on both sides.""" + mx.synchronize() + started = time.perf_counter() + result = operation() + mx.synchronize() + return result, time.perf_counter() - started + + +def _evaluate_cache(mx: Any, cache: list[Any]) -> None: + """Materialize cache state for one completed fixed chunk.""" + mx.eval([cache_object.state for cache_object in cache]) + + +def _prefill_chunk( + mx: Any, model: Any, cache: list[Any], token_ids: Sequence[int] +) -> None: + """Advance one prompt chunk while materializing only bounded cache state.""" + model(mx.array([list(token_ids)]), cache=cache) + _evaluate_cache(mx, cache) + + +def _model_chunk( + mx: Any, model: Any, cache: list[Any], token_ids: Sequence[int] +) -> Any: + """Run one bounded final/decode forward and materialize current logits.""" + output = model(mx.array([list(token_ids)]), cache=cache) + logits = _extract_logits(output) + mx.eval(logits, [cache_object.state for cache_object in cache]) + return logits + + +def _ordinary_convert(mx: Any, cache: list[Any], bits: float) -> dict[str, Any]: + """Use the production ordinary post-prefill conversion implementation.""" + from omlx.scheduler import Scheduler + + owner = SimpleNamespace(_turboquant_kv_bits=bits, _turboquant_skip_last=True) + Scheduler._apply_turboquant_kv_convert(owner, cache) + _evaluate_cache(mx, cache) + return {"path": "ordinary_from_cache"} + + +def _mid_convert(mx: Any, cache: list[Any], bits: float) -> dict[str, Any]: + """Use the bounded existing sliced conversion at the fixed midpoint.""" + from omlx.turboquant_kv import convert_kv_cache_sliced + + stats = convert_kv_cache_sliced(cache, bits=bits, skip_last=True) + _evaluate_cache(mx, cache) + return {"path": "sliced", **asdict(stats)} + + +def _run_direct_prefill( + mx: Any, + model: Any, + prompt_ids: Sequence[int], + mode: ValidationMode, + chunk_size: int, +) -> tuple[list[Any], Any, dict[str, Any]]: + """Run the direct fixed-chunk prompt loop with its exact conversion boundary.""" + from mlx_lm.models.cache import make_prompt_cache + + if len(prompt_ids) < 2: + raise ValidationError("rendered prompt must contain at least two tokens") + cache = make_prompt_cache(model) + cacheable = list(prompt_ids[:-1]) + midpoint = len(cacheable) // 2 + prefix_forward_seconds = 0.0 + suffix_forward_seconds = 0.0 + conversion_seconds = 0.0 + conversion: dict[str, Any] | None = None + + mx.synchronize() + total_started = time.perf_counter() + for start in range(0, midpoint, chunk_size): + end = min(start + chunk_size, midpoint) + _, elapsed = _timed_synchronized( + mx, + partial(_prefill_chunk, mx, model, cache, cacheable[start:end]), + ) + prefix_forward_seconds += elapsed + mx.clear_cache() + + if mode.conversion == "mid": + if mode.bits is None: + raise ValidationError("mid mode omitted its bit width") + conversion, conversion_seconds = _timed_synchronized( + mx, partial(_mid_convert, mx, cache, mode.bits) + ) + + mx.synchronize() + suffix_wall_started = time.perf_counter() + for start in range(midpoint, len(cacheable), chunk_size): + end = min(start + chunk_size, len(cacheable)) + _, elapsed = _timed_synchronized( + mx, + partial(_prefill_chunk, mx, model, cache, cacheable[start:end]), + ) + suffix_forward_seconds += elapsed + mx.clear_cache() + mx.synchronize() + suffix_wall_seconds = time.perf_counter() - suffix_wall_started + + # Production external prefill converts the resident N-1 prompt cache + # before BatchGenerator processes the held final prompt token. + if mode.conversion == "ordinary": + if mode.bits is None: + raise ValidationError("ordinary mode omitted its bit width") + conversion, conversion_seconds = _timed_synchronized( + mx, partial(_ordinary_convert, mx, cache, mode.bits) + ) + + logits, final_seconds = _timed_synchronized( + mx, partial(_model_chunk, mx, model, cache, prompt_ids[-1:]) + ) + suffix_forward_seconds += final_seconds + suffix_wall_seconds += final_seconds + mx.synchronize() + total_seconds = time.perf_counter() - total_started + suffix_tokens = len(prompt_ids) - midpoint + return ( + cache, + logits, + { + "prompt_tokens": len(prompt_ids), + "held_final_prompt_tokens": 1, + "midpoint_token_index": midpoint, + "same_boundary_suffix_tokens": suffix_tokens, + "prefix_forward_seconds": prefix_forward_seconds, + "same_boundary_suffix_forward_seconds": suffix_forward_seconds, + "same_boundary_suffix_seconds": suffix_wall_seconds, + "same_boundary_suffix_tokens_per_second": ( + suffix_tokens / suffix_wall_seconds if suffix_wall_seconds > 0 else 0.0 + ), + "conversion_seconds": conversion_seconds, + "conversion": conversion, + "total_prefill_seconds": total_seconds, + "total_prefill_tokens_per_second": ( + len(prompt_ids) / total_seconds if total_seconds > 0 else 0.0 + ), + }, + ) + + +def _percentile_nearest_rank(values: Sequence[float], percentile: float) -> float: + """Return a deterministic nearest-rank percentile.""" + if not values: + return 0.0 + ordered = sorted(values) + rank = max(1, math.ceil(percentile * len(ordered))) + return float(ordered[rank - 1]) + + +def _model_token_chunk( + mx: Any, + model: Any, + cache: list[Any], + token_id: int, + stream: Any | None, +) -> Any: + """Run one teacher-forced token on the scheduler stream when provided.""" + if stream is None: + return _model_chunk(mx, model, cache, (token_id,)) + with mx.stream(stream): + return _model_chunk(mx, model, cache, (token_id,)) + + +def _teacher_forced_replay( + mx: Any, + model: Any, + cache: list[Any], + initial_logits: Any, + replay_ids: Sequence[int], + *, + stream: Any | None = None, +) -> dict[str, Any]: + """Score and time bounded one-token teacher-forced forwards.""" + if not replay_ids: + raise ValidationError("teacher-forced replay is empty") + logits = initial_logits + latencies: list[float] = [] + nll_values: list[float] = [] + for token_id in replay_ids: + row = logits[0, -1].astype(mx.float32) + nll = mx.logsumexp(row) - row[int(token_id)] + mx.eval(nll) + nll_values.append(float(nll.item())) + + logits, elapsed = _timed_synchronized( + mx, + partial( + _model_token_chunk, + mx, + model, + cache, + int(token_id), + stream, + ), + ) + latencies.append(elapsed) + total_nll = math.fsum(nll_values) + mean_nll = total_nll / len(nll_values) + remaining = nll_values[1:] + remaining_mean = math.fsum(remaining) / len(remaining) if remaining else None + total_seconds = math.fsum(latencies) + return { + "token_count": len(replay_ids), + "token_ids_sha256": hash_token_ids(replay_ids), + "nll_sum": total_nll, + "mean_nll": mean_nll, + "perplexity": math.exp(mean_nll), + "first_token_nll": nll_values[0], + "tokens_2_to_n_count": len(remaining), + "tokens_2_to_n_mean_nll": remaining_mean, + "tokens_2_to_n_perplexity": ( + math.exp(remaining_mean) if remaining_mean is not None else None + ), + "decode_seconds": total_seconds, + "decode_tokens_per_second": ( + len(replay_ids) / total_seconds if total_seconds > 0 else 0.0 + ), + "latency_median_seconds": float(statistics.median(latencies)), + "latency_p95_seconds": _percentile_nearest_rank(latencies, 0.95), + } + + +def _eos_token_ids(tokenizer: Any) -> set[int]: + """Normalize a tokenizer's EOS declaration.""" + raw = getattr(tokenizer, "eos_token_id", None) + if raw is None: + return set() + if isinstance(raw, int) and not isinstance(raw, bool): + return {raw} + if isinstance(raw, Sequence) and not isinstance(raw, str | bytes): + return {int(value) for value in raw} + return set() + + +def _greedy_response( + mx: Any, + model: Any, + cache: list[Any], + initial_logits: Any, + tokenizer: Any, + token_limit: int, +) -> tuple[list[int], str]: + """Generate a separate untimed greedy response for official MRCR grading.""" + logits = initial_logits + generated: list[int] = [] + eos_ids = _eos_token_ids(tokenizer) + for _ in range(token_limit): + token = mx.argmax(logits[0, -1]) + mx.eval(token) + token_id = int(token.item()) + if token_id in eos_ids: + break + generated.append(token_id) + logits = _model_chunk(mx, model, cache, (token_id,)) + return generated, _decode_tokens(tokenizer, generated) + + +def _reset_mlx_measurement(mx: Any) -> dict[str, int]: + """Clear transients, capture separate baselines, and reset allocator peak.""" + mx.synchronize() + mx.clear_cache() + mx.synchronize() + baseline = { + "mlx_active_baseline_bytes": int(mx.get_active_memory()), + "mlx_cache_baseline_bytes": int(mx.get_cache_memory()), + } + mx.reset_peak_memory() + return baseline + + +def _finish_mlx_measurement(mx: Any, baseline: Mapping[str, int]) -> dict[str, int]: + """Capture MLX peak/final counters without conflating phys_footprint.""" + mx.synchronize() + return { + **dict(baseline), + "mlx_peak_bytes": int(mx.get_peak_memory()), + "mlx_active_final_bytes": int(mx.get_active_memory()), + "mlx_cache_final_bytes": int(mx.get_cache_memory()), + } + + +def _load_direct_model( + model_path: str, mode: ValidationMode, trust: bool +) -> tuple[Any, Any]: + """Load one direct-loop model with production compatibility transforms.""" + from omlx.model_settings import ModelSettings + from omlx.utils.model_loading import ( + apply_post_load_transforms, + lm_load_compat, + materialize_lazy_state, + maybe_apply_pre_load_patches, + ) + from omlx.utils.tokenizer import get_tokenizer_config + + settings = ModelSettings( + enable_thinking=False, + turboquant_kv_enabled=mode.bits is not None, + turboquant_mid_prefill=mode.conversion == "mid", + turboquant_kv_bits=mode.bits or 4.0, + turboquant_skip_last=True, + ) + maybe_apply_pre_load_patches(model_path, model_settings=settings) + tokenizer_config = get_tokenizer_config(model_path, trust_remote_code=trust) + model, tokenizer = lm_load_compat( + model_path, + tokenizer_config=tokenizer_config, + trust_remote_code=trust, + ) + model = apply_post_load_transforms(model, settings) + materialize_lazy_state(model) + if mode.bits is not None: + from omlx.patches.turboquant_attention import apply_turboquant_attention_patch + + apply_turboquant_attention_patch() + return model, tokenizer + + +def run_matrix_child(spec: Mapping[str, Any]) -> dict[str, Any]: + """Execute one fresh real-model matrix cell.""" + import mlx.core as mx + + mode_data = spec["mode"] + mode = ValidationMode( + str(mode_data["name"]), + str(mode_data["conversion"]), + float(mode_data["bits"]) if mode_data["bits"] is not None else None, + ) + sample = spec["sample"] + metadata = dict(spec["metadata"]) + model: Any | None = None + tokenizer: Any | None = None + primary_cache: list[Any] | None = None + greedy_cache: list[Any] | None = None + try: + model, tokenizer = _load_direct_model( + str(spec["model_path"]), mode, bool(spec.get("trust_remote_code", False)) + ) + rendered = _apply_chat_template(tokenizer, list(sample["messages"])) + prompt_ids = _encode_prompt(tokenizer, rendered) + if len(prompt_ids) > int(metadata["max_prompt_tokens"]): + raise ValidationError( + f"rendered prompt has {len(prompt_ids)} tokens, above safety limit " + f"{metadata['max_prompt_tokens']}" + ) + answer = str(sample["answer"]) + answer_ids = _encode_answer(tokenizer, answer) + replay_ids = answer_ids[:FIXED_REPLAY_TOKENS] + baseline = _reset_mlx_measurement(mx) + + primary_cache, primary_logits, prefill = _run_direct_prefill( + mx, model, prompt_ids, mode, int(metadata["chunk_size"]) + ) + replay = _teacher_forced_replay( + mx, model, primary_cache, primary_logits, replay_ids + ) + primary_cache = None + primary_logits = None + gc.collect() + mx.synchronize() + mx.clear_cache() + + greedy_cache, greedy_logits, _ = _run_direct_prefill( + mx, model, prompt_ids, mode, int(metadata["chunk_size"]) + ) + generated_ids, response = _greedy_response( + mx, + model, + greedy_cache, + greedy_logits, + tokenizer, + int(metadata["greedy_token_limit"]), + ) + score = official_retrieval_score( + response, + answer, + str(sample["random_string_to_prepend"]), + ) + memory = _finish_mlx_measurement(mx, baseline) + return { + "schema_version": SCHEMA_VERSION, + "status": "ok", + "kind": "matrix", + "mode": mode.name, + "metadata": metadata, + "metrics": { + "dataset_prompt_sha256": sample["dataset_prompt_sha256"], + "rendered_prompt_sha256": hash_text(rendered), + "prompt_token_ids_sha256": hash_token_ids(prompt_ids), + "answer_sha256": hash_text(answer), + "answer_token_ids_sha256": hash_token_ids(answer_ids), + "prompt_token_count": len(prompt_ids), + "answer_token_count": len(answer_ids), + "teacher_forced_replay_truncated": len(answer_ids) > len(replay_ids), + "prefill": prefill, + "teacher_forced": replay, + "greedy_retrieval": { + "response": response, + "response_sha256": hash_text(response), + "token_count": len(generated_ids), + "token_ids_sha256": hash_token_ids(generated_ids), + "required_prefix": sample["random_string_to_prepend"], + "official_score": score, + }, + "memory": memory, + }, + } + finally: + primary_cache = None + greedy_cache = None + model = None + tokenizer = None + gc.collect() + try: + mx.synchronize() + mx.clear_cache() + except Exception: + logger.exception("failed to clear MLX cache after matrix child") + + +def _find_pool_model_id(pool: Any, model_path: Path) -> str: + """Resolve the discovered EnginePool ID for an exact local model path.""" + expected = model_path.expanduser().resolve() + matches = [ + model_id + for model_id in pool.get_model_ids() + if Path(pool.get_entry(model_id).model_path).expanduser().resolve() == expected + ] + if len(matches) != 1: + raise ValidationError( + f"EnginePool discovered {len(matches)} entries for exact model path {expected}" + ) + return matches[0] + + +def _capture_initial_prefill_pause( + mx: Any, + scheduler: Any, + request: Any, + prompt_ids: Sequence[int], +) -> OrganicPrefillPause: + """Run the first organic attempt and require the production LRU pause.""" + from omlx.scheduler import _PrefillEvictionNeeded + + captured: list[Any] = [] + original = scheduler._new_prefill_context + + def _capture( + inner_request: Any, + prompt_cache: list[Any], + *, + loop_label: str, + ) -> Any: + context = original(inner_request, prompt_cache, loop_label=loop_label) + captured.append(context) + return context + + scheduler._new_prefill_context = _capture + baseline = _reset_mlx_measurement(mx) + mx.synchronize() + total_started = time.perf_counter() + attempt_started = time.perf_counter() + try: + try: + completed = scheduler._do_external_prefill( + request, + list(prompt_ids), + existing_cache=None, + ) + except _PrefillEvictionNeeded as exc: + mx.synchronize() + external_seconds = time.perf_counter() - attempt_started + eviction_request = exc.request + else: + del completed + raise ValidationError( + "organic first prefill attempt completed without the production LRU pause" + ) + if len(captured) != 1: + raise ValidationError( + f"organic first attempt captured {len(captured)} contexts instead of one" + ) + context = captured[0] + if context.mid_triggered: + raise ValidationError( + "organic first attempt converted before the LRU pause" + ) + return OrganicPrefillPause( + baseline=baseline, + context=context, + eviction_request=eviction_request, + external_seconds=external_seconds, + total_started=total_started, + ) + finally: + scheduler._new_prefill_context = original + gc.collect() + mx.synchronize() + mx.clear_cache() + + +def _capture_external_prefill_after_pause( + mx: Any, + scheduler: Any, + request: Any, + prompt_ids: Sequence[int], + replay_ids: Sequence[int], + pause: OrganicPrefillPause, + eviction_callback_result: bool, + eviction_pause_seconds: float, +) -> dict[str, Any]: + """Retry organic prefill after LRU handling and require one conversion.""" + captured: list[Any] = [] + original = scheduler._new_prefill_context + + def _capture( + inner_request: Any, + prompt_cache: list[Any], + *, + loop_label: str, + ) -> Any: + context = original(inner_request, prompt_cache, loop_label=loop_label) + captured.append(context) + return context + + scheduler._new_prefill_context = _capture + try: + (cache, last_token), retry_external_seconds = _timed_synchronized( + mx, + partial( + scheduler._do_external_prefill, + request, + list(prompt_ids), + existing_cache=None, + ), + ) + external_completed = time.perf_counter() + with mx.stream(scheduler._stream): + logits, final_seconds = _timed_synchronized( + mx, + partial( + _model_chunk, + mx, + scheduler.model, + cache, + tuple(last_token), + ), + ) + total_seconds = time.perf_counter() - pause.total_started + replay = _teacher_forced_replay( + mx, + scheduler.model, + cache, + logits, + replay_ids, + stream=scheduler._stream, + ) + if len(captured) != 1: + raise ValidationError( + f"organic retry captured {len(captured)} contexts instead of one" + ) + context = captured[0] + if context is pause.context: + raise ValidationError("organic retry reused the first attempt's context") + if pause.context.mid_triggered: + raise ValidationError("organic first attempt converted before its pause") + if not context.mid_triggered: + raise ValidationError( + "organic retry completed without mid-prefill conversion" + ) + if int(request.prefill_eviction_retries) != 1: + raise ValidationError( + "organic request did not retain exactly one production LRU retry" + ) + post_trigger_seconds = ( + external_completed - context.conversion_completed_at + if context.conversion_completed_at is not None + else 0.0 + ) + memory = _finish_mlx_measurement(mx, pause.baseline) + cache = None + logits = None + external_attempt_seconds = [ + pause.external_seconds, + retry_external_seconds, + ] + return { + "actual_mid_prefill_trigger": True, + "prefill_attempt_count": 2, + "eviction_pause_count": 1, + "prefill_eviction_retries": int(request.prefill_eviction_retries), + "eviction_callback_reclaimed": bool(eviction_callback_result), + "eviction_pause_seconds": eviction_pause_seconds, + "trigger_tokens": context.trigger_tokens, + "conversion_seconds": float(context.conversion_seconds), + "converted_layers": int(context.converted_layers), + "conversion_slices": int(context.conversion_slices), + "skipped_dense_layers": int(context.skipped_dense_layers), + "external_prefill_attempt_seconds": external_attempt_seconds, + "external_prefill_seconds": sum(external_attempt_seconds), + "held_final_prompt_token_seconds": final_seconds, + "total_prefill_seconds": total_seconds, + "total_prefill_tokens_per_second": ( + len(prompt_ids) / total_seconds if total_seconds > 0 else 0.0 + ), + "post_trigger_tokens": int(context.post_trigger_tokens), + "post_trigger_seconds": post_trigger_seconds, + "post_trigger_tokens_per_second": ( + context.post_trigger_tokens / post_trigger_seconds + if post_trigger_seconds > 0 + else 0.0 + ), + "teacher_forced": replay, + "memory": memory, + } + finally: + scheduler._new_prefill_context = original + gc.collect() + mx.synchronize() + mx.clear_cache() + + +def _configure_organic_pressure( + scheduler: Any, + *, + soft_limit_bytes: int, + hard_limit_bytes: int, + prefill_abort_margin: float, + prefill_min_chunk_tokens: int, +) -> None: + """Apply normal propagated scheduler pressure limits without a trigger hook.""" + if soft_limit_bytes <= 0 or hard_limit_bytes <= 0: + raise ValidationError("organic scheduler limits must be positive") + if soft_limit_bytes > hard_limit_bytes: + raise ValidationError("organic soft limit exceeds hard limit") + if not 0.0 < prefill_abort_margin <= 1.0: + raise ValidationError("organic prefill abort margin must be in (0, 1]") + if prefill_min_chunk_tokens <= 0: + raise ValidationError("organic prefill minimum chunk must be positive") + scheduler._memory_limit_bytes = soft_limit_bytes + scheduler._memory_hard_limit_bytes = hard_limit_bytes + scheduler._memory_hard_watermark_bytes = hard_limit_bytes + scheduler._memory_abort_limit_bytes = hard_limit_bytes + scheduler._memory_static_ceiling_bytes = hard_limit_bytes + scheduler._memory_dynamic_ceiling_bytes = hard_limit_bytes + scheduler._memory_metal_cap_bytes = hard_limit_bytes + scheduler._memory_guard_tier = "custom" + scheduler._prefill_abort_margin = prefill_abort_margin + scheduler._prefill_min_chunk_tokens = prefill_min_chunk_tokens + scheduler._prefill_memory_guard = True + scheduler._memory_limits_propagated = True + + +async def _run_organic_child_async(spec: Mapping[str, Any]) -> dict[str, Any]: + """Load via EnginePool and run the production external-prefill pressure path.""" + import mlx.core as mx + + from omlx.engine_pool import EnginePool + from omlx.model_settings import ModelSettings + from omlx.request import Request, SamplingParams + from omlx.scheduler import SchedulerConfig + + model_path = Path(str(spec["model_path"])).expanduser().resolve() + chunk_size = int(spec["chunk_size"]) + scheduler_config = SchedulerConfig( + max_num_seqs=1, + max_num_batched_tokens=8192, + completion_batch_size=1, + prefill_step_size=chunk_size, + chunked_prefill=False, + prefill_speed_priority=False, + paged_ssd_cache_dir=None, + hot_cache_max_size=0, + ) + pool = EnginePool(scheduler_config=scheduler_config) + engine: Any | None = None + scheduler: Any | None = None + request: Any | None = None + try: + pool.discover_models(str(model_path.parent)) + model_id = _find_pool_model_id(pool, model_path) + settings = ModelSettings( + enable_thinking=False, + trust_remote_code=bool(spec.get("trust_remote_code", False)), + turboquant_kv_enabled=True, + turboquant_mid_prefill=True, + turboquant_kv_bits=8.0, + turboquant_skip_last=True, + ) + engine = await pool.get_engine( + model_id, + force_lm=True, + runtime_settings=settings, + ) + if pool.loaded_model_count != 1: + raise ValidationError( + "organic validation did not retain exclusive model count" + ) + rendered = _apply_chat_template( + engine.tokenizer, + list(spec["sample"]["messages"]), + ) + rendered_ids = _encode_prompt(engine.tokenizer, rendered) + required = ORGANIC_PROMPT_TOKENS + FIXED_REPLAY_TOKENS + if len(rendered_ids) < required: + raise ValidationError( + f"organic row rendered to {len(rendered_ids)} tokens, requires {required}" + ) + prompt_ids = rendered_ids[:ORGANIC_PROMPT_TOKENS] + replay_ids = rendered_ids[ORGANIC_PROMPT_TOKENS:required] + core = engine._engine.engine + await core.stop() + scheduler = core.scheduler + if scheduler._prefill_eviction_callback_configured is not True: + raise ValidationError("organic engine has no production prefill callback") + _configure_organic_pressure( + scheduler, + soft_limit_bytes=int(spec["scheduler_soft_limit_bytes"]), + hard_limit_bytes=int(spec["scheduler_hard_limit_bytes"]), + prefill_abort_margin=float(spec["prefill_abort_margin"]), + prefill_min_chunk_tokens=int(spec["prefill_min_chunk_tokens"]), + ) + request = Request( + request_id="turboquant-organic-validation", + prompt=list(prompt_ids), + sampling_params=SamplingParams( + max_tokens=FIXED_REPLAY_TOKENS, + temperature=0.0, + ), + prompt_token_ids=list(prompt_ids), + num_prompt_tokens=len(prompt_ids), + skip_cache_store=True, + ) + scheduler.requests[request.request_id] = request + loop = asyncio.get_running_loop() + pause = await loop.run_in_executor( + core._mlx_executor, + _capture_initial_prefill_pause, + mx, + scheduler, + request, + prompt_ids, + ) + eviction_started = time.perf_counter() + eviction_callback_result = await pool._evict_idle_lru_for_prefill( + exclude_model_id=model_id, + eviction_request=pause.eviction_request, + ) + eviction_pause_seconds = time.perf_counter() - eviction_started + metrics = await loop.run_in_executor( + core._mlx_executor, + _capture_external_prefill_after_pause, + mx, + scheduler, + request, + prompt_ids, + replay_ids, + pause, + eviction_callback_result, + eviction_pause_seconds, + ) + return { + "schema_version": SCHEMA_VERSION, + "status": "ok", + "kind": "organic", + "mode": "organic-mid-q8", + "metadata": dict(spec["metadata"]), + "metrics": { + "dataset_prompt_sha256": spec["sample"]["dataset_prompt_sha256"], + "full_rendered_sha256": hash_text(rendered), + "full_rendered_token_count": len(rendered_ids), + "prompt_token_ids_sha256": hash_token_ids(prompt_ids), + "prompt_token_count": len(prompt_ids), + "replay_token_ids_sha256": hash_token_ids(replay_ids), + "replay_token_count": len(replay_ids), + **metrics, + }, + } + finally: + if scheduler is not None and request is not None: + scheduler.requests.pop(request.request_id, None) + await pool.shutdown() + engine = None + gc.collect() + try: + mx.synchronize() + mx.clear_cache() + except Exception: + logger.exception("failed to clear MLX cache after organic child") + + +def run_organic_child(spec: Mapping[str, Any]) -> dict[str, Any]: + """Run the asynchronous organic child entry point.""" + return asyncio.run(_run_organic_child_async(spec)) + + +def run_child(spec_path: Path, result_path: Path) -> int: + """Execute a private child spec and always publish an atomic result.""" + try: + decoded = json.loads(spec_path.read_text(encoding="utf-8")) + if not isinstance(decoded, dict): + raise ValidationError("child spec is not an object") + kind = decoded.get("kind") + if kind == "matrix": + result = run_matrix_child(decoded) + elif kind == "organic": + result = run_organic_child(decoded) + else: + raise ValidationError(f"unknown child kind: {kind!r}") + atomic_write_json(result_path, result) + return 0 + except BaseException as exc: + logger.exception("validation child failed") + atomic_write_json( + result_path, + { + "schema_version": SCHEMA_VERSION, + "status": "failed", + "error": f"{type(exc).__name__}: {exc}", + }, + ) + return 1 + + +def _ensure_outside_model(model_path: Path, candidate: Path, label: str) -> None: + """Reject every configured write location below the read-only model path.""" + model_root = model_path.expanduser().resolve() + resolved = candidate.expanduser().resolve() + if resolved == model_root or resolved.is_relative_to(model_root): + raise ValidationError(f"{label} must not be under the model path") + + +def _validate_runtime_write_locations( + model_path: Path, + *, + output_path: Path, + dataset_cache_dir: Path | None, +) -> None: + """Ensure every known cache/temp/result location is outside model sources.""" + _ensure_outside_model(model_path, output_path, "output") + if dataset_cache_dir is not None: + _ensure_outside_model(model_path, dataset_cache_dir, "dataset cache") + _ensure_outside_model( + model_path, + Path(tempfile.gettempdir()), + "temporary directory", + ) + for variable in ( + "HF_HOME", + "HF_HUB_CACHE", + "HUGGINGFACE_HUB_CACHE", + "TRANSFORMERS_CACHE", + "XDG_CACHE_HOME", + "TMPDIR", + "TMP", + "TEMP", + ): + value = os.environ.get(variable) + if value: + _ensure_outside_model( + model_path, + Path(value), + f"{variable} environment location", + ) + + +def _run_supervised_spec( + *, + spec: Mapping[str, Any], + workspace: Path, + sequence: int, + args: argparse.Namespace, +) -> SupervisedOutcome: + """Persist one spec and supervise its fresh child.""" + spec_path = workspace / f"spec-{sequence:03d}.json" + result_path = workspace / f"result-{sequence:03d}.json" + atomic_write_json(spec_path, spec) + command = build_child_command(Path(__file__), spec_path, result_path) + return supervise_child( + command=command, + environment=child_environment(os.environ), + result_path=result_path, + poll_interval_seconds=float(args.poll_interval), + child_limit_bytes=int(float(args.child_memory_gib) * GIB), + host_minimum_bytes=int(float(args.host_headroom_gib) * GIB), + ) + + +def _matrix_config(args: argparse.Namespace) -> dict[str, Any]: + """Build JSON-compatible matrix configuration provenance.""" + return { + "model_path": str(Path(args.model).expanduser().resolve()), + "dataset_cache_dir": ( + str(Path(args.dataset_cache_dir).expanduser().resolve()) + if args.dataset_cache_dir + else None + ), + "dataset": pinned_dataset_config(), + "rows": list(args.rows), + "modes": [asdict(mode) for mode in matrix_modes()], + "chunk_size": args.chunk_size, + "max_prompt_tokens": args.max_prompt_tokens, + "greedy_token_limit": args.greedy_token_limit, + "teacher_forced_replay_token_limit": FIXED_REPLAY_TOKENS, + "thinking_enabled": False, + "child_memory_limit_bytes": int(float(args.child_memory_gib) * GIB), + "host_headroom_minimum_bytes": int(float(args.host_headroom_gib) * GIB), + "poll_interval_seconds": args.poll_interval, + "trust_remote_code": args.trust_remote_code, + } + + +def _organic_config(args: argparse.Namespace) -> dict[str, Any]: + """Build JSON-compatible organic configuration provenance.""" + return { + "model_path": str(Path(args.model).expanduser().resolve()), + "dataset_cache_dir": ( + str(Path(args.dataset_cache_dir).expanduser().resolve()) + if args.dataset_cache_dir + else None + ), + "dataset": { + **pinned_dataset_config(), + "row_index": ORGANIC_ROW_INDEX, + }, + "mode": "organic-mid-q8", + "prompt_tokens": ORGANIC_PROMPT_TOKENS, + "teacher_forced_replay_tokens": FIXED_REPLAY_TOKENS, + "chunk_size": args.chunk_size, + "scheduler": { + "max_num_seqs": 1, + "max_num_batched_tokens": 8192, + "completion_batch_size": 1, + "prefill_step_size": args.chunk_size, + "chunked_prefill": False, + "prefill_speed_priority": False, + "prefill_abort_margin": ORGANIC_PREFILL_ABORT_MARGIN, + "prefill_min_chunk_tokens": ORGANIC_PREFILL_MIN_CHUNK_TOKENS, + }, + "turboquant_bits": 8.0, + "turboquant_skip_last": True, + "exclusive_ownership": True, + "no_cache": True, + "forced_trigger": False, + "thinking_enabled": False, + "scheduler_soft_limit_bytes": int(float(args.scheduler_soft_limit_gib) * GIB), + "scheduler_hard_limit_bytes": int(float(args.child_memory_gib) * GIB), + "prefill_abort_margin": ORGANIC_PREFILL_ABORT_MARGIN, + "prefill_min_chunk_tokens": ORGANIC_PREFILL_MIN_CHUNK_TOKENS, + "child_memory_limit_bytes": int(float(args.child_memory_gib) * GIB), + "host_headroom_minimum_bytes": int(float(args.host_headroom_gib) * GIB), + "poll_interval_seconds": args.poll_interval, + "trust_remote_code": args.trust_remote_code, + } + + +def run_parent(args: argparse.Namespace) -> int: + """Run all public work under fresh-child supervision and tensor verification.""" + model_path = Path(args.model).expanduser().resolve() + output_path = Path(args.output).expanduser().resolve() + cache_dir = ( + Path(args.dataset_cache_dir).expanduser().resolve() + if args.dataset_cache_dir + else None + ) + _validate_runtime_write_locations( + model_path, + output_path=output_path, + dataset_cache_dir=cache_dir, + ) + config = _matrix_config(args) if args.command == "matrix" else _organic_config(args) + provenance = collect_provenance(config) + before = build_model_manifest(model_path) + results: list[dict[str, Any]] = [] + error: str | None = None + atomic_write_json( + output_path, + shape_validation_checkpoint( + kind=str(args.command), + provenance=provenance, + config=config, + before_manifest=before, + results=results, + ), + ) + try: + row_indices = ( + list(args.rows) if args.command == "matrix" else [ORGANIC_ROW_INDEX] + ) + rows = fetch_mrcr_rows(row_indices, cache_dir) + with tempfile.TemporaryDirectory( + prefix="omlx-turboquant-validation-" + ) as temporary: + workspace = Path(temporary) + sequence = 0 + if args.command == "matrix": + for sample in rows: + metadata = matrix_shared_metadata( + row_index=int(sample["row_index"]), + chunk_size=int(args.chunk_size), + max_prompt_tokens=int(args.max_prompt_tokens), + greedy_token_limit=int(args.greedy_token_limit), + ) + for mode in matrix_modes(): + spec = { + "schema_version": SCHEMA_VERSION, + "kind": "matrix", + "model_path": str(model_path), + "trust_remote_code": bool(args.trust_remote_code), + "mode": asdict(mode), + "sample": sample, + "metadata": metadata, + } + outcome = _run_supervised_spec( + spec=spec, + workspace=workspace, + sequence=sequence, + args=args, + ) + sequence += 1 + child_result = ( + dict(outcome.result) + if outcome.result is not None + else { + "schema_version": SCHEMA_VERSION, + "status": "failed", + "kind": "matrix", + "mode": mode.name, + "metadata": metadata, + "error": outcome.error, + } + ) + child_result["supervision"] = outcome.telemetry + results.append(child_result) + atomic_write_json( + output_path, + shape_validation_checkpoint( + kind=str(args.command), + provenance=provenance, + config=config, + before_manifest=before, + results=results, + ), + ) + if outcome.error is not None: + raise ValidationError(outcome.error) + validate_matrix_results(results, expected_rows=args.rows) + else: + sample = rows[0] + metadata = { + "dataset": { + "repo_id": DATASET_REPO, + "revision": DATASET_REVISION, + "filename": DATASET_FILE, + "row_index": ORGANIC_ROW_INDEX, + }, + "thinking_enabled": False, + "prompt_tokens": ORGANIC_PROMPT_TOKENS, + "teacher_forced_replay_tokens": FIXED_REPLAY_TOKENS, + "chunk_size": int(args.chunk_size), + "no_cache": True, + "exclusive_ownership": True, + "forced_trigger": False, + "prefill_abort_margin": ORGANIC_PREFILL_ABORT_MARGIN, + "prefill_min_chunk_tokens": ORGANIC_PREFILL_MIN_CHUNK_TOKENS, + } + spec = { + "schema_version": SCHEMA_VERSION, + "kind": "organic", + "model_path": str(model_path), + "trust_remote_code": bool(args.trust_remote_code), + "sample": sample, + "metadata": metadata, + "chunk_size": int(args.chunk_size), + "scheduler_soft_limit_bytes": int( + float(args.scheduler_soft_limit_gib) * GIB + ), + "scheduler_hard_limit_bytes": int( + float(args.child_memory_gib) * GIB + ), + "prefill_abort_margin": ORGANIC_PREFILL_ABORT_MARGIN, + "prefill_min_chunk_tokens": ORGANIC_PREFILL_MIN_CHUNK_TOKENS, + } + outcome = _run_supervised_spec( + spec=spec, + workspace=workspace, + sequence=sequence, + args=args, + ) + child_result = ( + dict(outcome.result) + if outcome.result is not None + else { + "schema_version": SCHEMA_VERSION, + "status": "failed", + "kind": "organic", + "mode": "organic-mid-q8", + "metadata": metadata, + "error": outcome.error, + } + ) + child_result["supervision"] = outcome.telemetry + results.append(child_result) + atomic_write_json( + output_path, + shape_validation_checkpoint( + kind=str(args.command), + provenance=provenance, + config=config, + before_manifest=before, + results=results, + ), + ) + if outcome.error is not None: + raise ValidationError(outcome.error) + except BaseException as exc: + logger.exception("validation parent failed") + error = f"{type(exc).__name__}: {exc}" + try: + after = build_model_manifest(model_path) + except BaseException as exc: + logger.exception("post-run tensor manifest failed") + after = [] + manifest_error = f"{type(exc).__name__}: {exc}" + error = manifest_error if error is None else f"{error}; {manifest_error}" + payload = shape_validation_result( + kind=str(args.command), + provenance=provenance, + config=config, + before_manifest=before, + after_manifest=after, + results=results, + error=error, + ) + atomic_write_json(output_path, payload) + return 0 if payload["status"] == "ok" else 1 + + +def _add_common_parent_arguments(parser: argparse.ArgumentParser) -> None: + """Add shared public supervisor arguments.""" + parser.add_argument( + "--model", required=True, help="Local read-only model directory" + ) + parser.add_argument("--output", required=True, help="Atomic provenance/result JSON") + parser.add_argument( + "--dataset-cache-dir", + help="Optional Hugging Face dataset cache (must be outside the model)", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=2048, + help="Fixed prefill chunk size (default: 2048)", + ) + parser.add_argument( + "--child-memory-gib", + type=float, + default=36.0, + help="Terminate at this child phys_footprint in GiB (default: 36)", + ) + parser.add_argument( + "--host-headroom-gib", + type=float, + default=6.0, + help="Terminate below this kernel host headroom in GiB (default: 6)", + ) + parser.add_argument( + "--poll-interval", + type=float, + default=0.25, + help="Safety telemetry polling interval in seconds (default: 0.25)", + ) + parser.add_argument( + "--trust-remote-code", + action="store_true", + help="Allow model remote code during loading", + ) + + +def build_parser() -> argparse.ArgumentParser: + """Build the typed validation CLI parser.""" + parser = argparse.ArgumentParser( + prog="validate_turboquant_mid_prefill.py", + description=( + "Validate TurboQuant mid-prefill quality, throughput, and memory " + "with fail-closed parent supervision." + ), + ) + subparsers = parser.add_subparsers(dest="command", required=True) + matrix = subparsers.add_parser( + "matrix", + help="Run pinned MRCR rows in dense, ordinary Q8/Q4, and mid Q8/Q4 modes", + ) + _add_common_parent_arguments(matrix) + matrix.add_argument( + "--rows", + type=int, + nargs="+", + default=list(DEFAULT_MATRIX_ROWS), + help="Pinned parquet row indices (default: 104 109 136 301 311 328)", + ) + matrix.add_argument( + "--max-prompt-tokens", + type=int, + default=131072, + help="Reject, never truncate, rendered prompts above this count", + ) + matrix.add_argument( + "--greedy-token-limit", + type=int, + default=1024, + help="Maximum untimed greedy tokens for official MRCR scoring", + ) + + organic = subparsers.add_parser( + "organic", + help="Run the natural 131071-token production scheduler pressure path", + ) + _add_common_parent_arguments(organic) + organic.add_argument( + "--scheduler-soft-limit-gib", + type=float, + default=32.0, + help="Normal scheduler pressure target in GiB (default: 32)", + ) + + child = subparsers.add_parser("__child", help=argparse.SUPPRESS) + child.add_argument("--spec", required=True) + child.add_argument("--result", required=True) + return parser + + +def _validate_cli_arguments(args: argparse.Namespace) -> None: + """Reject nonsensical limits before hashing or launching children.""" + if args.command == "__child": + return + if args.chunk_size <= 0: + raise ValidationError("chunk size must be positive") + if args.child_memory_gib <= 0: + raise ValidationError("child memory limit must be positive") + if args.host_headroom_gib < 0: + raise ValidationError("host headroom minimum must be non-negative") + if args.poll_interval <= 0: + raise ValidationError("poll interval must be positive") + if args.command == "matrix": + if args.max_prompt_tokens <= 1: + raise ValidationError("max prompt tokens must exceed one") + if args.greedy_token_limit <= 0: + raise ValidationError("greedy token limit must be positive") + if tuple(args.rows) != DEFAULT_MATRIX_ROWS: + raise ValidationError("matrix rows are pinned to 104 109 136 301 311 328") + if args.command == "organic": + if args.scheduler_soft_limit_gib <= 0: + raise ValidationError("scheduler soft limit must be positive") + if args.scheduler_soft_limit_gib > args.child_memory_gib: + raise ValidationError("scheduler soft limit exceeds child hard limit") + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI entry point.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + parser = build_parser() + args = parser.parse_args(argv) + try: + _validate_cli_arguments(args) + if args.command == "__child": + return run_child(Path(args.spec), Path(args.result)) + return run_parent(args) + except BaseException as exc: + logger.error("validation failed: %s: %s", type(exc).__name__, exc) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_server_endpoints.py b/tests/integration/test_server_endpoints.py index 7e5ef36ee..1be0bc100 100644 --- a/tests/integration/test_server_endpoints.py +++ b/tests/integration/test_server_endpoints.py @@ -7,10 +7,11 @@ """ import json +from collections.abc import AsyncIterator from dataclasses import dataclass, field from types import SimpleNamespace from typing import Any, Dict, List, Optional -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -852,6 +853,217 @@ def test_completion_with_list_prompt(self, client): data = response.json() assert "choices" in data + def test_completion_forwards_scalar_preflight_eviction_attempt( + self, + client: TestClient, + mock_llm_engine: MockBaseEngine, + ) -> None: + mock_llm_engine.preflight_completion = AsyncMock(return_value=True) + mock_llm_engine.generate = AsyncMock( + return_value=MockGenerationOutput(text="Generated response.") + ) + + response = client.post( + "/v1/completions", + json={"model": "test-model", "prompt": "First prompt"}, + ) + + assert response.status_code == 200 + assert ( + mock_llm_engine.generate.call_args.kwargs[ + "prefill_eviction_callback_attempted" + ] + is True + ) + + def test_completion_keeps_list_preflight_attempts_prompt_local( + self, + client: TestClient, + mock_llm_engine: MockBaseEngine, + ) -> None: + mock_llm_engine.preflight_completion = AsyncMock( + side_effect=[True, None], + ) + mock_llm_engine.generate = AsyncMock( + side_effect=[ + MockGenerationOutput(text="First response."), + MockGenerationOutput(text="Second response."), + ] + ) + + response = client.post( + "/v1/completions", + json={ + "model": "test-model", + "prompt": ["First prompt", "Second prompt"], + }, + ) + + assert response.status_code == 200 + first_kwargs = mock_llm_engine.generate.call_args_list[0].kwargs + second_kwargs = mock_llm_engine.generate.call_args_list[1].kwargs + assert first_kwargs["prefill_eviction_callback_attempted"] is True + assert "prefill_eviction_callback_attempted" not in second_kwargs + + def test_completion_stream_forwards_preflight_eviction_attempt( + self, + client: TestClient, + mock_llm_engine: MockBaseEngine, + ) -> None: + captured: dict[str, object] = {} + mock_llm_engine.preflight_completion = AsyncMock(return_value=True) + + async def _recording_stream_generate( + prompt: str, + **kwargs: object, + ) -> AsyncIterator[MockGenerationOutput]: + captured.update(kwargs) + yield MockGenerationOutput( + text="Hi", + new_text="Hi", + finished=True, + finish_reason="stop", + ) + + mock_llm_engine.stream_generate = _recording_stream_generate + + response = client.post( + "/v1/completions", + json={ + "model": "test-model", + "prompt": "Stream prompt", + "stream": True, + }, + ) + + assert response.status_code == 200 + assert captured["prefill_eviction_callback_attempted"] is True + + def test_completion_stream_rejects_unsupported_turboquant_layout_before_start( + self, + client: TestClient, + mock_llm_engine: MockBaseEngine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from mlx_lm.models.cache import RotatingKVCache + + import omlx.scheduler as scheduler_mod + from omlx.scheduler import Scheduler, SchedulerConfig + + model = MagicMock() + model.layers = [] + model.config = SimpleNamespace( + model_type="unit", + num_hidden_layers=2, + num_attention_heads=2, + num_key_value_heads=2, + hidden_size=64, + head_dim=32, + ) + model.make_cache.return_value = [RotatingKVCache(max_size=128)] + tokenizer = MagicMock() + tokenizer.eos_token_id = 2 + scheduler = Scheduler( + model=model, + tokenizer=tokenizer, + config=SchedulerConfig( + max_num_seqs=1, + prefill_step_size=2048, + paged_cache_block_size=0, + ), + ) + assert scheduler._turboquant_preflight_conversion_eligible is False + + scheduler._prefill_memory_guard = True + scheduler._memory_hard_limit_bytes = 1 + scheduler._prefill_eviction_callback_configured = False + scheduler._turboquant_mid_prefill = True + scheduler._turboquant_kv_bits = 4.0 + scheduler._prefill_tq_kv_dtype_size = 1.0 + + def _zero_usage(*, refresh_mlx_active: bool = True) -> int: + del refresh_mlx_active + return 0 + + def _exclusive(_owner: object) -> bool: + return True + + monkeypatch.setattr(scheduler, "_current_usage_bytes", _zero_usage) + monkeypatch.setattr( + scheduler_mod._conversion_coordinator, + "process_exclusive", + _exclusive, + ) + + async def _preflight_completion( + prompt: str, + *, + request_id: str | None = None, + **kwargs: object, + ) -> bool | None: + del prompt, kwargs + scheduler.preflight_or_raise( + num_prompt_tokens=65536, + request_id=request_id, + ) + return None + + stream_started = False + + async def _stream_generate( + prompt: str, + **kwargs: object, + ) -> AsyncIterator[MockGenerationOutput]: + nonlocal stream_started + del prompt, kwargs + stream_started = True + yield MockGenerationOutput(text="must not stream") + + mock_llm_engine.preflight_completion = _preflight_completion + mock_llm_engine.stream_generate = _stream_generate + + response = client.post( + "/v1/completions", + json={ + "model": "test-model", + "prompt": "Rejected stream prompt", + "stream": True, + }, + headers={"x-request-id": "unsupported-layout"}, + ) + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/json") + body = response.json() + assert body["error"]["code"] == "prefill_memory_exceeded" + assert body["error"]["omlx_code"] == "prefill_memory_exceeded" + assert body["error"]["estimated_bytes"] > body["error"]["limit_bytes"] + assert stream_started is False + + def test_completion_preflight_failure_does_not_generate( + self, + client: TestClient, + mock_llm_engine: MockBaseEngine, + ) -> None: + from omlx.exceptions import PrefillMemoryExceededError + + mock_llm_engine.preflight_completion = AsyncMock( + side_effect=PrefillMemoryExceededError( + message="rejected after callback", + request_id="failed-completion", + ) + ) + mock_llm_engine.generate = AsyncMock() + + response = client.post( + "/v1/completions", + json={"model": "test-model", "prompt": "Rejected prompt"}, + headers={"x-request-id": "failed-completion"}, + ) + + assert response.status_code == 400 + mock_llm_engine.generate.assert_not_awaited() + def test_completion_includes_cached_tokens_on_cache_hit( self, client, mock_llm_engine ): diff --git a/tests/test_admin_model_settings_template.py b/tests/test_admin_model_settings_template.py index e7c7ee14e..4bfe9c877 100644 --- a/tests/test_admin_model_settings_template.py +++ b/tests/test_admin_model_settings_template.py @@ -1,5 +1,6 @@ """Regression tests for admin model-settings UI gates.""" +import json from pathlib import Path @@ -14,7 +15,7 @@ def _section(html: str, start_marker: str, end_marker: str) -> str: return html.split(start_marker, 1)[1].split(end_marker, 1)[0] -def test_lightning_mtp_and_turboquant_are_not_ui_mutexed(): +def test_lightning_mtp_and_turboquant_are_not_ui_mutexed() -> None: html = _model_settings_template() turboquant = _section( @@ -32,7 +33,7 @@ def test_lightning_mtp_and_turboquant_are_not_ui_mutexed(): assert "modelSettings.turboquant_kv_enabled" not in lightning_mtp -def test_vlm_mtp_still_conflicts_with_turboquant(): +def test_vlm_mtp_still_conflicts_with_turboquant() -> None: html = _model_settings_template() vlm_mtp = _section( html, @@ -43,7 +44,83 @@ def test_vlm_mtp_still_conflicts_with_turboquant(): assert "modelSettings.turboquant_kv_enabled" in vlm_mtp -def test_reasoning_effort_offers_max_after_high(): +def test_mid_prefill_toggle_is_nested_under_turboquant_parent() -> None: + html = _model_settings_template() + turboquant = _section( + html, + "", + "", + ) + + parent_gate = 'x-show="modelSettings.turboquant_kv_enabled"' + child_key = "modelSettings.turboquant_mid_prefill" + assert parent_gate in turboquant + assert child_key in turboquant + assert turboquant.index(parent_gate) < turboquant.index(child_key) + assert 'id="turboquant-mid-prefill-label"' in turboquant + assert 'id="turboquant-kv-label"' in turboquant + assert 'id="turboquant-kv-hint"' in turboquant + assert 'aria-labelledby="turboquant-kv-label"' in turboquant + assert 'aria-describedby="turboquant-kv-hint"' in turboquant + assert ( + ":aria-checked=\"modelSettings.turboquant_kv_enabled ? 'true' : 'false'\"" + in turboquant + ) + assert 'for="turboquant-kv-bits"' in turboquant + assert 'id="turboquant-kv-bits"' in turboquant + assert 'role="switch"' in turboquant + assert 'aria-labelledby="turboquant-mid-prefill-label"' in turboquant + assert 'id="turboquant-mid-prefill-hint"' in turboquant + assert 'aria-describedby="turboquant-mid-prefill-hint"' in turboquant + assert ( + ":aria-checked=\"modelSettings.turboquant_mid_prefill ? 'true' : 'false'\"" + in turboquant + ) + + +def test_mid_prefill_dashboard_state_payload_profile_and_reset_strings() -> None: + root = Path(__file__).resolve().parents[1] + js = (root / "omlx/admin/static/js/dashboard.js").read_text() + + assert "turboquant_mid_prefill: s.turboquant_mid_prefill || false" in js + assert "out.turboquant_mid_prefill = !!ms.turboquant_mid_prefill;" in js + assert "turboquant_mid_prefill: !!this.modelSettings.turboquant_mid_prefill" in js + assert "out.turboquant_mid_prefill = !!ms.turboquant_kv_enabled" not in js + assert "turboquant_mid_prefill: this.modelSettings.turboquant_kv_enabled" not in js + assert "this.modelSettings.turboquant_mid_prefill = false;" in js + assert "'turboquant_mid_prefill'," in js + assert "this.profileFields.model_specific" in js + + +def test_locales_include_mid_prefill_copy_and_parent_semantics() -> None: + root = Path(__file__).resolve().parents[1] + locale_paths = sorted((root / "omlx/admin/i18n").glob("*.json")) + assert len(locale_paths) == 9 + + for path in locale_paths: + strings = json.loads(path.read_text()) + assert strings["modal.model_settings.turboquant_kv_hint"] + assert strings["modal.model_settings.turboquant_mid_prefill"] + assert strings["modal.model_settings.turboquant_mid_prefill_hint"] + + english = json.loads((root / "omlx/admin/i18n/en.json").read_text()) + assert english["modal.model_settings.turboquant_kv_hint"] == ( + "Compress the KV cache with vector quantization after prefill for generation. " + "Ordinary TurboQuant does not convert during cold prefill. Lower bits use " + "less memory; higher bits preserve more quality." + ) + assert ( + english["modal.model_settings.turboquant_mid_prefill"] + == "Convert under prefill pressure" + ) + assert english["modal.model_settings.turboquant_mid_prefill_hint"] == ( + "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." + ) + + +def test_reasoning_effort_offers_max_after_high() -> None: html = _model_settings_template() high_option = '