Add Metal execution preferences

This commit is contained in:
Georg Bauer
2026-07-24 15:37:13 +02:00
parent bccce697af
commit ac464317d4
8 changed files with 326 additions and 16 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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));

View File

@@ -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<ExecutionPreferences, String> {
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<Option<u64>, String> {
}
}
fn parse_optional_u32(name: &str, value: &str) -> Result<Option<u32>, String> {
parse_optional_number(name, value)
}
fn parse_optional_u8(name: &str, value: &str) -> Result<Option<u8>, String> {
parse_optional_number(name, value)
}
fn parse_optional_number<T: std::str::FromStr>(
name: &str,
value: &str,
) -> Result<Option<T>, 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<window::Id>,
@@ -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;

View File

@@ -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)

View File

@@ -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<f32>,
pub seed: Option<String>,
pub reasoning_mode: String,
pub cpu_threads: Option<i32>,
pub power_percent: Option<i32>,
pub prefill_chunk: Option<i32>,
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<ExecutionPreferences, String> {
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<f32>,
seed: Option<&'a str>,
reasoning_mode: &'a str,
cpu_threads: Option<i32>,
power_percent: Option<i32>,
prefill_chunk: Option<i32>,
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<AppPreferences, String> {
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();
}

View File

@@ -20,6 +20,11 @@ diesel::table! {
min_p -> Nullable<Float>,
seed -> Nullable<Text>,
reasoning_mode -> Text,
cpu_threads -> Nullable<Integer>,
power_percent -> Nullable<Integer>,
prefill_chunk -> Nullable<Integer>,
quality -> Bool,
warm_weights -> Bool,
}
}

View File

@@ -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<u32>,
pub(crate) power_percent: Option<u8>,
pub(crate) prefill_chunk: Option<u32>,
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);
}
}