Skip to content

feat(sched): add CPU core affinity via ClusteredEDF scheduler#701

Open
ytakano wants to merge 6 commits into
tier4:mainfrom
ytakano:core_affinity
Open

feat(sched): add CPU core affinity via ClusteredEDF scheduler#701
ytakano wants to merge 6 commits into
tier4:mainfrom
ytakano:core_affinity

Conversation

@ytakano

@ytakano ytakano commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Description

Generalizes the PartitionedEDF scheduler — which pinned each task to a single core — into a ClusteredEDF scheduler where each task carries a CpuSet describing the set of cores it may run on. Pinning to a sing
le core is now the special case of a one-bit CpuSet.

Key changes:

  • awkernel_lib: adds a const-friendly CpuSet type ([u64; NUM_MAX_CPU / 64], i.e. 512 CPUs) with const fn constructors (empty, from_bits, insert, contains, is_empty, masked_workers, all_workers) so it can be embedded in the const-evaluated PRIORITY_LIST.
  • Run queue: replaces the per-core [Mutex<EDFData>; NUM_MAX_CPU] BinaryHeap array with a single affinity-aware priority queue from the affinity_btree_queue crate. get_next pops the earliest-deadline task runnable on the calling CPU via pop_for_cpu. Priority is (absolute_deadline, wake_time), matching the previous EDF ordering, with FIFO tie-breaking on fully-equal keys.
  • API: SchedulerType::PartitionedEDF(u64, u16)ClusteredEDF(u64, CpuSet); Task.partitioned_core: Option<u16>Task.cpu_set: Option<CpuSet>. spawn masks out CPU 0 (primary core) and out-of-range bits, and falls back to all worker cores (1..num_cpu()) with a warning when the resulting set is empty.
  • Preemption: invoke_preemption now selects the core running the lowest-priority task among the set, and skips preemption entirely (enqueue only) when any core in the set is idle.
  • Renames: PartitionedTaskClusteredTask (now holding a CpuSet and updating the per-CPU counter for every core in the set), NUM_PARTITIONED_TASKS_IN_QUEUENUM_CLUSTERED_TASKS_IN_QUEUE, get_num_partitioned_schedulersget_num_clustered_schedulers, test crate test_partitioned_edftest_clustered_edf.
  • Bug fix: fixes a pre-existing lost-wakeup bug in wake_workersbreakcontinue so higher-numbered CPUs with queued clustered tasks are no longer skipped when the global task count reaches zero.

Related links

How was this PR tested?

  • cargo check_std, cargo check_x86 (no_std target), cargo clippy_std — all pass with no warnings.
  • cargo test_awkernel_async_lib — 42 passed.
  • make x86_64 RELEASE=1 — builds successfully.
  • Booted in QEMU (-smp 4) with the test_clustered_edf feature:
    • core pinning: single-core CpuSet tasks ran only on their assigned core (cores 1–3) — all [OK].
    • cluster affinity: a task with CpuSet {1, 2} only ever ran on cores 1 or 2 — all [OK].
    • EDF preemption: light (earlier deadline) correctly preempted heavy on the same core.
    • multi-core: tasks on cores 1 and 2 ran in parallel without interference.

Notes for reviewers

  • SchedulerType grows from 16 bytes to ~72 bytes (still Copy); it is only passed by value on the spawn path.
  • The get_next_task(false) path (RR preemption tick, primary CPU only) can never dequeue a clustered task b
    ecause spawn always removes CPU 0 from every set — same invariant as before. This is noted in a comment in
    clustered_edf::get_next.
  • The single queue Mutex is only ever acquired while GLOBAL_WAKE_GET_MUTEX is held (both in wake_task a
    nd via the get_next_task wrapper), so no new lock cycle is introduced. invoke_preemption still runs befor
    e the queue lock is taken.
  • The queue is lazily initialized with num_cpu() on first use (AffinityBTreeQueue::new is not const), f
    ollowing the existing pattern in prioritized_rr.rs. set_num_cpu runs before the first spawn, so num_cp u() is final by then.
  • The wake_workers breakcontinue fix addresses a latent lost-wakeup that becomes more reachable with
    clusters; it is included here rather than split out because the counter semantics changed in the same area.
  • mdbook/src/internal/scheduler.md is updated to describe ClusteredEDF and the affinity-aware B-tree queue.

