feat(sched): add CPU core affinity via ClusteredEDF scheduler#701
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.
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. |
- get_next_task: skip the clustered prefix in the fallback loop so it is never re-run (state is frozen under GLOBAL_WAKE_GET_MUTEX) and the NUM_TASK_IN_QUEUE decrement structurally never applies to a clustered task - mdbook: update get_scheduler signature to &SchedulerType (2 mentions) - affinity_btree_queue/README.md: drop dead coverage.txt link, note that coverage data is generated locally with cargo llvm-cov Signed-off-by: Yuuki Takano <ytakanoster@gmail.com>
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.