Skip to content

sched: placement reads the cache tree, and every per-CPU table is sized for the CPUs the firmware names - #976

Merged
Japabu merged 6 commits into
mainfrom
wt/toyos-topology-2
Oct 21, 2026
Merged

Japabu merged 6 commits into
mainfrom
wt/toyos-topology-2

Conversation

@Japabu

@Japabu Japabu commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

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 738c5ab5d until #971 lands; if it lands first, that commit lands with it (see Ordering).

The problem

  • Placement saw no topology. The wake forward, the push, the steal probe and spawn placement were flat scans by dense id. sched: stage-1 host model of per-CPU job-group EEVDF and the per-CPU realtime ceiling #956 landed a shared Topology that only the models used.
  • MAX_CPUS = 8 sized 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).

  • x86. Every CPU decodes its own CPUID before it echoes: leaf 1FH (else 0BH) gives the SMT and package shifts, and the deterministic cache leaf (04H on Intel, 8000_001DH on AMD) gives the LLC. The decoder is pure, in kernel/pure/cpu.rs, beside the other CPUID decoders.
  • AArch64. The BSP places each committed GICC through the PPTT (ACPI 6.5 §5.2.30), decoded in 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.
  • Validation and boot lines. The keys are validated as a tree (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 logs topo: cpuN core= llc= pkg= and a summary line.
  • The LLC is the highest cache the CPU describes. On x86 that is the highest data or unified cache in the deterministic cache leaf, at the sharing width that leaf gives. On AArch64 it is the highest node, up to the package, owning a cache other than a declared instruction cache; a cache whose type is not declared valid still places its node. Private caches alone therefore make each core its own LLC. Only a CPU that describes no data or unified cache at all gets one LLC per package, the only shared domain it names: QEMU's qemu64 (AMD vendor without topoext, no cache leaf) and a PPTT without cache structures, QEMU virt's default.

2. Placement on those rings (kernel/pure/sched/cpu.rs).

  • Topology orders 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.
  • Wake forward and push take an idle CPU in the own LLC, then the rest of the package, then the machine. Within each group they prefer a free physical core over the SMT thread of a busy one, and rotate among equals from a per-CPU cursor (the push cursor, generalized).
  • The steal probe asks the nearest CPU with surplus: an SMT sibling, then the LLC, the package and the machine, with most surplus winning inside the first ring that has any.
  • A spawn takes the least queue, then a sleeping CPU, then the CPU whose siblings run least.

3. Every per-CPU table is sized for the CPUs the MADT enables (smp: every per-CPU table is sized …).

  • One sizing call. 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.
  • Module state moves into one record per CPU (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.
  • The roster, the shootdown rounds and the log registry are compiled by kernel-loom too. Each holds a set-once CpuSlice (kernel/src/cpu_slice.rs, also compiled there). The trace diary's 256 KiB rings are allocated a CPU at a time.
  • The log walks take their per-CPU positions from storage the caller owns. The console's, the black box's and each panel render's positions are sized at boot. Candidates are recomputed each step, so no fatal path puts a per-CPU array on its stack.
  • ABI. A cursor is a two-word head plus a position per CPU the firmware names, so a CPU that started, logged and never came online is read too. A read refuses a cursor of another width and writes the width into the head; that is how the SDK sizes one (toyos::log::cursor_words, SysCap::trace_cursor_words). The SDK cannot allocate, being a dependency of std, so LogTail and TraceCursor hold the reader's own words.
  • ABI, read buffers. A read no longer has to fit a record per CPU. The merge never needed that rule, and on a wide machine it would force a buffer that size on every reader. trace_read's guest test now asserts the width refusal in its place.
  • Removed: MAX_CPUS, MAX_LOG_SHARDS, LogCursor, the ABI TraceCursor and both bring-up arms' "roster full" branch. issues/the-kernel-brings-up-at-most-eight-cpus.md is deleted; its metal exit is the AMD row below.

Decisions and parameters, which are engineering choices rather than owner questions:

  • Locality before SMT freedom across LLCs. A forward or push stays in its own LLC, on a busy core's thread if it must, before taking a free core in another LLC. The plan makes locality a preference with no patience timer; stealing still reaches the far LLC promptly. Inside each group a free physical core wins.
  • Steal nearest-first. A steal moves one task whichever victim answers, so the nearer victim moves the same work for less refill. No random choice: the plan asks for deterministic rotation.
  • Capacity bound: 65,536. That is 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.
  • Record alignment: 128 bytes, spent once per CPU record so that neither a 64-byte line nor a prefetched pair of them spans two CPUs' records. This is an inference from x86's paired-line prefetch and 128-byte-line Arm cores; it is not measured here. Shootdown answers stay unpadded, as on main.

Ordering with #971, and OwedLock

OwedLock is untouched, and #971 already deletes it with the kernel xHCI driver and its loom model. This branch's commit 738c5ab5d deletes the one line of drivers/xhci/mod.rs that named MAX_CPUS, a const assertion. Once #971 lands, that commit has nothing left and drops in the rebase. If this lands before #971, 738c5ab5d lands 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's OwedLock contended hits try_lock_or_owe's runtime refusal (a panic naming it) instead of being owed. No bench is that wide. Its exit is #971, which deletes OwedLock with the kernel xHCI driver.

Checks

At the head, c85d91eb8 on base b48aab819 (#973), after review r1's fixes. c85d91eb8 regroups 767c1469f's commits with no tree change (git diff 767c1469f c85d91eb8 is empty); the logs below ran at 767c1469f:

  • cargo run -- --ci host: EXIT=0, 88 of 88 green.
  • Guest: desktop_without_x2apic, virt_smp and virt_failed_ap_leaves_no_hole, EXIT=0, 3 of 3.
  • Controls for r1's fixes, both red: a sizing that stores the length before its claim, and a PPTT walk that counts only caches whose type is declared valid.
  • No metal rerun, and the reasons. CpuSlice::size runs on every boot, but the claim-first change is hardware-independent: loom's cpu_slice test covers it, and so do the QEMU boots at the head. The PPTT changes are AArch64's alone. f91f2601a (61c6d5035 after 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 at dce097b4b on base d5713917d, 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).
  • Guest: desktop_without_x2apic, virt_smp (runs counters_read and the reworked trace_read on AArch64) and virt_failed_ap_leaves_no_hole: EXIT=0, 3 of 3.
  • Bring-up past the old cap (manual boots): x86 TCG --smp 16 reaches SMP: 16 of 16, 15 APs join the scheduler and logkeeper runs. AArch64 HVF --smp 12 reaches SMP: 12 of 12. The first --smp 16 boot 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.
  • Negative controls, each a checked patch applied, run and restored, and each red: the forward ignores SMT; the steal ignores distance; the PPTT ignores the thread flag; the x86 LLC is always the package; the spawn ignores idleness; the log walk stops at the first unpublished ring; the merge takes the first ring rather than the oldest record.
  • Independent oracles: the benches' scouted CPUID words (another kernel's reading) for the x86 decoder, and QEMU's own PPTT checked against its -smp command line for the PPTT decoder.

Metal, run by the orchestrator on dce097b4b's staged images: one testcases boot per bench carrying topology_readback, smp_roster_and_tsc_trail and the shared members trace_read, counters_read, inbox_log_post and disk_backtrace.

Risks

  • The scans still walk whole rings. A quiet machine's idle steal probe reads every CPU's handle, an idle-path cost that grows with the CPU count. It is derived and not measured; both benches have at most 16 CPUs. Recorded in issues/placement-has-no-migration-cost-or-wake-affinity.md (renamed from placement-is-blind-to-caches-and-topology).
  • The log merge is O(CPUs) per record now that candidates are not cached. That costs nothing measurable at bench widths; it is a console-drain throughput cost on a very wide machine.
  • Nothing here is timed on metal yet: the forward and push's group order and SMT preference have no bench measurement. The host model checks work conservation and the choice rules, not their cost.

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1: #976 scheduler stage 2, topology and roster (head f91f260, base d571391)

I read every commit, the whole diff (d5713917d..f91f2601a) and the callers involved: sync::Lock spin, blackbox::with_page, irqchip::init, parse_madt, percpu, mm/alloc and control_regs. I ran nothing. The metal results are the coordinator's: at dce097b the T14 passed 6/6 and the AMD bench 5/6 (16 of 16 CPUs online; topology 16 CPUs, 8 cores, 2 LLCs). After f91f260 both readbacks re-judge 6/6.

Architecture

The design fits the plan. CPUID decoding stays pure in kernel/pure/cpu.rs and the instruction itself stays in arch/x86_64/cpu.rs. The PPTT is decoded at the toyos-acpi boundary. One validated Topology is installed once and carried by CpuHandles, so the sim and the kernel now share one tree and one decoder. One allocated cpu_local record per CPU makes a forgotten per-CPU table impossible, which is a real simplification over 29 static arrays.

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). OwedLock is left for #971 as briefed.

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 Local structs and the ring and placement code. That is proportionate.

Landing condition (orchestrator): the branch must rebase onto main (#973, and #971 once it lands), drop 7d1fc0e5b, and rerun the host check.

Questions asked

  1. Topology sources.
    • x86 decoder. It is correct against SDM CPUID 04H/0BH/1FH and AMD 8000_001D: the (n+1).next_power_of_two() sharing width, the SMT shift from level type 1, the package shift from the last level, and the TopologyExtensions gate for 8000_001D. Both benches' bit patterns check out.
    • "No cache, so one LLC per package." This is sound as a rule for a CPU that describes no cache at all. The package is the only shared domain named; it only shapes locality preference; and refusing would break QEMU qemu64/default virt, which are supported.
    • But the rule is mis-stated in the code and the PR as "no shared cache" (blocker 2).
    • PPTT walk. It is bounded. Lengths are checked against the table (declared >= floor, at + declared <= len, resource count × 4 within the node), parents must be nodes inside the table, cycles are cut at structures + 1 steps, and every refusal is named. Good.
    • Refusing an AArch64 machine without a PPTT breaks no configuration we support today. QEMU virt has generated a PPTT since 7.0, the virt_smp and HVF --smp 12 boots show it, and there is no AArch64 metal bench. But the refusal rests on an assumption the PR itself calls unverified (blocker 4).
  2. Placement.
    • Order. The forward and the push go to the own LLC, then the package, then the machine, preferring a free core within each group and rotating by topology position. The steal goes nearest ring first, takes the most surplus within a ring, and falls through when a ring is below PUSH_THRESHOLD.
    • No starvation or lost-wake hazard found. A forward still requires SLEEPING with no kick pending, and the push still pokes and kicks exactly as before. A far LLC's surplus stays reachable: a thief falls through to it as soon as its nearer rings hold nothing above the threshold, and the far LLC's own CPUs steal it first. The shared cursor past order.len() simply restarts at the domain start.
    • Spawn. The tuple key (load, !sleeping, busy_siblings) with first-minimum and a rotating start is fine.
  3. Per-CPU records.
    • Lifetime. Records are leaked and never freed, and sizing runs before the boot CPU's per-CPU block exists. Every path that can run before sizing or on a fatal path uses get() (report_contexts, KernelPcs, try_with_cpu, irq_census::read). hardlockup::spinning_on runs only on contention, which needs a second CPU.
    • Bound. The 65,536 bound is refused by name.
    • One unsound safe API remains: CpuSlice::size (blocker 1).
  4. Cursor boundary. It is validated well:
    • The syscap is demanded first.
    • The head is copied by length and the width is checked against the kernel's own count. A mismatch writes back only the head, with shards answered.
    • Positions are copied only at the declared width, every position is held to its ring's head, and 0 is clamped to FIRST_SEQ.
    • The copy-back covers exactly head + positions.
    • Allocation size is kernel-chosen (at most 512 KiB at the bound), not caller-chosen.
  5. Costs.
    • Idle-path scans. Acceptable for this stage: they are bounded by idle_probes_spent, both benches have at most 16 CPUs, and the cost is recorded properly in issues/placement-has-no-migration-cost-or-wake-affinity.md.
    • "O(CPUs) per record" log merge. This is not new: main also scanned its candidate array per record. What changed is the constant, a stamp read and an atomic store per ring per record. It is recorded only in the PR (suggestion below).
  6. Superseded paths. Deleted cleanly. git grep 'MAX_CPUS\|MAX_LOG_SHARDS\|LogCursor\|placement-is-blind\|the-kernel-brings-up-at-most-eight' finds nothing. The deleted issue's exit is met by grep plus the AMD 16-of-16 metal row. Comments: see blockers 2–3.

f91f260 (APIC mode per bench)

Keep the per-bench table; don't compare against the machine's own CPUID.

  • The fix is correct. In 0x76d8320b the bit 20–23 nibble is d (1101), so bit 21 is clear. In the T14's 0x77fafbbf it is a (1010), so bit 21 is set. The kernel's rule (control_regs.rs:327, x2APIC iff CPUID.01H:ECX[21]) chose correctly on both, and the row was simply wrong for the AMD bench.
  • Why not CPUID on the machine itself?
    • A judge that reads the kernel's own CPUID report and checks the declaration against it restates line 327. It would pass a kernel that reads the wrong leaf or bit, or whose report and decision share one bug.
    • The scouted words are another kernel's reading of the same hardware. That makes them the independent oracle, the same one topology_readback uses.
    • The kernel does not log leaf-1 ECX today anyway, so that approach would need new production logging just to feed a test.
  • Whose job is the selection rule? It belongs to a host unit test of the pure decision, not to the metal row.
  • Suggestion: factor the three microcode_lines(log)?.first()…signature bench lookups into one bench(log) -> Result<Bench, String>.

Findings

BLOCKER

  1. kernel/src/cpu_slice.rs:242-246: CpuSlice::size is a safe fn that can cause out-of-bounds reads.
    • Problem. install stores the new len before it learns, via the swap, that a table was already installed.
    • Consequence. On a second size (the bug this assertion exists to catch), a concurrent Deref can pair the old pointer with the new length and read out of bounds before the assertion fires. A safe API must not permit that; the assertion is a fence that currently fires after the damage.
    • Fix (cheap). Claim first, with a compare_exchange on a sized/sizing state (or the pointer against a sentinel), then write len, then publish. Alternatively, publish one pointer to a leaked header holding both len and the data, so the two can never disagree.
  2. lib/acpi/src/pptt.rs:59-60 (Place doc) and lib/acpi/tests/pptt.rs a_table_that_names_no_shared_cache_puts_the_llc_at_the_package: false contract and false test name.
    • What the code does. The LLC is the highest node, up to the package, that owns a data or unified cache. With private caches only, that is the core: the test itself asserts llc: a, the leaf. Only a table describing no data/unified cache puts the LLC at the package.
    • Fix. Correct the doc and rename the test (e.g. private_caches_alone_make_each_core_its_own_llc). Add the no-cache case if wanted; an_instruction_cache_alone_is_no_llc already covers it in effect.
    • For the orchestrator. The PR body's "A CPU that describes no shared cache gets one LLC per package" has the same error, on both architectures: a CPU whose leaf 4 lists only private caches gets LLC = core.
  3. kernel/loom/tests/log_publish.rs:28-41: the W5 doc comment ("W5 — publication. … a machine that noticed.") now documents the slots helper, not a_reader_that_finds_a_shard_finds_it_built.
    • dce097b4b's message says these helpers were moved below the tests; this one was not.
    • Move slots below the tests as was done elsewhere, and delete the stray double blank line at kernel/loom/tests/counters_round.rs:38-39.
  4. kernel/src/arch/aarch64/smp.rs:41-42: the boot refusal without a PPTT rests on an uninvestigated assumption, which the PR flags itself ("not checked against the document").
    • Why it matters. It decides whether a standards-conforming Arm server boots at all.
    • Cheapest sufficient check. Read Arm DEN0044 (BBR), SBBR recipe, ACPI table requirements, and cite the clause at the panic site in a few words. My recollection, unverified, is that SBBR lists PPTT as required.
    • If it is not mandated, a missing PPTT is "firmware describes no topology" and should get the same treatment as x86's undescribed caches, not a boot refusal.

SUGGESTION

  • kernel/pure/sched/cpu.rs:915-917: the forward_target doc breaks mid-sentence ("…one an earlier forward has kicked is already being" / "handed work") after the edit. Re-wrap or shorten it.
  • lib/acpi/src/pptt.rs owns_a_cache: ACPI 6.5 §5.2.30.2 cache Flags bit 4 ("cache type valid") is not consulted. An entry whose type is not declared valid reads as type 0, data, and can become the LLC. Consider requiring the valid bit before trusting bits 3:2.
  • kernel/src/syscall/machine.rs read_on_cursor: the width and answer decision is checked only by the trace_read guest test. Root's policy puts malformed-input handling in unit tests. Extracting the head/width decision as a pure function with a host test would be cheaper and more precise.
  • The log merge's per-record cost: either state it accurately (same order as main, with a higher constant from per-ring stamp reads and Published stores) or add one line to issues/logging-records-from-every-producer-and-a-kernel-that-waits-on-nobody.md, whose stage 1 removes the merge anyway.
  • kernel/src/trace.rs size: a 256 KiB ring is allocated for every MADT-enabled CPU, including those that never come online. That is fine at bench scale. On a wide server it is 64 MiB at 256 CPUs and is worth one line where the bound is stated.

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>
@Japabu
Japabu force-pushed the wt/toyos-topology-2 branch from f91f260 to 767c146 Compare October 21, 2026 13:37
@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2: head 767c146, rebased onto main b48aab8 (#973)

Scope: the r1 fixes (767c1469f), the rebase, the metal-reuse argument, and a24875733 on its own. I ran nothing. The evidence I read is the implementer's evidence/r1/:

  • ci-host.log at 767c1469f: EXIT=0, 88/88. It runs cpu_slice in the kernel-loom step and tests/pptt.rs.
  • guest.log at 767c1469f: EXIT=0, 3/3 (desktop_without_x2apic, virt_smp, virt_failed_ap_leaves_no_hole).
  • controls-3.log: m8 and m9, each EXIT=101 on its own test. They ran at the pre-rebase 36000e3fb, but git diff 36000e3fb 767c1469f -- kernel/src/cpu_slice.rs lib/acpi kernel/loom/tests/cpu_slice.rs is empty, so they hold for the head.

r1 blockers

  1. CpuSlice::size: resolved. The claim is a swap on claimed before anything is allocated or stored. Then len is stored, then ptr with Release, and a reader's Acquire on ptr sees len. A second or concurrent sizing panics before it touches either word. The API is now sound.
    • The test asserts exactly the property that was broken: the first table is intact after a refused second sizing. Its control m8 restores the old order and goes red.
  2. PPTT doc and test name: resolved.
    • Place's doc, owns_a_cache's doc and the test names now state what the code does. Private caches alone make each core its own LLC; a processor with no data or unified cache gets the package.
    • The flags handling is sound against ACPI 6.5 §5.2.30.2: only a cache that declares a valid type of instruction is passed over, and one of undeclared type still counts as a cache. The flags word is read only after structure() has accepted a 24-byte cache, so it is in bounds.
    • The new test has control m9. The QEMU fixture test still passes in ci-host.log.
  3. Loom doc layout: resolved. W5's comment is back on a_reader_that_finds_a_shard_finds_it_built, slots sits below the tests, and the blank line is gone.
  4. No-PPTT refusal: resolved. It now cites Arm BBR 2.2 (DEN0044I) §3.1 and §7.3.1.10, read by the implementer from Arm's PDF (sha256 recorded). With the PPTT mandatory under SBBR, refusing a machine without one is refusing nonconforming firmware by name, and it stays. I did not reread the PDF myself.

Also taken: the trace ring's per-CPU cost is stated at RINGS, and the merge's per-record cost is recorded in the logging track that removes it. Both are accurate.

Rebase onto #973

#973 is IPC: messages carry their handles. It overlaps this branch textually in three files: kernel/src/syscall/dispatch.rs, sdk/abi/src/syscall.rs and system/logkeeper/src/main.rs. In each, this branch's hunks (SYS_COUNTERS's bound, log_read and trace_read's cursor, logkeeper's cursor sizing) are separate from #973's message changes and share no state with them. The host and guest runs at 767c1469f cover the merged result.

The metal-reuse argument

Reuse holds, but not for the reason the handoff gives. "No boot path a bench takes changed" is not accurate: CpuSlice::size runs on every boot, for every per-CPU table. Here is why the dce097b metal still stands:

  • f91f260 changed only the judge, and both readbacks were re-judged 6/6.
  • CpuSlice::size now orders a claim before the same two stores. That is hardware-independent logic: the loom-crate test establishes it, and the QEMU boots at the head complete it on both architectures. Metal adds nothing.
  • The PPTT change reaches only AArch64, which has no metal bench.
  • Everything else is comments, tests and issues.
  • ipc: a frame and its handles travel as one message; the standalone handle send is retired #973's own changes are main's, and they land under main's evidence. This branch shares no mechanism with them.

The orchestrator should correct the reason in the PR's evidence record. That is not a code finding.

a248757 alone

The code is harmless. Before 22ace70a5 it deletes only a const assertion that MAX_CPUS = 8 already satisfies, so it builds and changes no behaviour. If #976 lands before #971, OwedLock::try_lock_or_owe keeps its runtime expect past bit 63. A machine of more than 64 CPUs whose kernel xHCI lock is contended would then panic, where main runs such a machine on 8 CPUs. No bench is that wide. The gap exists only until #971 deletes OwedLock, but it should not be silent (see below).

BLOCKER

  1. a24875733 (commit layout): the rebase moved the deletion of issues/the-kernel-brings-up-at-most-eight-cpus.md into the xhci commit. git show a24875733 --stat shows the issue file plus drivers/xhci/mod.rs, while its message names only the xhci line, and 22ace70a5, whose message claims the deletion, no longer contains it. This matters:
    • The plan is to drop a24875733 when rebasing over usbd drives every xHCI controller; the kernel drives no USB #971. Dropping it would silently resurrect a closed defect record.
    • Main takes merge commits, so the mislabelled commit would also be permanent history.
    • Fix: move the deletion back into 22ace70a5, so that a24875733 touches kernel/src/drivers/xhci/mod.rs alone.

Landing condition (orchestrator): if #976 lands before #971, record the >64-CPU OwedLock panic, with its exit (#971), in #971's description or an issue, so the gap is not silent.

SUGGESTION

  • kernel/pure/sched/cpu.rs:915-918: the forward_target doc is whole now, but one line runs to about 150 columns ("…drained in one pass would otherwise all land on it. Reading the doorbells is a heuristic: a CPU that woke up"). Re-wrap it.

SEND BACK (one blocker: the commit layout of the issue deletion; the code fixes are all resolved)

Japabu and others added 5 commits October 21, 2026 15:40
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>
@Japabu
Japabu force-pushed the wt/toyos-topology-2 branch from 767c146 to c85d91e Compare October 21, 2026 13:40
@Japabu
Japabu marked this pull request as ready for review October 21, 2026 13:41
@Japabu
Japabu added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit e74dbf1 Oct 21, 2026
8 checks passed
@Japabu
Japabu deleted the wt/toyos-topology-2 branch October 21, 2026 14:04
Japabu added a commit that referenced this pull request Oct 21, 2026
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>
github-merge-queue Bot pushed a commit that referenced this pull request Oct 21, 2026
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)
Japabu added a commit that referenced this pull request Oct 22, 2026
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
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.

1 participant