ytakano and others added 2 commits July 7, 2026 14:56
Generalize the PartitionedEDF scheduler, which pinned each task to a
single core, into ClusteredEDF where each task carries a CpuSet of cores
it may run on. Run queues are managed by the affinity_btree_queue crate:
a single affinity-aware priority queue replaces the per-core BinaryHeap
array, and get_next pops the earliest-deadline task runnable on the
calling CPU via pop_for_cpu.

- awkernel_lib: add const-friendly CpuSet ([u64; NUM_MAX_CPU/64]) with
  const constructors so it can live in the const-evaluated PRIORITY_LIST.
- SchedulerType::PartitionedEDF(u64, u16) -> ClusteredEDF(u64, CpuSet);
  Task.partitioned_core -> cpu_set: Option<CpuSet>. spawn masks out CPU 0
  and out-of-range bits, falling back to all worker cores when empty.
- invoke_preemption picks the core running the lowest-priority task among
  the set; enqueues without preemption when any core in the set is idle.
- Rename partitioned symbols to clustered (PartitionedTask -> ClusteredTask,
  NUM_PARTITIONED_TASKS_IN_QUEUE -> NUM_CLUSTERED_TASKS_IN_QUEUE, test
  crate test_partitioned_edf -> test_clustered_edf).
- Fix a pre-existing lost-wakeup bug in wake_workers: break -> continue so
  higher-numbered CPUs with queued clustered tasks are not skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
@ytakano ytakano changed the title Core affinity feat(sched): add CPU core affinity via ClusteredEDF scheduler Jul 7, 2026
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a new ClusteredEDF real-time scheduler that generalizes the prior PartitionedEDF model by allowing tasks to run on a set of worker cores (CpuSet affinity), and updates the surrounding API, tests, and documentation accordingly.

Changes:

  • Add CpuSet (const-friendly CPU affinity bitmask) and migrate task/scheduler APIs from single-core pinning to affinity sets.
  • Replace per-core EDF runqueues with a single affinity-aware AffinityBTreeQueue, and add a new clustered_edf scheduler implementation.
  • Rename and update the userland/kernel test feature and test crate from test_partitioned_edf to test_clustered_edf, plus doc updates/regeneration.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
userland/src/lib.rs Switch test entrypoint feature from test_partitioned_edf to test_clustered_edf.
userland/Cargo.toml Rename optional dependency + feature flag to test_clustered_edf.
kernel/Cargo.toml Rename kernel feature passthrough to test_clustered_edf.
awkernel_lib/src/cpu.rs Add CpuSet type and related helpers/constants.
awkernel_async_lib/src/task.rs Replace partitioned_core with cpu_set, update counters and wake logic.
awkernel_async_lib/src/scheduler.rs Register ClusteredEDF, update scheduler type API, add clustered counter RAII wrapper.
awkernel_async_lib/src/scheduler/partitioned_edf.rs Remove the old PartitionedEDF scheduler implementation.
awkernel_async_lib/src/scheduler/clustered_edf.rs Add new ClusteredEDF scheduler implementation using AffinityBTreeQueue.
awkernel_async_lib/Cargo.toml Add affinity_btree_queue dependency.
applications/tests/test_clustered_edf/src/lib.rs Update tests to exercise core pinning and multi-core affinity via CpuSet.
applications/tests/test_clustered_edf/Cargo.toml Rename test crate to test_clustered_edf.
mdbook/src/internal/scheduler.md Update scheduler docs from PartitionedEDF to ClusteredEDF.
docs/print.html Regenerated docs including new ClusteredEDF content (and additional RISC-V sections).
docs/internal/scheduler.html Regenerated scheduler page reflecting ClusteredEDF.
docs/internal/page_table.html Regenerated docs content (adds RISC-V sections).
docs/internal/memory_allocator.html Regenerated docs content (adds RISC-V sections).
docs/internal/interrupt_controller.html Regenerated docs content (adds RISC-V sections).
docs/internal/arch/mapper.html Regenerated docs content (adds RISC-V sections).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread awkernel_async_lib/src/scheduler.rs Outdated
Comment thread awkernel_lib/src/cpu.rs Outdated
Comment thread awkernel_lib/src/cpu.rs Outdated
Comment thread docs/print.html
ytakano and others added 3 commits July 7, 2026 15:51
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
@ytakano ytakano marked this pull request as ready for review July 7, 2026 08:40
@ytakano ytakano requested review from atsushi421 and nokosaaan July 7, 2026 08:41
Comment thread awkernel_lib/src/cpu.rs
}

