Skip to main content

celox_runtime/
parallel.rs

1//! Concurrent execution of lane-partitioned kernels.
2//!
3//! A kernel is a list of tasks. Every task belongs to one lane, and each lane
4//! runs its tasks in list order on one thread: lane 0 on the calling thread,
5//! the others on persistent worker threads. Before a task starts, its waits
6//! require other lanes to have completed a number of their tasks in the
7//! current execution. Completion counts are published with release stores and
8//! observed with acquire loads, so a consumer sees every state write of the
9//! producer tasks it waited for.
10//!
11//! Idle workers spin briefly for the next kernel and then park; dispatch
12//! unparks only workers that announced they were going to sleep.
13
14use std::cell::UnsafeCell;
15use std::panic::{AssertUnwindSafe, catch_unwind};
16use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18use std::thread::JoinHandle;
19use std::time::{Duration, Instant};
20
21use serde::{Deserialize, Serialize};
22
23/// Process-local identity for diagnostic objects. Never derived from addresses
24/// or topology, and never persisted in native images.
25pub fn new_diagnostic_id() -> String {
26    static NEXT: AtomicU64 = AtomicU64::new(1);
27    let id = NEXT
28        .try_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
29        .expect("diagnostic identity exhausted");
30    id.to_string()
31}
32
33#[derive(Default)]
34struct TaskTiming {
35    waits: Vec<(u32, u32, u64, u64)>,
36    start: u64,
37    end: u64,
38    code: Option<i64>,
39}
40
41struct ExecutionTrace {
42    start: Instant,
43    // Each cell has one writer. Read only after all workers have finished.
44    tasks: Vec<Mutex<TaskTiming>>,
45}
46
47impl ExecutionTrace {
48    fn nanos(&self) -> u64 {
49        self.start.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64
50    }
51}
52
53/// One task of a lane-partitioned kernel.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct LaneTaskSpec {
56    /// Lane whose thread executes the task.
57    pub lane: u32,
58    /// `(lane, completed task count)` pairs that must hold before the task
59    /// starts.
60    pub waits: Vec<(u32, u32)>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
64pub enum LaneScheduleError {
65    #[error("task {task} names lane {lane}, but the schedule has {lanes} lanes")]
66    Lane { task: usize, lane: u32, lanes: u32 },
67    #[error("task {task} has an invalid wait on lane {lane} for {completed} tasks")]
68    Wait {
69        task: usize,
70        lane: u32,
71        completed: u32,
72    },
73}
74
75/// Validated execution order of a lane-partitioned kernel.
76#[derive(Debug, Clone)]
77pub struct LaneSchedule {
78    lanes: u32,
79    tasks: Vec<LaneTaskSpec>,
80    /// Global task indices of every lane, in execution order.
81    lane_tasks: Vec<Vec<usize>>,
82    diagnostic_id: String,
83}
84
85impl PartialEq for LaneSchedule {
86    fn eq(&self, other: &Self) -> bool {
87        self.lanes == other.lanes
88            && self.tasks == other.tasks
89            && self.lane_tasks == other.lane_tasks
90    }
91}
92impl Eq for LaneSchedule {}
93
94impl LaneSchedule {
95    /// Validate `tasks`, listed in a valid sequential order.
96    ///
97    /// Every wait must refer to tasks of another lane that are listed before
98    /// the waiting task, which makes concurrent execution deadlock free.
99    pub fn new(lanes: u32, tasks: Vec<LaneTaskSpec>) -> Result<Self, LaneScheduleError> {
100        let lanes = lanes.max(1);
101        let mut lane_tasks = vec![Vec::new(); lanes as usize];
102        for (task, spec) in tasks.iter().enumerate() {
103            if spec.lane >= lanes {
104                return Err(LaneScheduleError::Lane {
105                    task,
106                    lane: spec.lane,
107                    lanes,
108                });
109            }
110            for &(lane, completed) in &spec.waits {
111                let listed_before = lane_tasks
112                    .get(lane as usize)
113                    .is_some_and(|listed: &Vec<usize>| completed as usize <= listed.len());
114                if lane == spec.lane || completed == 0 || !listed_before {
115                    return Err(LaneScheduleError::Wait {
116                        task,
117                        lane,
118                        completed,
119                    });
120                }
121            }
122            lane_tasks[spec.lane as usize].push(task);
123        }
124        Ok(Self {
125            lanes,
126            tasks,
127            lane_tasks,
128            diagnostic_id: new_diagnostic_id(),
129        })
130    }
131
132    /// Bind this runtime schedule to its compiler diagnostic identity.
133    pub fn with_diagnostic_id(mut self, name: String) -> Self {
134        self.diagnostic_id = name;
135        self
136    }
137
138    pub fn lanes(&self) -> u32 {
139        self.lanes
140    }
141
142    pub fn tasks(&self) -> &[LaneTaskSpec] {
143        &self.tasks
144    }
145
146    /// Run every task on the calling thread in list order.
147    pub fn run_sequential(&self, runner: &dyn LaneTaskRunner) -> Result<(), LaneTaskFailure> {
148        for task in 0..self.tasks.len() {
149            let code = runner.run_task(task);
150            if code != 0 {
151                return Err(LaneTaskFailure { task, code });
152            }
153        }
154        Ok(())
155    }
156}
157
158/// Executes the tasks of one kernel.
159///
160/// `run_task` is called concurrently for tasks of different lanes, which the
161/// schedule guarantees to have no conflicting state accesses.
162pub trait LaneTaskRunner: Sync {
163    /// Run one task and return its generated-code status (zero on success).
164    fn run_task(&self, task: usize) -> i64;
165}
166
167/// A kernel failure. Non-fatal failures take precedence over combinational
168/// fatal captures; within either class the lowest task index wins.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub struct LaneTaskFailure {
171    pub task: usize,
172    pub code: i64,
173}
174
175/// Status reported for a task whose runner panicked.
176pub const LANE_TASK_PANICKED: i64 = i64::MIN;
177
178// repr requires a literal; keep the common layout boundary checked below.
179#[repr(align(256))]
180struct Padded<T>(T);
181const _: () = assert!(std::mem::align_of::<Padded<u8>>() == celox_state_layout::LANE_SEGMENT_ALIGN);
182
183#[derive(Clone, Copy)]
184struct Job {
185    schedule: *const LaneSchedule,
186    runner: *const (dyn LaneTaskRunner + 'static),
187    trace: *const ExecutionTrace,
188}
189
190/// Progress value of a lane that has finished every task of its epoch.
191const LANE_DONE: u64 = u32::MAX as u64;
192
193/// Largest epoch before the counters restart.
194const MAX_EPOCH: u64 = u32::MAX as u64;
195
196struct Shared {
197    lanes: usize,
198    job_epoch: Padded<AtomicU64>,
199    /// Written by the dispatching thread before `job_epoch` is published and
200    /// read by workers only after they acquire that epoch.
201    job: UnsafeCell<Option<Job>>,
202    /// Per lane: `epoch << 32 | completed tasks`, or `epoch << 32 |
203    /// LANE_DONE` once the lane has finished the epoch. Each lane writes only
204    /// its own counter, so completion never contends on a shared line.
205    progress: Box<[Padded<AtomicU64>]>,
206    abort: Padded<AtomicBool>,
207    failure: Mutex<Option<LaneTaskFailure>>,
208    sleeping: Box<[Padded<AtomicBool>]>,
209    shutdown: AtomicBool,
210    idle_spin: Duration,
211}
212
213// SAFETY: `job` is written only by the dispatching thread while every worker
214// is idle (between the previous epoch's completion and the next epoch's
215// release store) and read only after acquiring the new epoch. The pointers it
216// holds stay valid until the dispatching call observes every worker finished.
217unsafe impl Sync for Shared {}
218unsafe impl Send for Shared {}
219
220impl Shared {
221    fn record_failure(&self, failure: LaneTaskFailure) {
222        let mut slot = self
223            .failure
224            .lock()
225            .unwrap_or_else(|poisoned| poisoned.into_inner());
226        if slot.is_none_or(|current| {
227            let priority = |failure: LaneTaskFailure| {
228                (crate::comb_fatal_site(failure.code).is_some(), failure.task)
229            };
230            priority(failure) < priority(current)
231        }) {
232            *slot = Some(failure);
233        }
234        self.abort.0.store(true, Ordering::Release);
235    }
236
237    fn execute_lane(&self, lane: usize, epoch: u64, job: Job) {
238        // SAFETY: the dispatching thread keeps both referents alive until
239        // every lane of this epoch has finished.
240        let (schedule, runner) = unsafe { (&*job.schedule, &*job.runner) };
241        // SAFETY: the optional trace is owned by run until every lane finishes.
242        let trace = unsafe { job.trace.as_ref() };
243        let base = epoch << 32;
244        let Some(tasks) = schedule.lane_tasks.get(lane) else {
245            return;
246        };
247        for (sequence, &task) in tasks.iter().enumerate() {
248            let mut timing = trace.map(|_| TaskTiming::default());
249            let mut aborted = self.abort.0.load(Ordering::Acquire);
250            for &(other, completed) in &schedule.tasks[task].waits {
251                if aborted {
252                    break;
253                }
254                let target = base | u64::from(completed);
255                let progress = &self.progress[other as usize].0;
256                let mut spins = 0u32;
257                let wait_start = trace.map(ExecutionTrace::nanos);
258                while progress.load(Ordering::Acquire) < target {
259                    if self.abort.0.load(Ordering::Acquire) {
260                        aborted = true;
261                        break;
262                    }
263                    wait_pause(&mut spins);
264                }
265                if let (Some(trace), Some(timing), Some(start)) = (trace, &mut timing, wait_start) {
266                    timing.waits.push((other, completed, start, trace.nanos()));
267                }
268            }
269            if !aborted {
270                if let (Some(trace), Some(timing)) = (trace, &mut timing) {
271                    timing.start = trace.nanos();
272                }
273                let code = catch_unwind(AssertUnwindSafe(|| runner.run_task(task)))
274                    .unwrap_or(LANE_TASK_PANICKED);
275                if let (Some(trace), Some(timing)) = (trace, &mut timing) {
276                    timing.end = trace.nanos();
277                    timing.code = Some(code);
278                }
279                if code != 0 {
280                    self.record_failure(LaneTaskFailure { task, code });
281                }
282            }
283            if let (Some(trace), Some(timing)) = (trace, timing) {
284                *trace.tasks[task].lock().unwrap_or_else(|e| e.into_inner()) = timing;
285            }
286            self.progress[lane]
287                .0
288                .store(base | (sequence as u64 + 1), Ordering::Release);
289        }
290    }
291}
292
293/// Back off inside a dependency wait: spin first, then yield so an
294/// oversubscribed host still makes progress.
295fn wait_pause(spins: &mut u32) {
296    *spins = spins.wrapping_add(1);
297    if *spins < 2048 {
298        std::hint::spin_loop();
299    } else {
300        std::thread::yield_now();
301    }
302}
303
304/// Persistent worker threads for one lane-partitioned program.
305pub struct LanePool {
306    shared: Arc<Shared>,
307    workers: Vec<JoinHandle<()>>,
308    epoch: u64,
309    diagnostic_id: String,
310}
311
312impl std::fmt::Debug for LanePool {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        f.debug_struct("LanePool")
315            .field("lanes", &self.shared.lanes)
316            .finish_non_exhaustive()
317    }
318}
319
320impl LanePool {
321    /// Default time an idle worker keeps polling for the next kernel before
322    /// it parks.
323    pub const DEFAULT_IDLE_SPIN: Duration = Duration::from_micros(200);
324
325    /// Start `lanes - 1` worker threads; lane 0 runs on the dispatching
326    /// thread.
327    pub fn new(lanes: u32) -> std::io::Result<Self> {
328        Self::with_idle_spin(lanes, Self::DEFAULT_IDLE_SPIN)
329    }
330
331    pub fn with_idle_spin(lanes: u32, idle_spin: Duration) -> std::io::Result<Self> {
332        let lanes = lanes.max(1) as usize;
333        let shared = Arc::new(Shared {
334            lanes,
335            job_epoch: Padded(AtomicU64::new(0)),
336            job: UnsafeCell::new(None),
337            progress: (0..lanes).map(|_| Padded(AtomicU64::new(0))).collect(),
338            abort: Padded(AtomicBool::new(false)),
339            failure: Mutex::new(None),
340            sleeping: (0..lanes).map(|_| Padded(AtomicBool::new(false))).collect(),
341            shutdown: AtomicBool::new(false),
342            idle_spin,
343        });
344        let mut workers = Vec::with_capacity(lanes - 1);
345        for lane in 1..lanes {
346            let worker_shared = Arc::clone(&shared);
347            let spawned = std::thread::Builder::new()
348                .name(format!("celox-lane-{lane}"))
349                .spawn(move || worker(&worker_shared, lane));
350            match spawned {
351                Ok(handle) => workers.push(handle),
352                Err(error) => {
353                    let pool = Self {
354                        shared,
355                        workers,
356                        epoch: 0,
357                        diagnostic_id: new_diagnostic_id(),
358                    };
359                    drop(pool);
360                    return Err(error);
361                }
362            }
363        }
364        Ok(Self {
365            shared,
366            workers,
367            epoch: 0,
368            diagnostic_id: new_diagnostic_id(),
369        })
370    }
371
372    pub fn lanes(&self) -> u32 {
373        self.shared.lanes as u32
374    }
375
376    /// Execute one kernel and return its failure, preferring non-fatal errors
377    /// to captured assertions before comparing task indices.
378    ///
379    /// After a failure, tasks that have not started are skipped; every lane
380    /// still finishes before this call returns.
381    pub fn run(
382        &mut self,
383        schedule: &LaneSchedule,
384        runner: &dyn LaneTaskRunner,
385    ) -> Result<(), LaneTaskFailure> {
386        if schedule.lanes as usize > self.shared.lanes {
387            tracing::trace!(target: "celox::parallel", event = "pool_fallback", reason = "insufficient_workers", requested = schedule.lanes, available = self.shared.lanes);
388            return schedule.run_sequential(runner);
389        }
390        let trace =
391            tracing::enabled!(target: "celox::parallel", tracing::Level::TRACE).then(|| {
392                ExecutionTrace {
393                    start: Instant::now(),
394                    tasks: (0..schedule.tasks.len())
395                        .map(|_| Mutex::new(TaskTiming::default()))
396                        .collect(),
397                }
398            });
399        let shared = &*self.shared;
400        // Progress words hold `epoch << 32 | count`, so the epoch must fit 32
401        // bits. Every lane is idle between runs; clearing the words before
402        // wrapping keeps no stale value above the new epoch's targets.
403        if self.epoch >= MAX_EPOCH {
404            for progress in shared.progress.iter() {
405                progress.0.store(0, Ordering::Relaxed);
406            }
407            self.epoch = 0;
408        }
409        self.epoch += 1;
410        let epoch = self.epoch;
411        // SAFETY: lifetime erasure only; the referents outlive this call,
412        // which waits for every worker below before returning.
413        let runner: &'static dyn LaneTaskRunner = unsafe {
414            std::mem::transmute::<&dyn LaneTaskRunner, &'static dyn LaneTaskRunner>(runner)
415        };
416        let job = Job {
417            schedule: schedule as *const LaneSchedule,
418            runner: runner as *const dyn LaneTaskRunner,
419            trace: trace
420                .as_ref()
421                .map_or(std::ptr::null(), |trace| trace as *const ExecutionTrace),
422        };
423        // SAFETY: every worker finished the previous epoch (see the wait at
424        // the end of the previous call) and reads `job` only after acquiring
425        // the epoch published below.
426        unsafe {
427            *shared.job.get() = Some(job);
428        }
429        shared.abort.0.store(false, Ordering::Relaxed);
430        shared.job_epoch.0.store(epoch, Ordering::SeqCst);
431        for (index, worker) in self.workers.iter().enumerate() {
432            // Reading first keeps the flag's line shared while the worker
433            // spins; only a sleeping worker needs the exchange.
434            let sleeping = &shared.sleeping[index + 1].0;
435            if sleeping.load(Ordering::SeqCst) && sleeping.swap(false, Ordering::SeqCst) {
436                worker.thread().unpark();
437            }
438        }
439
440        shared.execute_lane(0, epoch, job);
441
442        let done = (epoch << 32) | LANE_DONE;
443        for lane in 1..shared.lanes {
444            let mut spins = 0u32;
445            while shared.progress[lane].0.load(Ordering::Acquire) != done {
446                wait_pause(&mut spins);
447            }
448        }
449        if let Some(trace) = &trace {
450            let elapsed_ns = trace.nanos();
451            let schedule_id = &schedule.diagnostic_id;
452            let pool = &self.diagnostic_id;
453            tracing::trace!(target: "celox::parallel", event = "dispatch", pool, epoch,
454                schedule = schedule_id, lanes = schedule.lanes, elapsed_ns);
455            // Emit after joining: no subscriber locks or I/O on worker threads.
456            for (task, timing) in trace.tasks.iter().enumerate() {
457                let timing = timing.lock().unwrap_or_else(|e| e.into_inner());
458                tracing::trace!(target: "celox::parallel", event = "task_run", pool, epoch,
459                    schedule = schedule_id, task, lane = schedule.tasks[task].lane,
460                    start_ns = timing.start, end_ns = timing.end, skipped = timing.code.is_none(), code = timing.code.unwrap_or(0));
461                for &(other, completed, start_ns, end_ns) in &timing.waits {
462                    tracing::trace!(target: "celox::parallel", event = "task_wait", pool, epoch,
463                        task, lane = schedule.tasks[task].lane, other, completed, start_ns, end_ns);
464                }
465            }
466        }
467        // SAFETY: every worker is idle again.
468        unsafe {
469            *shared.job.get() = None;
470        }
471        let failure = shared
472            .failure
473            .lock()
474            .unwrap_or_else(|poisoned| poisoned.into_inner())
475            .take();
476        match failure {
477            Some(failure) => Err(failure),
478            None => Ok(()),
479        }
480    }
481}
482
483fn worker(shared: &Shared, lane: usize) {
484    let mut seen = 0u64;
485    loop {
486        let mut idle_since = None::<Instant>;
487        let mut spins = 0u32;
488        let epoch = loop {
489            let epoch = shared.job_epoch.0.load(Ordering::Acquire);
490            if epoch != seen || shared.shutdown.load(Ordering::Acquire) {
491                break epoch;
492            }
493            spins = spins.wrapping_add(1);
494            std::hint::spin_loop();
495            if !spins.is_multiple_of(256) {
496                continue;
497            }
498            let since = *idle_since.get_or_insert_with(Instant::now);
499            if since.elapsed() < shared.idle_spin {
500                continue;
501            }
502            // Announce sleep, then re-check so a concurrent dispatch either
503            // sees the flag (and unparks) or is seen here.
504            shared.sleeping[lane].0.store(true, Ordering::SeqCst);
505            let epoch = shared.job_epoch.0.load(Ordering::SeqCst);
506            if epoch != seen || shared.shutdown.load(Ordering::SeqCst) {
507                shared.sleeping[lane].0.store(false, Ordering::SeqCst);
508                break epoch;
509            }
510            std::thread::park();
511            shared.sleeping[lane].0.store(false, Ordering::SeqCst);
512            idle_since = None;
513        };
514        if shared.shutdown.load(Ordering::Acquire) {
515            return;
516        }
517        seen = epoch;
518        // SAFETY: published before the epoch acquired above.
519        let job = unsafe { *shared.job.get() };
520        if let Some(job) = job {
521            shared.execute_lane(lane, epoch, job);
522        }
523        // The dispatching thread may replace `job` once every lane is done.
524        shared.progress[lane]
525            .0
526            .store((epoch << 32) | LANE_DONE, Ordering::Release);
527    }
528}
529
530impl Drop for LanePool {
531    fn drop(&mut self) {
532        self.shared.shutdown.store(true, Ordering::SeqCst);
533        self.shared.job_epoch.0.fetch_add(1, Ordering::SeqCst);
534        for worker in &self.workers {
535            worker.thread().unpark();
536        }
537        for worker in self.workers.drain(..) {
538            let _ = worker.join();
539        }
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use std::sync::atomic::AtomicI64;
547
548    /// Each task appends its index to a log after checking that every
549    /// task it waits for has already logged.
550    struct Recorder {
551        log: Mutex<Vec<usize>>,
552        done: Vec<AtomicBool>,
553        predecessors: Vec<Vec<usize>>,
554        fail: Option<usize>,
555        calls: AtomicI64,
556    }
557
558    impl LaneTaskRunner for Recorder {
559        fn run_task(&self, task: usize) -> i64 {
560            self.calls.fetch_add(1, Ordering::Relaxed);
561            for &predecessor in &self.predecessors[task] {
562                assert!(
563                    self.done[predecessor].load(Ordering::Acquire),
564                    "task {task} ran before {predecessor}"
565                );
566            }
567            self.log.lock().unwrap().push(task);
568            self.done[task].store(true, Ordering::Release);
569            if self.fail == Some(task) { 7 } else { 0 }
570        }
571    }
572
573    fn chain_schedule() -> (LaneSchedule, Vec<Vec<usize>>) {
574        // lane 0: t0, t2; lane 1: t1 (waits for t0); lane 2: t3 (waits for t1, t2)
575        let tasks = vec![
576            LaneTaskSpec {
577                lane: 0,
578                waits: vec![],
579            },
580            LaneTaskSpec {
581                lane: 1,
582                waits: vec![(0, 1)],
583            },
584            LaneTaskSpec {
585                lane: 0,
586                waits: vec![],
587            },
588            LaneTaskSpec {
589                lane: 2,
590                waits: vec![(1, 1), (0, 2)],
591            },
592        ];
593        let predecessors = vec![vec![], vec![0], vec![0], vec![1, 2]];
594        (LaneSchedule::new(3, tasks).unwrap(), predecessors)
595    }
596
597    fn recorder(predecessors: Vec<Vec<usize>>, fail: Option<usize>) -> Recorder {
598        Recorder {
599            log: Mutex::new(Vec::new()),
600            done: (0..predecessors.len())
601                .map(|_| AtomicBool::new(false))
602                .collect(),
603            predecessors,
604            fail,
605            calls: AtomicI64::new(0),
606        }
607    }
608
609    #[test]
610    fn schedules_reject_forward_or_self_waits() {
611        let forward = vec![LaneTaskSpec {
612            lane: 0,
613            waits: vec![(1, 1)],
614        }];
615        assert!(LaneSchedule::new(2, forward).is_err());
616        let self_wait = vec![
617            LaneTaskSpec {
618                lane: 0,
619                waits: vec![],
620            },
621            LaneTaskSpec {
622                lane: 0,
623                waits: vec![(0, 1)],
624            },
625        ];
626        assert!(LaneSchedule::new(1, self_wait).is_err());
627    }
628
629    #[test]
630    fn repeated_executions_honour_waits() {
631        let (schedule, predecessors) = chain_schedule();
632        let mut pool = LanePool::with_idle_spin(3, Duration::from_micros(0)).unwrap();
633        for _ in 0..2000 {
634            let runner = recorder(predecessors.clone(), None);
635            pool.run(&schedule, &runner).unwrap();
636            assert_eq!(runner.log.lock().unwrap().len(), 4);
637        }
638    }
639
640    #[test]
641    fn failures_skip_pending_work_and_report_the_first_task() {
642        let (schedule, predecessors) = chain_schedule();
643        let mut pool = LanePool::new(3).unwrap();
644        let runner = recorder(predecessors.clone(), Some(0));
645        assert_eq!(
646            pool.run(&schedule, &runner),
647            Err(LaneTaskFailure { task: 0, code: 7 })
648        );
649        // Dependents of the failed task never ran.
650        assert!(!runner.done[1].load(Ordering::Acquire));
651        assert!(!runner.done[3].load(Ordering::Acquire));
652        // The pool remains usable after a failure.
653        let runner = recorder(predecessors, None);
654        pool.run(&schedule, &runner).unwrap();
655        assert_eq!(runner.calls.load(Ordering::Relaxed), 4);
656    }
657
658    #[test]
659    fn concurrent_loop_and_internal_failures_take_precedence_over_fatal_captures() {
660        struct Runner {
661            started: std::sync::Barrier,
662            codes: [i64; 2],
663        }
664        impl LaneTaskRunner for Runner {
665            fn run_task(&self, task: usize) -> i64 {
666                self.started.wait();
667                self.codes[task]
668            }
669        }
670        let schedule = LaneSchedule::new(
671            2,
672            vec![
673                LaneTaskSpec {
674                    lane: 0,
675                    waits: vec![],
676                },
677                LaneTaskSpec {
678                    lane: 1,
679                    waits: vec![],
680                },
681            ],
682        )
683        .unwrap();
684        let mut pool = LanePool::new(2).unwrap();
685        for code in [1, 2000, LANE_TASK_PANICKED] {
686            for task in [0, 1] {
687                let mut codes = [crate::comb_fatal_code(1); 2];
688                codes[task] = code;
689                let runner = Runner {
690                    started: std::sync::Barrier::new(2),
691                    codes,
692                };
693                assert_eq!(
694                    pool.run(&schedule, &runner),
695                    Err(LaneTaskFailure { task, code })
696                );
697            }
698        }
699        let runner = Runner {
700            started: std::sync::Barrier::new(2),
701            codes: [crate::comb_fatal_code(2), crate::comb_fatal_code(1)],
702        };
703        assert_eq!(
704            pool.run(&schedule, &runner),
705            Err(LaneTaskFailure {
706                task: 0,
707                code: crate::comb_fatal_code(2),
708            })
709        );
710    }
711
712    #[test]
713    fn epochs_restart_without_releasing_waits_early() {
714        let (schedule, predecessors) = chain_schedule();
715        let mut pool = LanePool::new(3).unwrap();
716        pool.epoch = MAX_EPOCH - 2;
717        for _ in 0..4 {
718            let runner = recorder(predecessors.clone(), None);
719            pool.run(&schedule, &runner).unwrap();
720            assert_eq!(runner.calls.load(Ordering::Relaxed), 4);
721        }
722        assert!(pool.epoch < 4);
723    }
724
725    #[test]
726    fn sequential_fallback_preserves_list_order() {
727        let (schedule, predecessors) = chain_schedule();
728        let runner = recorder(predecessors, None);
729        schedule.run_sequential(&runner).unwrap();
730        assert_eq!(*runner.log.lock().unwrap(), vec![0, 1, 2, 3]);
731    }
732
733    #[test]
734    fn diagnostic_ids_distinguish_equal_topologies_and_recreated_pools() {
735        let (first, _) = chain_schedule();
736        let (second, _) = chain_schedule();
737        assert_ne!(first.diagnostic_id, second.diagnostic_id);
738        assert_eq!(first, second);
739        assert_eq!(first, second.clone().with_diagnostic_id("different".into()));
740        let different = LaneSchedule::new(
741            1,
742            vec![LaneTaskSpec {
743                lane: 0,
744                waits: vec![],
745            }],
746        )
747        .unwrap();
748        assert_ne!(first, different);
749        assert_eq!(first.clone().diagnostic_id, first.diagnostic_id);
750        let first_pool = LanePool::new(1).unwrap();
751        let id = first_pool.diagnostic_id.clone();
752        drop(first_pool);
753        let second_pool = LanePool::new(1).unwrap();
754        assert_ne!(id, second_pool.diagnostic_id);
755    }
756
757    #[test]
758    fn traced_failure_and_recovery_record_skips_and_real_waits() {
759        use std::collections::BTreeMap;
760        #[derive(Clone)]
761        struct Capture(Arc<Mutex<Vec<BTreeMap<String, String>>>>);
762        struct Fields(BTreeMap<String, String>);
763        impl tracing::field::Visit for Fields {
764            fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
765                self.0.insert(field.name().into(), format!("{value:?}"));
766            }
767        }
768        impl tracing::Subscriber for Capture {
769            fn max_level_hint(&self) -> Option<tracing::metadata::LevelFilter> {
770                Some(tracing::metadata::LevelFilter::TRACE)
771            }
772            fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
773                metadata.target() == "celox::parallel"
774            }
775            fn register_callsite(
776                &self,
777                _: &'static tracing::Metadata<'static>,
778            ) -> tracing::subscriber::Interest {
779                tracing::subscriber::Interest::sometimes()
780            }
781            fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
782                tracing::span::Id::from_u64(1)
783            }
784            fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
785            fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
786            fn enter(&self, _: &tracing::span::Id) {}
787            fn exit(&self, _: &tracing::span::Id) {}
788            fn event(&self, event: &tracing::Event<'_>) {
789                let mut fields = Fields(BTreeMap::new());
790                event.record(&mut fields);
791                self.0.lock().unwrap().push(fields.0);
792            }
793        }
794        let events = Arc::new(Mutex::new(Vec::new()));
795        tracing::subscriber::with_default(Capture(events.clone()), || {
796            let (schedule, predecessors) = chain_schedule();
797            let mut pool = LanePool::new(3).unwrap();
798            assert!(
799                pool.run(&schedule, &recorder(predecessors.clone(), Some(0)))
800                    .is_err()
801            );
802            pool.run(&schedule, &recorder(predecessors, None)).unwrap();
803        });
804        let events = events.lock().unwrap();
805        let rows = |name: &str| {
806            let name = format!("{name:?}");
807            events.iter().filter(move |e| e["event"] == name)
808        };
809        assert_eq!(rows("dispatch").count(), 2, "{events:?}");
810        let runs = rows("task_run").collect::<Vec<_>>();
811        assert_eq!(runs.len(), 8);
812        assert!(runs.iter().any(|e| e["skipped"] == "true"));
813        for event in runs.iter().filter(|e| e["epoch"] == "2") {
814            assert_eq!(event["skipped"], "false");
815            assert_eq!(event["code"], "0");
816            assert!(
817                event["end_ns"].parse::<u64>().unwrap()
818                    >= event["start_ns"].parse::<u64>().unwrap()
819            );
820        }
821        assert_eq!(rows("task_wait").filter(|e| e["epoch"] == "2").count(), 3);
822    }
823}
824
825/// Which implementation of a kernel to run next.
826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
827pub enum KernelChoice {
828    Parallel,
829    Sequential,
830}
831
832/// How a runtime picks between a kernel's partitioned and sequential code.
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
834pub enum KernelSelection {
835    /// Measure both implementations on the running simulation and keep the
836    /// faster one.
837    #[default]
838    Measured,
839    /// Always run the partitioned kernel.
840    Parallel,
841    /// Always run the sequential kernel.
842    Sequential,
843}
844
845/// Picks a kernel implementation from measured execution times.
846///
847/// Calibration runs blocks of consecutive calls of one implementation so that
848/// worker wake-up and cache warm-up are charged the way steady execution
849/// would see them. The first calls of every block, which pay for moving the
850/// state between cores, are discarded. After the configured number of
851/// rounds, the implementation with the lower median call time is kept; the
852/// partitioned kernel must win by a margin, since the sequential kernel needs
853/// no worker threads.
854///
855/// A measured decision is checked again after a number of calls that doubles
856/// with every check, so a choice made while the host was busy, or before the
857/// simulation reached its steady state, does not stay wrong for the whole
858/// run. Checks become rare quickly and cost a few dozen calls each.
859#[derive(Debug)]
860pub struct KernelSelector {
861    diagnostic_id: String,
862    state: SelectorState,
863    /// Calls between the next decision and its check; zero for a fixed
864    /// selection.
865    recheck_interval: u64,
866}
867
868#[derive(Debug, Clone)]
869enum SelectorState {
870    Calibrating {
871        round: u32,
872        position: u32,
873        samples: [Vec<u64>; 2],
874    },
875    Decided {
876        choice: KernelChoice,
877        /// Calls left until the decision is measured again.
878        remaining: u64,
879    },
880}
881
882impl Clone for KernelSelector {
883    fn clone(&self) -> Self {
884        Self {
885            diagnostic_id: new_diagnostic_id(),
886            state: self.state.clone(),
887            recheck_interval: self.recheck_interval,
888        }
889    }
890}
891
892impl KernelSelector {
893    /// Calls per calibration block, including the discarded calls.
894    const BLOCK: u32 = 16;
895    /// Calls discarded at the start of every block.
896    const DISCARDED: u32 = 4;
897    /// Blocks per implementation.
898    const ROUNDS: u32 = 3;
899    /// The partitioned kernel's median must be below this fraction (in
900    /// percent) of the sequential median.
901    const PARALLEL_MARGIN_PERCENT: u64 = 95;
902    /// Calls before the first check of a measured decision.
903    const FIRST_RECHECK: u64 = 1 << 16;
904    /// Longest interval between two checks.
905    const MAX_RECHECK: u64 = 1 << 26;
906
907    pub fn new(selection: KernelSelection) -> Self {
908        let decided = |choice| SelectorState::Decided {
909            choice,
910            remaining: u64::MAX,
911        };
912        match selection {
913            KernelSelection::Measured => Self {
914                diagnostic_id: new_diagnostic_id(),
915                state: Self::calibrating(),
916                recheck_interval: Self::FIRST_RECHECK,
917            },
918            KernelSelection::Parallel => Self {
919                diagnostic_id: new_diagnostic_id(),
920                state: decided(KernelChoice::Parallel),
921                recheck_interval: 0,
922            },
923            KernelSelection::Sequential => Self {
924                diagnostic_id: new_diagnostic_id(),
925                state: decided(KernelChoice::Sequential),
926                recheck_interval: 0,
927            },
928        }
929    }
930
931    /// Process-local identity preserved across moves, but not across clones.
932    pub fn diagnostic_id(&self) -> &str {
933        &self.diagnostic_id
934    }
935
936    fn calibrating() -> SelectorState {
937        SelectorState::Calibrating {
938            round: 0,
939            position: 0,
940            samples: [Vec::new(), Vec::new()],
941        }
942    }
943
944    /// The implementation to run next, and whether its duration should be
945    /// reported with [`Self::record`].
946    pub fn next(&self) -> (KernelChoice, bool) {
947        match &self.state {
948            SelectorState::Decided { choice, .. } => (*choice, false),
949            SelectorState::Calibrating { round, .. } => {
950                // Alternate whole blocks: parallel, sequential, parallel, ...
951                let choice = if round % 2 == 0 {
952                    KernelChoice::Parallel
953                } else {
954                    KernelChoice::Sequential
955                };
956                (choice, true)
957            }
958        }
959    }
960
961    /// The current decision, or `None` while measuring.
962    pub fn decided(&self) -> Option<KernelChoice> {
963        match self.state {
964            SelectorState::Decided { choice, .. } => Some(choice),
965            SelectorState::Calibrating { .. } => None,
966        }
967    }
968
969    /// Account for `calls` executions of the decided implementation; a
970    /// measured decision is checked again when its interval has elapsed.
971    pub fn advance(&mut self, calls: u64) {
972        if self.recheck_interval == 0 {
973            return;
974        }
975        if let SelectorState::Decided { remaining, .. } = &mut self.state {
976            *remaining = remaining.saturating_sub(calls);
977            if *remaining == 0 {
978                self.state = Self::calibrating();
979            }
980        }
981    }
982
983    /// Report the duration of a call started after [`Self::next`] asked for
984    /// timing.
985    pub fn record(&mut self, choice: KernelChoice, nanos: u64) {
986        let SelectorState::Calibrating {
987            round,
988            position,
989            samples,
990        } = &mut self.state
991        else {
992            return;
993        };
994        if *position >= Self::DISCARDED {
995            samples[usize::from(choice == KernelChoice::Sequential)].push(nanos);
996        }
997        *position += 1;
998        if *position < Self::BLOCK {
999            return;
1000        }
1001        *position = 0;
1002        *round += 1;
1003        if *round < 2 * Self::ROUNDS {
1004            return;
1005        }
1006        let median = |values: &mut Vec<u64>| {
1007            values.sort_unstable();
1008            values.get(values.len() / 2).copied().unwrap_or(u64::MAX)
1009        };
1010        let [parallel, sequential] = samples;
1011        let parallel = median(parallel);
1012        let sequential = median(sequential);
1013        let choice = if parallel.saturating_mul(100)
1014            < sequential.saturating_mul(Self::PARALLEL_MARGIN_PERCENT)
1015        {
1016            KernelChoice::Parallel
1017        } else {
1018            KernelChoice::Sequential
1019        };
1020        tracing::debug!(target: "celox::parallel::selection", event = "selector_decision",
1021            selector = self.diagnostic_id.as_str(), parallel_ns = parallel, sequential_ns = sequential, choice = ?choice);
1022        tracing::debug!(
1023            "[parallel] kernel selection: parallel median {parallel} ns, sequential median {sequential} ns -> {choice:?}"
1024        );
1025        self.state = SelectorState::Decided {
1026            choice,
1027            remaining: self.recheck_interval,
1028        };
1029        self.recheck_interval = self
1030            .recheck_interval
1031            .saturating_mul(2)
1032            .min(Self::MAX_RECHECK);
1033    }
1034}
1035
1036#[cfg(test)]
1037mod selector_tests {
1038    use super::*;
1039
1040    fn calibrate(parallel: u64, sequential: u64) -> KernelChoice {
1041        let mut selector = KernelSelector::new(KernelSelection::Measured);
1042        while selector.decided().is_none() {
1043            let (choice, timed) = selector.next();
1044            assert!(timed);
1045            selector.record(
1046                choice,
1047                match choice {
1048                    KernelChoice::Parallel => parallel,
1049                    KernelChoice::Sequential => sequential,
1050                },
1051            );
1052        }
1053        selector.decided().unwrap()
1054    }
1055
1056    #[test]
1057    fn selector_identity_survives_moves_without_reuse() {
1058        let selector = KernelSelector::new(KernelSelection::Measured);
1059        let id = selector.diagnostic_id().to_owned();
1060        let mut moved = Box::new(selector);
1061        assert_eq!(moved.diagnostic_id(), id);
1062        let cloned = moved.clone();
1063        assert_ne!(cloned.diagnostic_id(), id);
1064        assert_eq!(cloned.next(), moved.next());
1065        while moved.decided().is_none() {
1066            let (choice, _) = moved.next();
1067            moved.record(choice, 100);
1068        }
1069        assert_eq!(moved.diagnostic_id(), id);
1070        let decided_clone = moved.clone();
1071        assert_ne!(decided_clone.diagnostic_id(), id);
1072        assert_eq!(decided_clone.next(), moved.next());
1073        drop(moved);
1074        let replacement = KernelSelector::new(KernelSelection::Measured);
1075        assert_ne!(replacement.diagnostic_id(), id);
1076        assert_ne!(replacement.diagnostic_id(), cloned.diagnostic_id());
1077    }
1078
1079    #[test]
1080    fn measured_selection_keeps_the_faster_kernel() {
1081        assert_eq!(calibrate(500, 1000), KernelChoice::Parallel);
1082        assert_eq!(calibrate(1000, 500), KernelChoice::Sequential);
1083        // Parallel execution must win by a margin.
1084        assert_eq!(calibrate(980, 1000), KernelChoice::Sequential);
1085    }
1086
1087    #[test]
1088    fn measured_decisions_are_checked_again_with_growing_intervals() {
1089        let mut selector = KernelSelector::new(KernelSelection::Measured);
1090        let mut calibrations = 0;
1091        let mut checks = Vec::new();
1092        let mut calls = 0u64;
1093        while checks.len() < 3 {
1094            let (choice, timed) = selector.next();
1095            if timed {
1096                let was_measuring = selector.decided().is_none();
1097                selector.record(
1098                    choice,
1099                    if choice == KernelChoice::Parallel {
1100                        1
1101                    } else {
1102                        2
1103                    },
1104                );
1105                if was_measuring && selector.decided().is_some() {
1106                    calibrations += 1;
1107                    checks.push(calls);
1108                }
1109            } else {
1110                selector.advance(1 << 12);
1111            }
1112            calls += 1;
1113        }
1114        assert_eq!(calibrations, 3);
1115        // The second interval is twice the first.
1116        let first = checks[1] - checks[0];
1117        let second = checks[2] - checks[1];
1118        assert!(second > first);
1119        assert_eq!(selector.decided(), Some(KernelChoice::Parallel));
1120    }
1121
1122    #[test]
1123    fn fixed_selections_do_not_measure() {
1124        for (selection, expected) in [
1125            (KernelSelection::Parallel, KernelChoice::Parallel),
1126            (KernelSelection::Sequential, KernelChoice::Sequential),
1127        ] {
1128            let mut selector = KernelSelector::new(selection);
1129            assert_eq!(selector.next(), (expected, false));
1130            selector.advance(u64::MAX);
1131            assert_eq!(selector.next(), (expected, false));
1132        }
1133    }
1134}