Skip to content

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

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

feat(sched): add CPU core affinity via ClusteredEDF scheduler#701
ytakano wants to merge 21 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 single core is now the special case of a one-bit CpuSet.

Key changes:

  • Affinity-aware 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, vendored in-tree as a workspace member (like smoltcp). 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.
  • CpuSet: awkernel_lib's CpuSet is now a type alias of affinity_btree_queue::CpuMask<CPU_SET_WORDS> (512 CPUs), so schedulers pass it to the run queue with no conversion. All constructors are const fn (chainable builder CpuSet::empty().with(1).with(2)), and its iter() is O(popcount). The CPU-0-exclusion policy stays in awkernel_lib as the const free functions cpu::masked_workers / cpu::all_workers.
  • 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. This normalization/fallback contract is documented on the SchedulerType::ClusteredEDF variant and in the mdbook.
  • O(1) clustered-task bookkeeping: the per-CPU NUM_CLUSTERED_TASKS_IN_QUEUE counter array is replaced by a single global AtomicU32 plus the run queue's exact affinity mask. ClusteredTask::new/take/Drop now do one atomic RMW instead of one per member CPU (up to 511 for a wide-affinity task). wake_workers reads the precise set of CPUs with an eligible queued task via the new Scheduler::queued_cpu_mask() trait method (default empty; ClusteredEDF implements it with the queue's O(1) affinity_mask()).
  • Extensibility: a new clustered scheduler only needs to (1) wrap queue entries in ClusteredTask, (2) implement queued_cpu_mask(), and (3) sit in the clustered prefix of PRIORITY_LIST. SchedulerType::is_clustered() is the single source of truth for the prefix, and a compile-time assertion rejects any reorder that would break it.
  • Boot invariant: cpu::sanity_check() (run on the primary CPU) panics if num_cpu() < 2. Awkernel reserves CPU 0 as the primary core and runs tasks only on the worker cores 1..num_cpu(), so a single CPU has no worker core; enforcing this once at boot replaces a confusing downstream panic and keeps the clustered fallback's messaging valid.
  • Preemption: invoke_preemption 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.
  • Micro-opt: get_scheduler / get_priority take &SchedulerType so the find_map in get_next_task no longer copies the large (CpuSet-embedding) SchedulerType per iteration.
  • Renames: PartitionedTaskClusteredTask, NUM_PARTITIONED_TASKS_IN_QUEUENUM_CLUSTERED_TASKS_IN_QUEUE, get_num_partitioned_schedulersget_num_clustered_schedulers, test crate test_partitioned_edftest_clustered_edf.

Related links

How was this PR tested?

  • cargo check_std, cargo check_riscv64 (no_std target), cargo clippy_std — all pass with no warnings.
  • RUSTFLAGS="-D warnings" make test — all suites pass, including:
    • test_awkernel_async_lib (42),
    • new cpu.rs unit tests for the masked_workers / all_workers worker-mask policy (word boundaries 64/65/128, CPU 0 exclusion, cross-word iter ordering),
    • the vendored test_affinity_btree_queue crate tests (61 unit + 17 doc).
  • make x86_64 RELEASE=1 — builds successfully.
  • Booted in QEMU (-smp 4) with the test_clustered_edf feature — all [OK], no panic:
    • core pinning: single-core CpuSet tasks ran only on their assigned core (cores 1–3).
    • cluster affinity: a task with CpuSet {1, 2} only ran on cores 1 or 2.
    • 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.
    • empty-set fallback: tasks spawned with an empty set and with an out-of-range-only set both fell back to worker cores and ran (never on CPU 0).