/// Iterate over the CPUs contained in the set, in ascending order.
pub fn iter(&self) -> impl Iterator<Item = usize> + '_ {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CpuSet::iter always evaluates the inner 0..64 filter for every word, so each traversal costs a fixed 512 branch evaluations regardless of how many bits are set or how many words are zero. This runs on the hot path (ClusteredTask::new/take/Drop and invoke_preemption), so even a single-core pinned task pays 512 evaluations to yield one CPU index. Consider skipping zero words and using word.trailing_zeros() with word &= word - 1 to make the cost O(popcount).

pub(crate) fn new(inner: T, cpu_id: usize) -> Self {
crate::task::NUM_PARTITIONED_TASKS_IN_QUEUE[cpu_id].fetch_add(1, Ordering::Relaxed);
impl<T> ClusteredTask<T> {
pub(crate) fn new(inner: T, cpu_set: CpuSet) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClusteredTask::new/take/Drop each loop over the whole cpu_set doing one atomic RMW per member CPU on a distinct cache line. A wide-affinity task (e.g. CpuSet::any(), up to 511 workers) does up to 511 atomics per wake, per dequeue, and per drop, versus O(1) in the old partitioned design. Consider whether a single global clustered-task counter plus the queue's affinity-aware pop_for_cpu could replace the per-CPU array, or at least ensure the loop only touches set bits.

Comment thread awkernel_lib/src/cpu.rs

/// Return a new set keeping only CPUs in `1..num_cpus`
/// (CPU 0 is always excluded).
pub const fn masked_workers(mut self, num_cpus: usize) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new CpuSet bit logic (masked_workers per-word masking, CPU 0 exclusion, all_workers, cross-word iter) has no unit tests, even though awkernel_lib already unit-tests a comparable bitmap in net/port_bitmap.rs. Consider adding a #[cfg(test)] mod tests covering masked_workers at and around word boundaries (num_cpus of 64/65/128), CPU 0 always excluded, all_workers for several sizes including 1, and iter ordering across words.

);
scheduler_type = SchedulerType::PartitionedEDF(deadline, 1);
Some(1u16)
CpuSet::all_workers(num_cpu())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-set fallback here (mask to empty, warn, fall back to all worker cores) is a distinct code path that no test exercises; the test crate only spawns valid single-core and {1,2} sets. Consider adding a test that spawns a ClusteredEDF task with an invalid set (e.g. CpuSet::empty(), or a set whose only cores are >= num_cpu()) and verifies it still runs on a worker core.

let normalized = if masked.is_empty() {
log::warn!(
"Partitioned core should be between 1 and {}. Falling back to core 1. Given core: {}",
"The CPU set must contain at least one core between 1 and {}. Falling back to all worker cores. Given set: {:?}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback makes an invalid or empty affinity set runnable on every worker core, a much larger blast radius than the previous PartitionedEDF fallback to a single core 1. The only trace of this policy is this runtime warning; it is not mentioned in the cpu_set() doc or the mdbook scheduler section. Consider documenting the fallback so callers know an out-of-range set is normalized to all workers rather than rejected.

if num_clustered_tasks > 0 {
let task = PRIORITY_LIST[..get_num_clustered_schedulers()]
.iter()
.find_map(|&scheduler_type| get_scheduler(scheduler_type).get_next(execution_ensured));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_scheduler (and get_priority) take SchedulerType by value, and this find_map copies the full ~72-byte value (now that CpuSet is embedded) out of the reference on every iteration, even though only the discriminant is matched. Consider changing these to take &SchedulerType and binding a reference in the closures to avoid the per-iteration payload copy.

Comment thread awkernel_lib/src/cpu.rs

/// Create a set from a bitmask of CPUs 0..64.
/// Bit `i` of `bits` corresponds to CPU `i`.
pub const fn from_bits(bits: u64) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from_bits only writes words[0], so it cannot express any CPU >= 64 even though CPU_SET_WORDS supports up to 512. It is also currently unused. Consider accepting [u64; CPU_SET_WORDS], or documenting explicitly that it is limited to the first 64 CPUs (and removing it / any() if they are not part of the intended public API).

.iter()
.find_map(|&scheduler_type| get_scheduler(scheduler_type).get_next(execution_ensured));

// Decrement is handled by ClusteredTask::Drop inside get_next().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says the decrement is handled by ClusteredTask::Drop, but the decrement actually happens in the explicit take() call inside get_next (Drop is a no-op once take() has run). Consider rewording to attribute the decrement to take().

};

if task > target_task {
push_preemption_pending(victim_cpu, task);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic inside invoke_preemption (which examines each CPU in the set, selects the CPU with the lowest-priority task currently running or pending, and sends an interrupt to that CPU) is if an interrupt is sent (i.e., preemption succeeds), the new task is not placed in the affinity queue (a shared location visible to multiple CPUs) but is queued exclusively in the holding area (the preemption_pending heap) dedicated to the single CPU selected as the victim.

For example, suppose the CPU set for task T is {1, 2}. Currently, both Core 1 and Core 2 are executing something, but since the task running on Core 1 has a lower priority, Core 1 is selected as the victim. Task T is placed in the pending heap dedicated to Core 1, and an interrupt (IPI) is sent to Core 1.

Even if, in this scenario, Core 2 finishes its currently running task and becomes idle before the interrupt reaches Core 1, Core 2 is completely unaware of the existence of T (since T is not in the affinity queue and, of course, is not in Core 2’s pending heap either). Core 2 determines that “there is no work for it” and goes to sleep; even though Core 2 could have picked up T immediately, T is forced to wait until Core 1 actually processes the interrupt, creating unnecessary wait time (a latency window).

Bounded, but maybe worth a note or a future enqueue-instead-of-pin strategy.

Comment on lines +76 to +78
if cpu_set.is_empty() || cpu_set.masked_workers(num_cpu()) != cpu_set {
panic!("ClusteredEDF: CPU set {cpu_set:?} is out of range");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalid CPU sets panic at wake time rather than failing at spawn, and two more panics are reachable through no fault of wake_task's own validation:

push() may need to grow the B-tree's backing storage; Rust's allocator has no recoverable-error path for that, so an allocation failure goes straight to alloc_error_handler (an abort on most bare-metal targets) while both GLOBAL_WAKE_GET_MUTEX and the scheduler's own lock are held. Not a regression — the old BinaryHeap had the same property — but worth naming since the move to a single shared queue makes this the only queue left in the hot wake path.

the crate itself panics inside get_next/pop_for_cpu if a descent finds the OR-aggregated subtree_affinity bit set but no eligible entry beneath it — a deliberate fail-loud choice by the crate author (better than silently losing a task forever), but any future regression in its split/merge/rotate bookkeeping would surface as a kernel panic rather than a recoverable error.

I found no way to trigger either today, and the only reachable push error from the kernel's own guards is SequenceOverflow (~2^64 pushes). But together this hot path now carries three independent panics stacked on it. Is an unconditional crash the intended failure mode across the board, or would spawn-time validation plus a documented contract be preferable?

to_cpu_mask(&cpu_set),
ClusteredTask::new(task.clone(), cpu_set),
)
.expect("ClusteredEDF: failed to push a task");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalid CPU sets panic at wake time rather than failing at spawn, and two more panics are reachable through no fault of wake_task's own validation:

push() may need to grow the B-tree's backing storage; Rust's allocator has no recoverable-error path for that, so an allocation failure goes straight to alloc_error_handler (an abort on most bare-metal targets) while both GLOBAL_WAKE_GET_MUTEX and the scheduler's own lock are held. Not a regression — the old BinaryHeap had the same property — but worth naming since the move to a single shared queue makes this the only queue left in the hot wake path.

the crate itself panics inside get_next/pop_for_cpu if a descent finds the OR-aggregated subtree_affinity bit set but no eligible entry beneath it — a deliberate fail-loud choice by the crate author (better than silently losing a task forever), but any future regression in its split/merge/rotate bookkeeping would surface as a kernel panic rather than a recoverable error.

I found no way to trigger either today, and the only reachable push error from the kernel's own guards is SequenceOverflow (~2^64 pushes). But together this hot path now carries three independent panics stacked on it. Is an unconditional crash the intended failure mode across the board, or would spawn-time validation plus a documented contract be preferable?

let normalized = if masked.is_empty() {
log::warn!(
"Partitioned core should be between 1 and {}. Falling back to core 1. Given core: {}",
"The CPU set must contain at least one core between 1 and {}. Falling back to all worker cores. Given set: {:?}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with num_cpu()==1, the warning says "Falling back to all worker cores" but all_workers(1) is empty and the next wake panics; the message also prints between 1 and 0. Returning an error from spawn would be cleaner (pre-existing behavior, but the new message obscures it).

const fn get_num_partitioned_schedulers() -> usize {
/// Return the number of clustered schedulers in `PRIORITY_LIST`.
/// Update this function if you add a new clustered scheduler to `PRIORITY_LIST`.
const fn get_num_clustered_schedulers() -> usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correctness of NUM_TASK_IN_QUEUE depends on clustered schedulers forming a strict prefix of PRIORITY_LIST; a future reorder would make the full scan decrement a counter never incremented for clustered tasks (u32 wrap → wake_workers wakes everyone forever). A debug_assert!/comment would cheaply guard this.

fixedbitset = "0.5.7"
async-trait = "0.1"
async-recursion = "1.1"
affinity_btree_queue = "0.1"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"0.1" floats to any future 0.1.x of a single-author, self-published v0.1 crate that is now the scheduler's core data structure. Suggest =0.1.0, vendoring, or moving it into the workspace/org.

Comment thread awkernel_lib/src/cpu.rs
Comment on lines +25 to +27
pub fn any() -> Self {
Self::all_workers(num_cpu())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppose, in the future, another developer decides, “I want this task to run on any core,” and uses CpuSet::any(). Based on the name alone, it might seem to mean “no constraints—leave it up to the system,” but what it actually returns is the set of “all worker cores (all cores except CPU0).” In this particular case, since any() and all_workers(num_cpu()) happen to return the same value, there is no actual harm. However, if a developer mistakenly interprets any as “any one of them” and uses it that way, it could lead to misuse—unintentionally expanding the task’s execution scope to all cores.

Please consider the following corrections:
If renaming it, use a name that accurately reflects its behavior (e.g., all_workers_default(), which clearly indicates it is a shorthand for all_workers(num_cpu())).
Alternatively, simply remove it (since it is unused, and if you want to call it, you can simply write CpuSet::all_workers(num_cpu()); there is little need to keep a thin wrapper around it).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants