diff --git a/PLAN.md b/PLAN.md index a1d5fa1..3a54e08 100644 --- a/PLAN.md +++ b/PLAN.md @@ -45,7 +45,7 @@ Exit criterion: restart the app and see the same project/session tree. ## Phase 1 — model library and on-demand loading -Status: **partially implemented**. Basic preferences, typed generation defaults, +Status: **partially implemented**. Typed generation and execution preferences and the complete background download workflow are implemented; the remaining DS4 runtime preferences, loading, inference, and idle unloading remain open. @@ -153,9 +153,9 @@ than inventing application-specific behavior. `--nothink`. Preserve DS4's explicit-vs-default distinction because GLM applies model-family sampling defaults only when the user has not overridden them. -- **Backend and execution:** backend (`--backend` and its aliases), CPU helper - threads, GPU power percentage, prefill chunk, exact/quality kernels, and warm - weights. Only offer backend/device choices supported by the current build. +- **Execution:** the macOS app uses Metal exclusively; omit the unnecessary CPU + backend and backend selector. Persist CPU helper threads for host-side work, + GPU power percentage, prefill chunk, exact/quality kernels, and warm weights. - **Speculative decoding:** MTP draft-token count and margin, GLM MTP, GLM MTP timing, DSpark enablement, DSpark confidence threshold, and DSpark strict target-only decode. Enabling a dependent control must enable or require its diff --git a/migrations/20260724200000_add_execution_preferences/down.sql b/migrations/20260724200000_add_execution_preferences/down.sql new file mode 100644 index 0000000..979e288 --- /dev/null +++ b/migrations/20260724200000_add_execution_preferences/down.sql @@ -0,0 +1,5 @@ +ALTER TABLE preferences DROP COLUMN warm_weights; +ALTER TABLE preferences DROP COLUMN quality; +ALTER TABLE preferences DROP COLUMN prefill_chunk; +ALTER TABLE preferences DROP COLUMN power_percent; +ALTER TABLE preferences DROP COLUMN cpu_threads; diff --git a/migrations/20260724200000_add_execution_preferences/up.sql b/migrations/20260724200000_add_execution_preferences/up.sql new file mode 100644 index 0000000..d9246ee --- /dev/null +++ b/migrations/20260724200000_add_execution_preferences/up.sql @@ -0,0 +1,10 @@ +ALTER TABLE preferences ADD COLUMN cpu_threads INTEGER + CHECK (cpu_threads IS NULL OR cpu_threads > 0); +ALTER TABLE preferences ADD COLUMN power_percent INTEGER + CHECK (power_percent IS NULL OR power_percent BETWEEN 1 AND 100); +ALTER TABLE preferences ADD COLUMN prefill_chunk INTEGER + CHECK (prefill_chunk IS NULL OR prefill_chunk > 0); +ALTER TABLE preferences ADD COLUMN quality BOOLEAN NOT NULL DEFAULT 0 + CHECK (quality IN (0, 1)); +ALTER TABLE preferences ADD COLUMN warm_weights BOOLEAN NOT NULL DEFAULT 0 + CHECK (warm_weights IN (0, 1)); diff --git a/src/app.rs b/src/app.rs index d1f872f..d7dcd54 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ pub(crate) use view::app_theme; use crate::database::{AppPreferences, Database, ProjectWithSessions}; use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice}; -use crate::settings::{GenerationPreferences, ReasoningMode}; +use crate::settings::{ExecutionPreferences, GenerationPreferences, ReasoningMode}; use iced::{Size, Subscription, Task, keyboard, window}; use rfd::AsyncFileDialog; use std::fs; @@ -30,6 +30,11 @@ struct PreferenceDraft { min_p: String, seed: String, reasoning_mode: ReasoningMode, + cpu_threads: String, + power_percent: String, + prefill_chunk: String, + quality: bool, + warm_weights: bool, } impl PreferenceDraft { @@ -37,6 +42,8 @@ impl PreferenceDraft { let model = ModelChoice::from_id(&preferences.selected_model) .ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?; let generation = preferences.generation()?; + let execution = preferences.execution()?; + execution.validate(model)?; Ok(Self { model, dspark_enabled: model.supports_dspark() && preferences.dspark_enabled, @@ -57,6 +64,17 @@ impl PreferenceDraft { .seed .map_or_else(String::new, |value| value.to_string()), reasoning_mode: generation.reasoning_mode, + cpu_threads: execution + .cpu_threads + .map_or_else(String::new, |value| value.to_string()), + power_percent: execution + .power_percent + .map_or_else(String::new, |value| value.to_string()), + prefill_chunk: execution + .prefill_chunk + .map_or_else(String::new, |value| value.to_string()), + quality: execution.quality, + warm_weights: execution.warm_weights, }) } @@ -80,6 +98,7 @@ impl PreferenceDraft { fn reset(&mut self) { let defaults = GenerationPreferences::default(); + let execution = ExecutionPreferences::default(); self.model = ModelChoice::default(); self.dspark_enabled = false; self.idle_timeout_minutes = "10".into(); @@ -91,6 +110,21 @@ impl PreferenceDraft { self.min_p.clear(); self.seed.clear(); self.reasoning_mode = defaults.reasoning_mode; + self.cpu_threads.clear(); + self.power_percent.clear(); + self.prefill_chunk.clear(); + self.quality = execution.quality; + self.warm_weights = execution.warm_weights; + } + + fn execution(&self) -> Result { + Ok(ExecutionPreferences { + cpu_threads: parse_optional_u32("CPU helper threads", &self.cpu_threads)?, + power_percent: parse_optional_u8("GPU power", &self.power_percent)?, + prefill_chunk: parse_optional_u32("Prefill chunk", &self.prefill_chunk)?, + quality: self.quality, + warm_weights: self.warm_weights, + }) } } @@ -127,6 +161,29 @@ fn parse_optional_u64(name: &str, value: &str) -> Result, String> { } } +fn parse_optional_u32(name: &str, value: &str) -> Result, String> { + parse_optional_number(name, value) +} + +fn parse_optional_u8(name: &str, value: &str) -> Result, String> { + parse_optional_number(name, value) +} + +fn parse_optional_number( + name: &str, + value: &str, +) -> Result, String> { + let value = value.trim(); + if value.is_empty() { + Ok(None) + } else { + value + .parse() + .map(Some) + .map_err(|_| format!("{name} must be a positive whole number or left blank.")) + } +} + pub(crate) struct App { main_window: window::Id, pub(super) model_manager_window: Option, @@ -196,6 +253,11 @@ pub(crate) enum Message { PreferenceMinPChanged(String), PreferenceSeedChanged(String), PreferenceReasoningChanged(ReasoningMode), + PreferenceCpuThreadsChanged(String), + PreferencePowerChanged(String), + PreferencePrefillChunkChanged(String), + PreferenceQualityChanged(bool), + PreferenceWarmWeightsChanged(bool), ResetPreferences, SavePreferences, DownloadArtifact(ManagedArtifactId), @@ -327,6 +389,10 @@ impl App { if !model.supports_dspark() { self.preference_draft.dspark_enabled = false; } + if model == ModelChoice::Glm52 { + self.preference_draft.power_percent.clear(); + self.preference_draft.prefill_chunk.clear(); + } self.preference_error = None; } Message::PreferenceDsparkChanged(enabled) => { @@ -370,6 +436,26 @@ impl App { self.preference_draft.reasoning_mode = value; self.preference_error = None; } + Message::PreferenceCpuThreadsChanged(value) => { + self.preference_draft.cpu_threads = value; + self.preference_error = None; + } + Message::PreferencePowerChanged(value) => { + self.preference_draft.power_percent = value; + self.preference_error = None; + } + Message::PreferencePrefillChunkChanged(value) => { + self.preference_draft.prefill_chunk = value; + self.preference_error = None; + } + Message::PreferenceQualityChanged(value) => { + self.preference_draft.quality = value; + self.preference_error = None; + } + Message::PreferenceWarmWeightsChanged(value) => { + self.preference_draft.warm_weights = value; + self.preference_error = None; + } Message::ResetPreferences => { self.preference_draft.reset(); self.preference_error = None; @@ -610,6 +696,17 @@ impl App { }; let model = self.preference_draft.model; + let execution = match self.preference_draft.execution() { + Ok(execution) => execution, + Err(error) => { + self.preference_error = Some(error); + return; + } + }; + if let Err(error) = execution.validate(model) { + self.preference_error = Some(error); + return; + } let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled; let Some(database) = &mut self.database else { return; @@ -619,6 +716,7 @@ impl App { dspark_enabled, idle_timeout_minutes, &generation, + &execution, ) { Ok(preferences) => { self.preferences = preferences; diff --git a/src/app/view.rs b/src/app/view.rs index 0758f15..217c3e9 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -305,6 +305,17 @@ impl App { .generation() .ok() .map(|generation| generation.effective(self.preference_draft.model)); + let execution = self + .preference_draft + .execution() + .ok() + .map(|execution| execution.engine_settings()); + let mut power = text_input("100", &self.preference_draft.power_percent); + let mut prefill = text_input("Automatic", &self.preference_draft.prefill_chunk); + if self.preference_draft.model != ModelChoice::Glm52 { + power = power.on_input(Message::PreferencePowerChanged); + prefill = prefill.on_input(Message::PreferencePrefillChunkChanged); + } let mut fields = column![ text("MODEL").size(11), @@ -322,6 +333,38 @@ impl App { }) .size(12), Space::with_height(8), + text("EXECUTION").size(11), + preference_input_row( + "CPU helper threads", + text_input("Automatic", &self.preference_draft.cpu_threads) + .on_input(Message::PreferenceCpuThreadsChanged), + ), + preference_input_row("GPU power percent", power), + preference_input_row("Prefill chunk", prefill), + checkbox("Prefer exact quality kernels", self.preference_draft.quality) + .on_toggle(Message::PreferenceQualityChanged), + checkbox("Warm mapped weights at load time", self.preference_draft.warm_weights) + .on_toggle(Message::PreferenceWarmWeightsChanged), + text(if self.preference_draft.model == ModelChoice::Glm52 { + "GLM 5.2 uses full GPU power and selects prefill chunks automatically." + } else { + "Blank numeric values preserve DS4's automatic engine behavior." + }) + .size(12), + text(execution.map_or_else( + || "Effective execution settings will appear after valid values are entered." + .to_owned(), + |settings| format!( + "Metal engine: threads {} • power {}% • prefill {} • quality {} • warm weights {}", + if settings.cpu_threads == 0 { "auto".to_owned() } else { settings.cpu_threads.to_string() }, + if settings.power_percent == 0 { 100 } else { settings.power_percent }, + if settings.prefill_chunk == 0 { "auto".to_owned() } else { settings.prefill_chunk.to_string() }, + if settings.quality { "on" } else { "off" }, + if settings.warm_weights { "on" } else { "off" }, + ), + )) + .size(12), + Space::with_height(8), text("CAPACITY").size(11), preference_input_row( "Context tokens", @@ -424,12 +467,19 @@ impl App { ] .spacing(8); - let panel = container(column![header, scrollable(fields), footer].spacing(16)) - .padding(24) - .width(600) - .height(Length::Fill) - .max_height(660) - .style(overview_style); + let panel = container( + column![ + header, + scrollable(container(fields).padding(iced::Padding::ZERO.right(18))), + footer + ] + .spacing(16), + ) + .padding(24) + .width(700) + .height(Length::Fill) + .max_height(660) + .style(overview_style); opaque( container(panel) .padding(24) diff --git a/src/database.rs b/src/database.rs index f2a2962..4d5ead9 100644 --- a/src/database.rs +++ b/src/database.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::Path; use crate::schema::{preferences, projects, sessions}; -use crate::settings::{GenerationPreferences, ReasoningMode}; +use crate::settings::{ExecutionPreferences, GenerationPreferences, ReasoningMode}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); @@ -25,6 +25,11 @@ pub struct AppPreferences { pub min_p: Option, pub seed: Option, pub reasoning_mode: String, + pub cpu_threads: Option, + pub power_percent: Option, + pub prefill_chunk: Option, + pub quality: bool, + pub warm_weights: bool, } impl Default for AppPreferences { @@ -42,6 +47,11 @@ impl Default for AppPreferences { min_p: None, seed: None, reasoning_mode: "high".into(), + cpu_threads: None, + power_percent: None, + prefill_chunk: None, + quality: false, + warm_weights: false, } } } @@ -67,6 +77,28 @@ impl AppPreferences { preferences.validate()?; Ok(preferences) } + + pub(crate) fn execution(&self) -> Result { + Ok(ExecutionPreferences { + cpu_threads: self + .cpu_threads + .map(u32::try_from) + .transpose() + .map_err(|_| "Saved CPU helper threads is invalid.".to_owned())?, + power_percent: self + .power_percent + .map(u8::try_from) + .transpose() + .map_err(|_| "Saved GPU power is invalid.".to_owned())?, + prefill_chunk: self + .prefill_chunk + .map(u32::try_from) + .transpose() + .map_err(|_| "Saved prefill chunk is invalid.".to_owned())?, + quality: self.quality, + warm_weights: self.warm_weights, + }) + } } #[derive(AsChangeset)] @@ -83,6 +115,11 @@ struct PreferenceChanges<'a> { min_p: Option, seed: Option<&'a str>, reasoning_mode: &'a str, + cpu_threads: Option, + power_percent: Option, + prefill_chunk: Option, + quality: bool, + warm_weights: bool, } #[derive(Clone, Debug, Identifiable, Queryable, Selectable)] @@ -186,8 +223,12 @@ impl Database { dspark_enabled: bool, idle_timeout_minutes: i32, generation: &GenerationPreferences, + execution: &ExecutionPreferences, ) -> Result { generation.validate()?; + let model = crate::model::ModelChoice::from_id(selected_model) + .ok_or_else(|| format!("Unsupported model: {selected_model}"))?; + execution.validate(model)?; let seed = generation.seed.map(|seed| seed.to_string()); diesel::update(preferences::table.find(1)) .set(PreferenceChanges { @@ -202,6 +243,11 @@ impl Database { min_p: generation.min_p, seed: seed.as_deref(), reasoning_mode: generation.reasoning_mode.id(), + cpu_threads: execution.cpu_threads.map(|value| value as i32), + power_percent: execution.power_percent.map(i32::from), + prefill_chunk: execution.prefill_chunk.map(|value| value as i32), + quality: execution.quality, + warm_weights: execution.warm_weights, }) .returning(AppPreferences::as_returning()) .get_result(&mut self.connection) @@ -262,14 +308,15 @@ mod tests { assert!(!preferences.dspark_enabled); assert_eq!(preferences.idle_timeout_minutes, 10); let generation = GenerationPreferences::default(); + let execution = ExecutionPreferences::default(); assert!( database - .update_preferences("glm-5.2", true, 30, &generation) + .update_preferences("glm-5.2", true, 30, &generation, &execution) .is_err() ); assert!( database - .update_preferences("deepseek-v4-flash", false, 0, &generation) + .update_preferences("deepseek-v4-flash", false, 0, &generation, &execution) .is_err() ); let generation = GenerationPreferences { @@ -278,8 +325,13 @@ mod tests { reasoning_mode: ReasoningMode::Max, ..generation }; + let execution = ExecutionPreferences { + cpu_threads: Some(8), + quality: true, + ..execution + }; database - .update_preferences("glm-5.2", false, 30, &generation) + .update_preferences("glm-5.2", false, 30, &generation, &execution) .unwrap(); let project = database.create_project("DS4", "/tmp/ds4").unwrap(); @@ -302,6 +354,7 @@ mod tests { assert_eq!(preferences.selected_model, "glm-5.2"); assert_eq!(preferences.idle_timeout_minutes, 30); assert_eq!(preferences.generation().unwrap(), generation); + assert_eq!(preferences.execution().unwrap(), execution); drop(reopened); fs::remove_file(path).unwrap(); } diff --git a/src/schema.rs b/src/schema.rs index b615cf7..ab161ef 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -20,6 +20,11 @@ diesel::table! { min_p -> Nullable, seed -> Nullable, reasoning_mode -> Text, + cpu_threads -> Nullable, + power_percent -> Nullable, + prefill_chunk -> Nullable, + quality -> Bool, + warm_weights -> Bool, } } diff --git a/src/settings.rs b/src/settings.rs index 4765ed9..c6c43cc 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -6,8 +6,74 @@ pub(crate) const REASONING_MODES: [ReasoningMode; 3] = [ ReasoningMode::Max, ReasoningMode::Direct, ]; - const THINK_MAX_MIN_CONTEXT: i32 = 393_216; +const MAX_CPU_THREADS: u32 = 32; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ExecutionPreferences { + pub(crate) cpu_threads: Option, + pub(crate) power_percent: Option, + pub(crate) prefill_chunk: Option, + pub(crate) quality: bool, + pub(crate) warm_weights: bool, +} + +impl ExecutionPreferences { + pub(crate) fn validate(&self, model: ModelChoice) -> Result<(), String> { + if self.cpu_threads == Some(0) { + return Err("CPU helper threads must be a positive whole number.".into()); + } + if self + .cpu_threads + .is_some_and(|threads| threads > i32::MAX as u32) + { + return Err("CPU helper threads is too large.".into()); + } + if self + .power_percent + .is_some_and(|power| !(1..=100).contains(&power)) + { + return Err("GPU power must be between 1 and 100 percent.".into()); + } + if self.prefill_chunk == Some(0) { + return Err("Prefill chunk must be a positive whole number.".into()); + } + if self + .prefill_chunk + .is_some_and(|chunk| chunk > i32::MAX as u32) + { + return Err("Prefill chunk is too large.".into()); + } + if model == ModelChoice::Glm52 { + if self.power_percent.is_some_and(|power| power != 100) { + return Err("GLM 5.2 currently requires 100% GPU power.".into()); + } + if self.prefill_chunk.is_some() { + return Err("GLM 5.2 selects its prefill chunk automatically.".into()); + } + } + Ok(()) + } + + pub(crate) fn engine_settings(&self) -> EngineExecutionSettings { + EngineExecutionSettings { + cpu_threads: self.cpu_threads.unwrap_or(0).min(MAX_CPU_THREADS), + power_percent: self.power_percent.unwrap_or(0), + prefill_chunk: self.prefill_chunk.unwrap_or(0), + quality: self.quality, + warm_weights: self.warm_weights, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct EngineExecutionSettings { + pub(crate) cpu_threads: u32, + pub(crate) power_percent: u8, + pub(crate) prefill_chunk: u32, + pub(crate) quality: bool, + pub(crate) warm_weights: bool, +} #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum ReasoningMode { @@ -156,4 +222,27 @@ mod tests { assert_eq!((effective.top_p, effective.min_p), (0.4, 0.2)); assert_eq!(effective.reasoning_mode, ReasoningMode::High); } + + #[test] + fn execution_settings_keep_ds4_automatic_values_and_glm_rules() { + let defaults = ExecutionPreferences::default().engine_settings(); + assert_eq!( + ( + defaults.cpu_threads, + defaults.power_percent, + defaults.prefill_chunk + ), + (0, 0, 0) + ); + + let tuned = ExecutionPreferences { + cpu_threads: Some(100), + power_percent: Some(50), + prefill_chunk: Some(4096), + ..ExecutionPreferences::default() + }; + assert!(tuned.validate(ModelChoice::DeepSeekV4Flash).is_ok()); + assert!(tuned.validate(ModelChoice::Glm52).is_err()); + assert_eq!(tuned.engine_settings().cpu_threads, MAX_CPU_THREADS); + } }