feat(sched): add CPU core affinity via ClusteredEDF scheduler#701
feat(sched): add CPU core affinity via ClusteredEDF scheduler#701ytakano wants to merge 21 commits into
Conversation
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>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
There was a problem hiding this comment.
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 newclustered_edfscheduler implementation. - Rename and update the userland/kernel test feature and test crate from
test_partitioned_edftotest_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.
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>
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>
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
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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`. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
Thank you. Merged main.
I regenerated the docs. |
Description
Generalizes the
PartitionedEDFscheduler — which pinned each task to a single core — into aClusteredEDFscheduler where each task carries aCpuSetdescribing the set of cores it may run on. Pinning to a single core is now the special case of a one-bitCpuSet.Key changes:
[Mutex<EDFData>; NUM_MAX_CPU]BinaryHeaparray with a single affinity-aware priority queue from theaffinity_btree_queuecrate, vendored in-tree as a workspace member (likesmoltcp).get_nextpops the earliest-deadline task runnable on the calling CPU viapop_for_cpu. Priority is(absolute_deadline, wake_time), matching the previous EDF ordering, with FIFO tie-breaking on fully-equal keys.CpuSet:awkernel_lib'sCpuSetis now a type alias ofaffinity_btree_queue::CpuMask<CPU_SET_WORDS>(512 CPUs), so schedulers pass it to the run queue with no conversion. All constructors areconst fn(chainable builderCpuSet::empty().with(1).with(2)), and itsiter()is O(popcount). The CPU-0-exclusion policy stays inawkernel_libas the const free functionscpu::masked_workers/cpu::all_workers.SchedulerType::PartitionedEDF(u64, u16)→ClusteredEDF(u64, CpuSet);Task.partitioned_core: Option<u16>→Task.cpu_set: Option<CpuSet>.spawnmasks 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 theSchedulerType::ClusteredEDFvariant and in the mdbook.NUM_CLUSTERED_TASKS_IN_QUEUEcounter array is replaced by a single globalAtomicU32plus the run queue's exact affinity mask.ClusteredTask::new/take/Dropnow do one atomic RMW instead of one per member CPU (up to 511 for a wide-affinity task).wake_workersreads the precise set of CPUs with an eligible queued task via the newScheduler::queued_cpu_mask()trait method (default empty; ClusteredEDF implements it with the queue's O(1)affinity_mask()).ClusteredTask, (2) implementqueued_cpu_mask(), and (3) sit in the clustered prefix ofPRIORITY_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.cpu::sanity_check()(run on the primary CPU) panics ifnum_cpu() < 2. Awkernel reserves CPU 0 as the primary core and runs tasks only on the worker cores1..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.invoke_preemptionselects the core running the lowest-priority task among the set, and skips preemption entirely (enqueue only) when any core in the set is idle.get_scheduler/get_prioritytake&SchedulerTypeso thefind_mapinget_next_taskno longer copies the large (CpuSet-embedding)SchedulerTypeper iteration.PartitionedTask→ClusteredTask,NUM_PARTITIONED_TASKS_IN_QUEUE→NUM_CLUSTERED_TASKS_IN_QUEUE,get_num_partitioned_schedulers→get_num_clustered_schedulers, test cratetest_partitioned_edf→test_clustered_edf.Related links
affinity_btree_queue/in this repo.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),cpu.rsunit tests for themasked_workers/all_workersworker-mask policy (word boundaries 64/65/128, CPU 0 exclusion, cross-worditerordering),test_affinity_btree_queuecrate tests (61 unit + 17 doc).make x86_64 RELEASE=1— builds successfully.-smp 4) with thetest_clustered_edffeature — all[OK], no panic:CpuSettasks ran only on their assigned core (cores 1–3).CpuSet {1, 2}only ran on cores 1 or 2.light(earlier deadline) correctly preemptedheavyon the same core.Notes for reviewers
SchedulerTypegrows from 16 bytes to ~72 bytes (stillCopy) because it embeds aCpuSet. It is passed by reference on the hotget_next_taskpath (get_scheduler/get_prioritytake&SchedulerType) and only by value on thespawnpath.get_next_task(false)path (RR preemption tick, primary CPU only) can never dequeue a clustered task becausespawnalways removes CPU 0 from every set — same invariant as before, noted inclustered_edf::get_next.Mutexis the innermost lock everywhere (nothing else is acquired while holding it). It is taken underGLOBAL_WAKE_GET_MUTEXinwake_task/get_next_task, andwake_workersnow also takes it directly (withoutGLOBAL_WAKE_GET_MUTEX) to readqueued_cpu_mask(); since it is innermost, no lock cycle is introduced.invoke_preemptionstill runs before the queue lock is taken.num_cpu()on first use (AffinityBTreeQueue::newis notconst), following the existing pattern inprioritized_rr.rs.set_num_cpuruns before the firstspawn.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.mdis updated to describe ClusteredEDF, the affinity-aware B-tree queue, the global-counter +queued_cpu_maskbookkeeping, and the spawn-time normalization/fallback.