From 98cb9f7f3ea24d30f3a61fe16492eb0bda5655b9 Mon Sep 17 00:00:00 2001 From: stefiosif Date: Sun, 1 Feb 2026 22:53:04 +0200 Subject: [PATCH] Parallelize DFS, use an AtomicBool for short-circuiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit time: [73.407 µs 76.145 µs 79.345 µs] change: [−40.126% −24.679% −3.8062%] (p = 0.04 < 0.05) --- justfile | 3 + src-tauri/Cargo.lock | 237 ++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 16 +++ src-tauri/benches/rayon.rs | 53 +++++++++ src-tauri/src/scheduler.rs | 61 +++++++++- src-tauri/src/slot.rs | 10 ++ 6 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 src-tauri/benches/rayon.rs diff --git a/justfile b/justfile index 49b1f62..001e3c5 100644 --- a/justfile +++ b/justfile @@ -22,6 +22,9 @@ test-integration: test-all: cd {{tauri_path}} && cargo test --release -- --nocapture +bench: + cd {{tauri_path}} && cargo bench + # profile: # cd {{tauri_path}} && cargo flamegraph diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5566dae..6cbac3b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -43,6 +43,15 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "android_log-sys" version = "0.3.2" @@ -69,6 +78,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + [[package]] name = "anyhow" version = "1.0.100" @@ -489,6 +510,12 @@ dependencies = [ "toml 0.9.8", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.49" @@ -552,6 +579,58 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "color_quant" version = "1.1.0" @@ -651,6 +730,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -660,12 +774,37 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1523,6 +1662,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1883,6 +2033,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2509,6 +2668,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.3.3" @@ -2537,6 +2702,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pango" version = "0.18.3" @@ -2779,6 +2954,34 @@ dependencies = [ "time", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -3070,6 +3273,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3224,10 +3447,12 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "criterion", "docx-rs", - "itertools", + "itertools 0.14.0", "log", "rand 0.9.2", + "rayon", "rstest", "serde", "serde_json", @@ -4272,6 +4497,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8719cb3..fc95a39 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,3 +30,19 @@ log = "0.4.29" rand = "0.9.2" docx-rs = "0.4.18" anyhow = "1.0.100" +rayon = "1.11" + +[dev-dependencies] +criterion = { version = "0.8.1", features = ["html_reports"] } + +[[bench]] +name = "rayon" +harness = false + +[lints.clippy] +indexing_slicing = "deny" +fallible_impl_from = "deny" +wildcard_enum_match_arm = "deny" +unneeded_field_pattern = "deny" +fn_params_excessive_bools = "deny" +# must_use_candidate = "deny" diff --git a/src-tauri/benches/rayon.rs b/src-tauri/benches/rayon.rs new file mode 100644 index 0000000..e5c1c45 --- /dev/null +++ b/src-tauri/benches/rayon.rs @@ -0,0 +1,53 @@ +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; +use rota_lib::{ + config::{ToxicPair, UserConfig}, + resident::Resident, + schedule::{MonthlySchedule, ShiftType}, + scheduler::Scheduler, + slot::Day, + workload::WorkloadTracker, +}; + +fn criterion_benchmark(c: &mut Criterion) { + let config = maximal_config(); + let scheduler = Scheduler::new_with_config(config); + + c.bench_function("scheduler run", |b| { + b.iter(|| { + let mut schedule = MonthlySchedule::new(); + let mut tracker = WorkloadTracker::default(); + let result = scheduler.run(&mut schedule, &mut tracker); + black_box(result) + }) + }); +} + +fn maximal_config() -> UserConfig { + UserConfig::default() + .with_holidays(vec![2, 3, 10, 11, 12, 25]) + .with_residents(vec![ + Resident::new(1, "R1").with_max_shifts(3), + Resident::new(2, "R2").with_max_shifts(4), + Resident::new(3, "R3").with_reduced_load(), + Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]), + Resident::new(5, "R5") + .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), + Resident::new(6, "R6").with_negative_shifts(vec![Day(5), Day(15), Day(25)]), + Resident::new(7, "R7") + .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), + Resident::new(8, "R8"), + Resident::new(9, "R9"), + Resident::new(10, "R10").with_reduced_load(), + ]) + .with_toxic_pairs(vec![ + ToxicPair::new(1, 2), + ToxicPair::new(3, 4), + ToxicPair::new(3, 5), + ToxicPair::new(7, 8), + ]) +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index 984cf66..b07f3de 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -1,3 +1,5 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + use crate::{ config::UserConfig, resident::ResidentId, @@ -7,7 +9,10 @@ use crate::{ workload::{WorkloadBounds, WorkloadTracker}, }; +use anyhow::bail; +use log::warn; use rand::Rng; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; pub struct Scheduler { pub config: UserConfig, @@ -46,7 +51,46 @@ impl Scheduler { //TODO: add validation - self.search(schedule, tracker, Slot::default()) + // find first non-manually-filled slot + let slot = (0..=self.config.total_slots) + .find(|&slot_idx| !schedule.0.contains_key(&Slot::from(slot_idx))) + .map(Slot::from) + .ok_or_else(|| anyhow::anyhow!("Schedule is already full"))?; + + let resident_ids = self.valid_residents(slot, schedule); + let solved_in_thread = AtomicBool::new(false); + + let solved_schedule = resident_ids.par_iter().find_map_any(|&id| { + let mut local_schedule = schedule.clone(); + let mut local_tracker = tracker.clone(); + + local_schedule.insert(slot, id); + local_tracker.insert(id, &self.config, slot); + + let solved = self.search( + &mut local_schedule, + &mut local_tracker, + slot.next(), + &solved_in_thread, + ); + match solved { + Ok(true) => Some(local_schedule), + Ok(false) => None, + Err(e) => { + warn!("Search error: {}", e); + None + } + } + }); + + // TODO: can return the schedule instead of a bool + + if let Some(solved_schedule) = solved_schedule { + *schedule = solved_schedule; + return Ok(true); + } + + Ok(false) } /// DFS where maximum depth is calculated by total_days_of_month + odd_days_of_month each node is called a slot @@ -57,7 +101,12 @@ impl Scheduler { schedule: &mut MonthlySchedule, tracker: &mut WorkloadTracker, slot: Slot, + solved_in_thread: &AtomicBool, ) -> anyhow::Result { + if solved_in_thread.load(Ordering::Relaxed) { + bail!("Another thread found the solution") + } + if self.timer.limit_exceeded() { anyhow::bail!("Time exceeded. Restrictions too tight"); } @@ -69,11 +118,15 @@ impl Scheduler { } if slot.greater_than(self.config.total_days) { - return Ok(tracker.are_all_thresholds_met(&self.config, &self.bounds)); + if tracker.are_all_thresholds_met(&self.config, &self.bounds) { + solved_in_thread.store(true, Ordering::Relaxed); + return Ok(true); + } + return Ok(false); } if schedule.is_slot_manually_assigned(&slot) { - return self.search(schedule, tracker, slot.next()); + return self.search(schedule, tracker, slot.next(), solved_in_thread); } // sort candidates by current workload, add rng for tie breakers @@ -88,7 +141,7 @@ impl Scheduler { schedule.insert(slot, id); tracker.insert(id, &self.config, slot); - if self.search(schedule, tracker, slot.next())? { + if self.search(schedule, tracker, slot.next(), solved_in_thread)? { return Ok(true); } diff --git a/src-tauri/src/slot.rs b/src-tauri/src/slot.rs index 96cffe9..1869452 100644 --- a/src-tauri/src/slot.rs +++ b/src-tauri/src/slot.rs @@ -113,6 +113,16 @@ impl Default for Slot { } } +impl From for Slot { + fn from(value: u8) -> Self { + let mut slot = Slot::default(); + for _ in 0..value { + slot = slot.next() + } + slot + } +} + #[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)] #[serde(rename_all = "camelCase")] pub struct Day(pub u8);