Notes for reviewers

  • SchedulerType grows from 16 bytes to ~72 bytes (still Copy) because it embeds a CpuSet. It is passed by reference on the hot get_next_task path (get_scheduler/get_priority take &SchedulerType) and only by value on the spawn path.
  • The get_next_task(false) path (RR preemption tick, primary CPU only) can never dequeue a clustered task because spawn always removes CPU 0 from every set — same invariant as before, noted in clustered_edf::get_next.
  • Locking: the single queue Mutex is the innermost lock everywhere (nothing else is acquired while holding it). It is taken under GLOBAL_WAKE_GET_MUTEX in wake_task/get_next_task, and wake_workers now also takes it directly (without GLOBAL_WAKE_GET_MUTEX) to read queued_cpu_mask(); since it is innermost, no lock cycle is introduced. invoke_preemption still runs before the queue lock is taken.
  • The queue is lazily initialized with num_cpu() on first use (AffinityBTreeQueue::new is not const), following the existing pattern in prioritized_rr.rs. set_num_cpu runs before the first spawn.
  • Known bounded latency (future work): when preemption succeeds, the task is pinned to the victim CPU's preemption-pending heap and is invisible to the other CPUs in its set until the IPI is handled; if another core in the set goes idle meanwhile it cannot pick the task up. This is bounded (IPI delivery + the victim's interrupt-disabled sections) and never a lost wakeup — documented in invoke_preemption. An enqueue-instead-of-pin strategy would close it but touches the pending-heap machinery shared by all schedulers, so it is deferred.
  • mdbook/src/internal/scheduler.md is updated to describe ClusteredEDF, the affinity-aware B-tree queue, the global-counter + queued_cpu_mask bookkeeping, and the spawn-time normalization/fallback.

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 Outdated
Comment thread awkernel_async_lib/src/scheduler.rs Outdated
Comment thread awkernel_lib/src/cpu.rs Outdated
Comment thread awkernel_async_lib/src/task.rs Outdated
Comment thread awkernel_async_lib/src/task.rs
Comment thread awkernel_async_lib/src/scheduler.rs Outdated
Comment thread awkernel_lib/src/cpu.rs Outdated
Comment thread awkernel_async_lib/src/scheduler.rs Outdated
Comment thread awkernel_async_lib/src/scheduler/clustered_edf.rs
Comment thread awkernel_async_lib/src/scheduler/clustered_edf.rs Outdated
Comment thread awkernel_async_lib/src/scheduler/clustered_edf.rs
Comment thread awkernel_async_lib/src/task.rs
Comment thread awkernel_async_lib/src/scheduler.rs
Comment thread awkernel_async_lib/Cargo.toml Outdated
Comment thread awkernel_lib/src/cpu.rs Outdated
ytakano and others added 6 commits July 21, 2026 13:42
Replace the per-CPU counter array NUM_CLUSTERED_TASKS_IN_QUEUE with a
single global counter plus the run queue's exact affinity mask:

- ClusteredTask::new/take/Drop now perform one atomic RMW instead of
  one per member CPU (up to 511 for wide-affinity tasks).
- wake_workers() reads the exact set of CPUs with eligible queued tasks
  via the new Scheduler::queued_cpu_mask() (default: empty), which
  ClusteredEDF implements with AffinityBTreeQueue::affinity_mask(),
  an O(1) read of the B-tree root's aggregated subtree_affinity.
- get_next_task() checks the global counter only; per-CPU eligibility
  is delegated to pop_for_cpu's O(1) root-mask fast-fail.

Replace awkernel_lib's CpuSet with a type alias of
affinity_btree_queue::CpuMask (now const-friendly in 0.1.1), removing
the to_cpu_mask() conversion and the unused from_bits()/any(). The
CPU-0-exclusion policy remains in awkernel as cpu::masked_workers()/
all_workers().

Add SchedulerType::is_clustered() and a compile-time assert that
clustered schedulers form a strict prefix of PRIORITY_LIST, so future
clustered schedulers only need to (1) wrap queue entries in
ClusteredTask, (2) implement queued_cpu_mask(), (3) join the prefix.

