Repository navigation
sched: placement reads the cache tree, and every per-CPU table is sized for the CPUs the firmware names - #976
Conversation
Review r1: #976 scheduler stage 2, topology and roster (head f91f260, base d571391)I read every commit, the whole diff ( ArchitectureThe design fits the plan. CPUID decoding stays pure in The ABI cursor change is the right one. The kernel holds no per-reader state, the width is refused and answered at the boundary, and the record-per-CPU buffer rule was never needed by the merge. Nothing here touches J1/stage 3 (no job groups, no cross-CPU service accounting). Net change: about +1,200 production lines (including in-file unit tests in the pure modules), +309 test lines, and issues −31. The production growth is mostly the PPTT decoder, the per-CPU Landing condition (orchestrator): the branch must rebase onto main (#973, and #971 once it lands), drop Questions asked
f91f260 (APIC mode per bench)Keep the per-bench table; don't compare against the machine's own CPUID.
FindingsBLOCKER
SUGGESTION
SEND BACK 🤖 Generated with Claude Code |
Each CPU's place in the cache tree now comes from what the platform says of it: on x86 every CPU decodes its own CPUID topology leaves (1FH or 0BH for its core and package, the deterministic cache leaf for its LLC) before it echoes, and on AArch64 the boot CPU places every committed GICC through the PPTT (ACPI 6.5 §5.2.30), which toyos-acpi now decodes. Bring-up installs the validated `Topology` once, logs one `topo:` line per CPU, and the scheduler's `CpuHandles` carries it. `Topology` orders its CPUs so each core, LLC and package is one run, and answers rings of CPUs at a given distance from any CPU whatever the dense ids. On those rings: - a wake forward and a push take an idle CPU in the own LLC before the package and the machine, and within each a free physical core before the SMT thread of a busy one, rotating among equals from a per-CPU cursor; - a steal probe asks the nearest CPU with surplus worth a probe: an SMT sibling, then the LLC, the package and the machine; - a spawn takes the least queue, then a sleeping CPU, then the one whose SMT siblings run least. A CPU that describes no shared cache (QEMU's qemu64, a PPTT without cache structures) is given one LLC per package, the only shared domain it names; leaves or tables that place no CPU stop the boot by name. The simulator's scout oracle now decodes the benches' recorded CPUID words through the kernel's own decoder, and the placement track is renamed for what it still lacks. A metal row, `topology_readback`, holds each bench's `topo:` lines to its scouted tree on the testcases boot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f91f260 to
767c146
Compare
Review r2: head 767c146, rebased onto main b48aab8 (#973)Scope: the r1 fixes (
r1 blockers
Also taken: the trace ring's per-CPU cost is stated at Rebase onto #973#973 is IPC: messages carry their handles. It overlaps this branch textually in three files: The metal-reuse argumentReuse holds, but not for the reason the handoff gives. "No boot path a bench takes changed" is not accurate:
The orchestrator should correct the reason in the PR's evidence record. That is not a code finding. a248757 aloneThe code is harmless. Before BLOCKER
Landing condition (orchestrator): if #976 lands before #971, record the >64-CPU SUGGESTION
SEND BACK (one blocker: the commit layout of the issue deletion; the code fixes are all resolved) |
The next commit deletes `MAX_CPUS`, which this assertion names. The owed word's own runtime bound stays: `OwedLock::try_lock_or_owe` refuses a cpu id past bit 63 by name. PR #971 deletes the kernel xHCI driver and `OwedLock` with it; once it lands this commit has nothing left to change and goes in the rebase onto it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The kernel brought up at most eight CPUs: `MAX_CPUS = 8` sized 29 static arrays, and the log and trace cursors carried the same width in the ABI. The AMD bench ran 8 of its 16. The count is now the MADT's: before the boot CPU's own per-CPU block exists, `smp::size` sizes every table once for the processors the MADT enables, online or not, and nothing is freed, so no fatal path allocates. - Every module's per-CPU state is a part of one record per CPU (`cpu_local`), each allocated apart and aligned so no two CPUs' records share a line; a forgotten table cannot exist. The scheduler's pass slot, the hard-lockup sampler, the masking windows and the rest move there. - The roster, the shootdown rounds and the log registry are compiled for loom too, so each holds a set-once `CpuSlice` of its own; the trace diary's 256 KiB rings are allocated a CPU at a time. - The log's walks keep a position per CPU in storage their caller owns: the console's, the black box's and each panel render's are sized at boot, so neither walk puts a per-CPU array on a fatal path's stack. A candidate is found afresh each step rather than cached per CPU. - The ABI cursor is a two-word head and a position per CPU the firmware names: a CPU that started, logged and never came online is read too. A read refuses a cursor of another width and answers the width in the head, which is how the SDK sizes one. A read buffer no longer has to hold a record per CPU: the merge never needed it, and a wide machine would have forced it on every reader. - `SYS_COUNTERS` is bounded by the capacity, and bring-up walks the MADT's list with no "roster full" arm. The one bound left is the trace record's 16-bit CPU field, refused by name at sizing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e panel's does `CpuSlice` hands out `len`, `get` and indexing by derefing to the slice it installed rather than restating them, which also gives clippy the `is_empty` its `len` asks for. The cursor's words decode through `as_chunks`. The loom models' sizing helpers move below the tests whose doc comments they had split from their functions. `topology_readback` tells the bench by `microcode_lines`, and the two bench signatures move beside it so `gop_edid_scale` and it name the same constants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Us offer The row demanded x2APIC of every bench, which is the T14's alone. The kernel takes x2APIC exactly where CPUID.01H:ECX[21] offers it, and the AMD bench's Ryzen 7 4800U does not: every CPU its scout read reports leaf 1 ECX = 0x76d8320b, bit 21 clear, against the T14's 0x77fafbbf. So the AMD bench declared xAPIC as it should, and the row failed on the expectation alone; main's own AMD readback for #974 says the same. The row now takes the mode from the bench, told by its signature as the topology and panel rows are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e as it is - `CpuSlice::size` claims the table before it writes anything, so a second sizing panics with the first table and its length untouched; storing the length first let a reader pair the old pointer with the new length. Held by `kernel-loom`'s `cpu_slice` test, which the host check runs. - The PPTT's rule, stated as the code has it: the LLC is the highest node up to the package owning a cache other than a declared instruction cache, so private caches alone make each core its own LLC and only a table giving a processor no data or unified cache puts it at the package. The test that said otherwise is renamed for what it holds. A cache whose type is not declared valid (flags bit 4) still places its node; only one declaring itself an instruction cache is passed over. - The PPTT refusal on AArch64 cites what requires the table: Arm BBR 2.2 (DEN0044I) §3.1 and §7.3.1.10, the SBBR recipe's mandatory tables. - `log_publish`'s W5 comment is back on its test; stray blank lines go; `forward_target`'s doc reads whole; the trace ring's per-CPU cost is stated where the rings are sized, and the log merge's per-record cost in the logging track that removes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
767c146 to
c85d91e
Compare
Kept this branch's deletions of the kernel xHCI, irq_ring, log recovery and the scheduler's Operation framework, and with them #976's per-CPU fields for irq_ring and the no-task operation slot. fileserver takes #975's descriptions without the partition claims this branch removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Implements the owner's decision (Jan, 2026-10-21, claude-questions item 10): small pipe writes are indivisible as ordinary ToyOS pipe behaviour. Closes `issues/a-pipe-write-of-a-few-bytes-can-tear-between-two-writers.md`. ## Problem `pipe::try_write` wrote `min(len, free space)`. Whenever the reader lagged, a 40-byte line could split, and another writer's bytes could land between its two parts. Pipe writers are separate processes that share no lock, so only the pipe can keep a write whole. ## Resulting behaviour - **The bound.** A pipe write of at most `MAX_WHOLE_PIPE_WRITE` = 4096 bytes (`toyos-abi`) lands whole or waits until there is room for all of it. The non-blocking form lands whole or answers `WouldBlock`. Longer writes still land as far as they fit, as before. libc declares the bound as `PIPE_BUF` 4096 in `limits.h`, and a libc host test (`pipe_buf_is_the_kernels_whole_pipe_write`) holds the two equal. A compile-time assertion in `kernel/src/pipe.rs` keeps the bound within the ring (2 MiB less its header). - **One write function.** `kernel::pipe::Landing` (`kernel/pure/pipe.rs`) says how a write lands: `Whole` (a message), `Pipe` (whole up to the bound) or `Partial` (a connection's plain write). Its `room(len)` is the room the write waits for, and `takes(space, len)` is what it writes; both come from the same comparison. `crate::pipe::write(id, buf, landing)` replaces both `try_write` and `write_whole`. #973's message send is now the same write with `Landing::Whole`, so the whole-write mechanism is reused rather than duplicated. - **Size-aware waiting.** `sys_write` waits on `has_space(id, landing.room(len))` instead of one byte. A waiting writer wakes only when its write will land. Every read and the reader's departure still post the write watch, which wakes every waiter to check its own condition again. No new wake path exists, and none is missing. - **Readiness.** A pipe's write end polls writable only when `MAX_WHOLE_PIPE_WRITE` bytes are free. A non-blocking write that was refused and is retried on readiness therefore lands instead of spinning. - **Empty writes wait for nothing.** `Holders::writable` no longer raises the room it wants to one byte. A zero-byte write to a full pipe or connection answers 0 (or `Gone` with no reader) instead of parking. - **Unchanged.** Byte counts, `Gone` / `ResourceExhausted` errors, files, consoles and devices, and the connection's plain write (`Landing::Partial`) behave as before. ## Decisions - **Connections are excluded.** The approval covers pipes, not sockets, so a connection's plain write stays partial and its writability stays one byte. Messages on a connection were already whole. Extending `Landing::Pipe` to connections is a one-line change if the owner wants it. - **4096 bytes.** That is the common `PIPE_BUF`, above POSIX's 512 minimum. It is far below the 2 MiB ring, so a whole write always fits eventually, and readiness costs little. - **No guest test** (review r1 agrees). The behaviour lives in the pure `Landing`/`Holders` functions, which the host tests exercise. The kernel glue around them is three call sites, held by inspection and not by any test or control: `sys_write` passes `room`, `ops::has_space` passes the bound, and `sys_write_nonblock` already maps a refused write to `WouldBlock`. The QEMU smoke run shows the write path is not regressed. It does not establish that the size-aware wait or readiness behave as designed; those rest on inspection plus the host tests. - **Declined review suggestions:** S1 (`Landing::ready_room()`) would need a meaningless arm for `Whole`, which no object polls; S2 (renaming `Landing::Pipe`) is kept as is, since its doc states the behaviour. ## Checks Round 2 (head `1fcbfd9d2`, rebased onto `e74dbf183`, after #975 and #976). Review r1's fixes: stale `pipe::try_write` names fixed in `kernel/loom/tests/inbox_answer.rs` and `issues/the-global-pipe-lock-spans-a-user-copy.md`; the `PIPE_BUF` check; and one clause in `kernel/pure/pipe.rs`'s header. | check | exit | |---|---| | `cargo run -- --ci host` | 0 (88 steps) | | `cargo run -- --build-only` (x86_64), `--arch aarch64` | 0, 0 | | control c7: `PIPE_BUF` set to 512, `cargo test -p toyos-libc-copies pipe_buf` | 101; restored 0 | Round 1 at `584d4f246` (the pipe code is unchanged since, apart from a module comment, so its results carry over): | check | exit | |---|---| | `cargo run -- --ci host` | 0 (88 steps) | | `cargo run -- --build-only` (x86_64), `--arch aarch64` | 0, 0 | | `cargo test -p kernel --lib --features sched-check pipe::` | 0 (12 tests, 7 new) | | QEMU x86_64 `console_image_boots libc_children` (stdio pipes, `spawn_pipe_eof`, `spawn_pipe_reader_gone`, `popen`) | 0 (2/2) | | negative controls c1–c6, each applied, built, run and reverted | each builds (0) and reds (101); restored 0 | New host tests in `kernel/pure/pipe.rs` model the pipe's write, wait condition and poll readiness over the real `Landing` and `Holders`: - whole-or-wait at every amount of room, with a long write still partial; - a refused non-blocking write is answered by readiness, and the retry lands; - empty writes; - the reader leaving answers a waiting writer, and the write is refused; - wake-iff-lands, checked exhaustively over room, length and landing; - three writers with a lagging reader: 1042 writes are refused for room, every record arrives intact and every writer finishes. The same competing run under `Partial` tears, which is the test's own control. Controls: - c1 is the old partial landing. - c2 is the old `want.max(1)`. - c3 is a `writable` that ignores the reader. - c4 mutates `Holders::writable` to want at most one byte. Its effect equals the old one-byte wait (a busy wake), but it does not mutate the `sys_write` glue. - c5 wants one byte more than needed (a missed wake). - c6 mutates only the test model's poll, to one byte. It shows the test's sensitivity to a busy retry, not a product property. No control can catch a revert of the `io.rs` `room` argument or the `ops.rs` bound; those are held by inspection. Red test names are in `controls/summary.txt`. Evidence (masked): round 2 https://github.com/ToyOSOrg/evidence/tree/afbfa442ca959f2a73f339b8dfb6c62189410a3c/r2, round 1 https://github.com/ToyOSOrg/evidence/tree/afbfa442ca959f2a73f339b8dfb6c62189410a3c/r1 ## Risks - **No order among waiting writers.** A whole write of up to 4096 bytes can keep losing freed room to smaller writes that fit first. Starving it would require the ring to stay within 4 KiB of full under continuous competing writes. This is not measured, and POSIX promises no fairness here either. - **Writability now needs 4 KiB free rather than one byte.** A poller that would have written a few bytes into a nearly full pipe now waits until 4 KiB is free. No in-tree program relies on writing to a pipe that is almost full. - **The glue is held by inspection only.** The three kernel call sites have no test or control; the QEMU smoke run shows only that the write path is not regressed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Main's benchcase and hostedclangcase came with nvme and usbd rows and no `acpi` claim holder, so on this branch their first claim would wait out its deadline and every claim of the boot would be refused. Both start acpiserver now, and a build host test holds the rule for every config: one whose rows claim a PCI function starts a row holding the `acpi` claim, undeclaredcase alone excepted, since it is booted to judge that refusal. Issues directly affected by the loader's window reads going: - the GCD map's held MMIO descriptors: deleted, since the loader reads no GCD map any more; - the hypervisor and ACPI tracks and the BarUnrouted tooling issue name `_CRS` where they named the loader's reads; - the x86 no-acpi-row issue names the disk drivers, which main's block service split made the claimants; - new: the ACPI server's death has no readback on the T14, since its boot's first `acpi` holder is a job and no row declares before usbd's claim. Issues that named closed drafts as owners: the four scheduler findings #872's S2 and S3 were to settle are the placement track's, which owns the push, the victim choice and wake placement; the speed track's line naming #865 as the CPU cap's owner goes, since #976 sized every per-CPU table for the CPUs firmware names; the ACPI track's `_CST` line keeps its issue and drops #870. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Scheduler stage 2, topology and roster: placement reads the machine's cache tree, and the kernel then brings up every CPU the firmware names instead of at most eight.
Draft. Rebased onto main at #973. It carries
738c5ab5duntil #971 lands; if it lands first, that commit lands with it (see Ordering).The problem
Topologythat only the models used.MAX_CPUS = 8sized 29 static arrays, and the ABI's log/trace cursor carried the same width. The AMD bench ran 8 of its 16 CPUs (SMP: roster full at 8 CPUs). The owner ruled topology first, so the 16 CPUs across two CCXs don't land on a scheduler blind to them.What changes
1. Each CPU's place in the tree comes from what the platform says of it (
sched: placement reads the machine's cache tree).kernel/pure/cpu.rs, beside the other CPUID decoders.toyos-acpi. A thread leaf's core is its parent node, the LLC is the highest node up to the package that owns a data or unified cache, and the package is the nearest physical-package ancestor.Topology::new); leaves or tables that place no CPU stop the boot by name. An AArch64 machine without a PPTT is refused: Arm BBR 2.2 (DEN0044I) §3.1 and §7.3.1.10 make the PPTT mandatory under the SBBR recipe, the ACPI one, and the clause is cited at the refusal. Bring-up logstopo: cpuN core= llc= pkg=and a summary line.qemu64(AMD vendor without topoext, no cache leaf) and a PPTT without cache structures, QEMUvirt's default.2. Placement on those rings (
kernel/pure/sched/cpu.rs).Topologyorders CPUs so every core, LLC and package is one run, and answers rings: the CPUs at exactly one level from a CPU, whatever the dense ids.3. Every per-CPU table is sized for the CPUs the MADT enables (
smp: every per-CPU table is sized …).smp::size(capacity)runs once, before the BSP's per-CPU block exists. Capacity is the MADT's enabled processor count, online or not. Nothing is freed, so no fatal path allocates.kernel/src/cpu_local.rs): scheduler slot, hard-lockup words, masking windows, IRQ ring and census, counters, the deadline's PC and the x86 switch shadow. Each record is allocated apart and aligned to 128 bytes, so a forgotten table cannot exist and no two CPUs share a line.kernel-loomtoo. Each holds a set-onceCpuSlice(kernel/src/cpu_slice.rs, also compiled there). The trace diary's 256 KiB rings are allocated a CPU at a time.toyos::log::cursor_words,SysCap::trace_cursor_words). The SDK cannot allocate, being a dependency of std, soLogTailandTraceCursorhold the reader's own words.trace_read's guest test now asserts the width refusal in its place.MAX_CPUS,MAX_LOG_SHARDS,LogCursor, the ABITraceCursorand both bring-up arms' "roster full" branch.issues/the-kernel-brings-up-at-most-eight-cpus.mdis deleted; its metal exit is the AMD row below.Decisions and parameters, which are engineering choices rather than owner questions:
TraceRecord::cpu's 16 bits, refused by name at sizing. The contiguous tables are at most 16 bytes per CPU (1 MiB at that bound), below the heap's 2 MiB allocation limit. Records and rings are allocated per CPU.Ordering with #971, and OwedLock
OwedLockis untouched, and #971 already deletes it with the kernel xHCI driver and its loom model. This branch's commit738c5ab5ddeletes the one line ofdrivers/xhci/mod.rsthat namedMAX_CPUS, a const assertion. Once #971 lands, that commit has nothing left and drops in the rebase. If this lands before #971,738c5ab5dlands with it, and one gap stands until #971: on a machine of more than 64 CPUs, a CPU past id 63 that finds the kernel xHCI'sOwedLockcontended hitstry_lock_or_owe's runtime refusal (a panic naming it) instead of being owed. No bench is that wide. Its exit is #971, which deletesOwedLockwith the kernel xHCI driver.Checks
At the head,
c85d91eb8on baseb48aab819(#973), after review r1's fixes.c85d91eb8regroups767c1469f's commits with no tree change (git diff 767c1469f c85d91eb8is empty); the logs below ran at767c1469f:cargo run -- --ci host: EXIT=0, 88 of 88 green.desktop_without_x2apic,virt_smpandvirt_failed_ap_leaves_no_hole, EXIT=0, 3 of 3.CpuSlice::sizeruns on every boot, but the claim-first change is hardware-independent: loom'scpu_slicetest covers it, and so do the QEMU boots at the head. The PPTT changes are AArch64's alone.f91f2601a(61c6d5035after the rebase) changed only the judge, and both benches' readbacks re-judge 6 of 6 under it. ipc: a frame and its handles travel as one message; the standalone handle send is retired #973 is main's own, with its own evidence.Earlier rounds (whole logs, commands and exit codes indexed by
README.pr-976) ran atdce097b4bon based5713917d, before the rebase:cargo run -- --ci host: EXIT=0, 88 of 88 green (sim sweep at the T14, the AMD bench, 4×8×2 and the 768-CPU tree, I15 included; the PPTT, CPUID, topology, placement and log-walk tests; clippy with every instrument feature on both architectures).desktop_without_x2apic,virt_smp(runscounters_readand the reworkedtrace_readon AArch64) andvirt_failed_ap_leaves_no_hole: EXIT=0, 3 of 3.--smp 16reachesSMP: 16 of 16, 15 APs join the scheduler and logkeeper runs. AArch64 HVF--smp 12reachesSMP: 12 of 12. The first--smp 16boot panicked: the trace table was one 4 MiB allocation past the heap's 2 MiB limit. That is why records and rings are now allocated per CPU.-smpcommand line for the PPTT decoder.Metal, run by the orchestrator on
dce097b4b's staged images: one testcases boot per bench carryingtopology_readback,smp_roster_and_tsc_trailand the shared memberstrace_read,counters_read,inbox_log_postanddisk_backtrace.topology_readbackreads 8 CPUs in 4 cores and 1 LLC, its scouted tree.dce097b4b's rows; the cap lift and the tree hold. It readsSMP: 16 of 16 MADT cpus onlinewith 15 of 15 APs inside the TSC bracket (main read 8 of 16 on The metal suite targets a second bench: --metal-host, every machine's record in the host's store, the home read off the machine, and the stick read off the record #852).topology_readbackreads 16 CPUs in 8 cores and 2 LLCs.smp_roster_and_tsc_traildemanded x2APIC of every bench, which only the T14 offers. Every AMD CPU reportsCPUID.01H:ECX[21]clear (leaf-1 ECX0x76d8320bin its scout, against the T14's0x77fafbbf), and the kernel takes x2APIC exactly where that bit offers it. Main's own AMD readback for iommu: lowest-first device addresses below each claim's stated limit; a claim masters only when its holder starts it #974 says the same. AMD's PPR (55922) could not be downloaded here, so the fact rests on the CPU's own CPUID word.61c6d5035(f91f2601abefore the rebase): the row now expects the mode each bench offers, told by its signature as the topology and panel rows are. Re-judging both readbacks with no machine touched: EXIT=0 and 6 of 6 on each (T14).Risks
issues/placement-has-no-migration-cost-or-wake-affinity.md(renamed fromplacement-is-blind-to-caches-and-topology).🤖 Generated with Claude Code