Note: wake_workers() now briefly acquires the run-queue mutex without
GLOBAL_WAKE_GET_MUTEX; the queue mutex remains innermost everywhere,
so no lock cycle is introduced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
When preemption is invoked, the task is pinned to the victim CPU's
preemption-pending heap and is invisible to the other CPUs in its
cpu_set, so a core that becomes idle while the IPI is in flight cannot
pick it up. Document that this window is bounded (IPI delivery plus the
victim's interrupt-disabled sections), why it is not a lost wakeup, and
the enqueue-instead-of-pin strategy as future work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Cover the awkernel-specific CPU-0-exclusion policy in masked_workers()
and all_workers(): sizes including 1 (empty), word boundaries at 64/65/
128, CPU 0 always excluded, ascending iter() ordering across words, and
the idempotence that clustered_edf::wake_task relies on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Add test_empty_set_fallback to test_clustered_edf: spawn a ClusteredEDF
task with an empty set and with an out-of-range-only set, and verify
both fall back to a worker core (never CPU 0) instead of being rejected.

Document the spawn-time normalization and fallback on the
SchedulerType::ClusteredEDF variant and in the mdbook scheduler section,
so callers know an invalid set is normalized to all workers rather than
rejected.

Verified in QEMU (-smp 4): both cases run on worker cores, all [OK].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Awkernel reserves CPU 0 as the primary core and runs tasks only on the
worker cores 1..num_cpu(); with a single CPU there is no worker core and
no task can ever be scheduled. Add cpu::sanity_check() (called from
sanity::check() on the primary CPU after set_num_cpu) that panics with a
clear message if num_cpu() < 2, making the invariant a single source of
truth instead of surfacing as a confusing downstream panic.

This makes the ClusteredEDF empty-set fallback's num_cpu()-1 message
always valid (previously "between 1 and 0" when num_cpu()==1) and its
all_workers(num_cpu()) fallback always non-empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
SchedulerType embeds a CpuSet and is large, so passing it by value made
the find_map in get_next_task copy the full payload out of each list
entry on every iteration even though only the discriminant is matched.
Take &SchedulerType instead; callers pass references (the static SCHEDULER
initializers rely on const promotion of the borrowed temporary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
ytakano and others added 3 commits July 22, 2026 12:22
The invalid-set check in wake_task is an internal-invariant assertion,
not error handling: Tasks::spawn is the single point that validates and
normalizes the CPU set, so reaching wake_task with an invalid set is an
internal bug. Use unreachable! (matching the existing unreachable!() in
the same function) with a comment naming spawn as the guarantor, so the
fail-loud intent is explicit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Bring the scheduler's core queue in-tree (like smoltcp) instead of
depending on the floating crates.io "0.1.1", which with the gitignored
Cargo.lock would resolve to any future 0.1.x of a single-author crate on
every clean build. awkernel_lib/awkernel_async_lib now use
`path = "../affinity_btree_queue"`, pinning the exact reviewed source and
removing the external fetch. Its unit tests run via a new
test_affinity_btree_queue alias wired into `make test`.

Only the library is vendored (Cargo.toml, README, src); the upstream repo
keeps the dev-only spec/reference material and remains the crates.io
source.

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>

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

The branch bundles the Rust nightly-2026-07-20 toolchain bump (#702) and its fixups (ixgbe.rs, smoltcp tcp.rs, fatfs dir_entry.rs, pvclock.rs, delay.rs, if_net.rs, Dockerfile, CI workflow), which are unrelated to CPU affinity and inflate the review surface. Consider landing #702 on main first and rebasing this PR so those changes drop out of the diff, or stating in the description that the newer nightly is a prerequisite.


The docs/ regeneration carries pages unrelated to this PR (RV32/64 mapper, page_table, interrupt_controller, memory_allocator, plus the book.toml and mdbook/src/LICENSE.md changes). Consider regenerating docs from an up-to-date main or limiting the generated-HTML changes to the scheduler pages, and moving the book.toml/LICENSE.md fix to a separate docs PR.

}
}

let task = PRIORITY_LIST

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.

When the clustered prefix above returns None, this fallback re-runs the clustered schedulers' get_next. The queue state is frozen under GLOBAL_WAKE_GET_MUTEX, so the second call deterministically returns None, at the cost of an extra queue-mutex acquisition and an O(log N) affinity probe on every get_next_task. Iterating PRIORITY_LIST[get_num_clustered_schedulers()..] here would skip the already-tried prefix and also structurally rule out a clustered task ever reaching the NUM_TASK_IN_QUEUE decrement below.


A scheduler can be implemented by implementing `Scheduler` Trait.
Each scheduler must be registered in the following three locations.
`fn get_next_task()`, `fn get_scheduler(sched_type: SchedulerType)` and `pub enum SchedulerType`.

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 PR changes the signature to fn get_scheduler(sched_type: &SchedulerType), but this line (and the function table near the top of the page) still shows the old by-value signature. Please update both mentions.


## Test Coverage

[coverage.txt](coverage.txt) contains line-by-line coverage data from `cargo llvm-cov` for the latest test run.

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 links to coverage.txt, but the file is not committed anywhere in the PR, so the link 404s on GitHub. Consider committing the file, noting it must be generated locally, or dropping the link.

ytakano added 5 commits July 23, 2026 00:39
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
@ytakano

ytakano commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@atsushi421

The branch bundles the Rust nightly-2026-07-20 toolchain bump (#702) and its fixups (ixgbe.rs, smoltcp tcp.rs, fatfs dir_entry.rs, pvclock.rs, delay.rs, if_net.rs, Dockerfile, CI workflow), which are unrelated to CPU affinity and inflate the review surface. Consider landing #702 on main first and rebasing this PR so those changes drop out of the diff, or stating in the description that the newer nightly is a prerequisite.

Thank you. Merged main.

The docs/ regeneration carries pages unrelated to this PR (RV32/64 mapper, page_table, interrupt_controller, memory_allocator, plus the book.toml and mdbook/src/LICENSE.md changes). Consider regenerating docs from an up-to-date main or limiting the generated-HTML changes to the scheduler pages, and moving the book.toml/LICENSE.md fix to a separate docs PR.

I regenerated the docs.
mdbook automatically generates mdbook/src/LICENSE.md and RV32/64's pages.
So, can you ignore generated contents for review?

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