xv6, line by line
lab 18

Extension labs · lab 18 · Concurrency · ★★★☆☆

Per-CPU free lists in kalloc

In this tree every page of physical memory is handed out from one list, kmem.freelist, under one spinlock, kmem.lock. Every kalloc and every kfree on every hart takes that lock, even when the three harts are working on entirely different pages for entirely different processes. With three processes growing and shrinking their memory at the same time, one acquisition in sixteen finds the lock already held and spins.

In this lab you give each CPU its own free list and its own lock. A CPU frees onto its own list and allocates from it, so in the common case no two harts ever touch the same lock. The idea is simple; the details are a short course in what “per-CPU” really means. How does code know which CPU it is on, and for how long is that answer true? What does a CPU do when its own list is empty, and what new locking between harts does that bring back? You will answer these questions yourself, then break the answers on purpose and record what happens, one of them with gdb.

The reference solution is five small commits. On three harts it cuts the contended acquisitions of the allocator’s locks during a stress test from about 47,000 to under 10, and you will see where memory ends up when nobody puts it back where it came from.

Read first: Tour 11: From a timer tick to a context switch, Tour 15: Spinlocks from the hardware up, Tour 18: Lock ordering: how xv6 avoids deadlock, Tour 26: sbrk, eager and lazy, and page faults, Tour 27: The physical page allocator, Tour 50: noff and intena through a sleep, a yield and an interrupt · Locks and interrupt state, The stacks of xv6

What this lab teaches

  • What cpuid() really returns, and what can make that answer stale before the code has finished using it.
  • How to keep a CPU number valid in a function that may be called with interrupts on, with interrupts off, or with other spinlocks held, and what noff and intena look like in the allocator from a system call, a page fault and kwait.
  • Whether data that is per-CPU still needs a lock, and what a stale CPU number actually costs: measured on a real run.
  • What new lock order question appears when one CPU must take pages from another, and what a violation looks like, recorded with gdb.
  • How much to take from another CPU, and from whom: what different choices cost in lock traffic and in lock hold time.
  • Where free memory ends up when nothing sends it back, and what that does to the meaning of a kalloc that returns 0.
  • How to measure lock contention (failed amoswap attempts per lock name) and why timings under QEMU must be read with care.

The reference branch

ext/18-percpu-kalloc in ShowMeTheStack/xv6-riscv-labs, branched from the frozen commit 06aad25; 5 commits.

git clone https://github.com/ShowMeTheStack/xv6-riscv-labs
cd xv6-riscv-labs
git checkout -b my-percpu-kalloc 06aad25   # start your own
git diff 06aad25 origin/ext/18-percpu-kalloc   # only when you want the answer

1. The spec

Behaviour. The allocator keeps one free list per possible CPU (NCPU = 8), each with its own spinlock, named kmem0 to kmem7 so that a contention counter can tell them apart.

Constraints. No two harts may ever deadlock in the allocator, whatever they are doing. The CPU number must not go stale while the code uses it. Nothing outside kalloc.c (and the one Ctrl-P line in procdump) changes: every caller of kalloc and kfree keeps working as it is. usertests -q must print ALL TESTS PASSED on 3 harts.

The test program, kalloctest, prints one line per check (and four lines of information):

$ kalloctest
kalloctest: churn: 3 processes x 2000 rounds x 64 pages, 15 ticks
kalloctest: churn: OK
kalloctest: one process got 32472 free pages, 32472 at start
kalloctest: gather: OK
kalloctest: exhaust: 3 processes got 32378 of 32472 free pages
kalloctest: exhaust: OK
kalloctest: free pages 32472 before, 32472 after
kalloctest: no leaks: OK
kalloctest: ALL OK

kalloctest passes on the original allocator too. This lab changes how fast the allocator is under contention, not what it does; the effect is in the measurements, not in the test’s verdict. What kalloctest cannot see (which list a page is on, how often a lock was contended) you check with Ctrl-P and with the contention counter of milestone 5.

2. Think first

Answer each question in your head (or on paper) before opening a hint. Hints get more specific; the reference answer comes last.

1Why is one free list a bottleneck?

Three harts each run a process that grows its memory by 64 pages and shrinks it again, over and over. The three processes never share a page. Do the three harts ever wait for each other inside the allocator? Where, and why? Then: what would you change so that, most of the time, they do not?

Check yourself

1warm-upChoose one

On the original kernel, three harts run three processes that allocate and free pages at the same time. The processes never share a page. Which statement is right?

2solidType a number

Each of three processes does 2,000 rounds of sbrk(64 pages), writes the pages, then sbrk(-64 pages). How many times do these rounds acquire kmem.lock in the original kernel, counting only the 64 data pages per round (not page-table pages)?

decimal, 0x hex or 0b binary

2Which CPU am I on, and for how long is that true?

Per-CPU lists mean kfree and kalloc must ask “which CPU is this?”. Find how the kernel answers that question. Then: a process in a system call on hart 1 gets the answer 1, and a few instructions later uses it to pick a list. What could make the answer wrong by then? What must be true in between, and how do you make it true in a function that may be called with spinlocks already held, or with interrupts already off?

Check yourself

1solidChoose one

A system call on hart 1, with interrupts on, executes id = cpuid(); and two instructions later uses id. Which event can make id wrong in between?

2solidTrue or false, and why

True or false: in kfree, intr_off(); id = cpuid(); ... intr_on(); would do the job as well as push_off(); id = cpuid(); ... pop_off();.

Why?

3Where do pages start, and what does a CPU with an empty list do?

kinit runs once, on one hart, before the other harts start. Where do the free pages end up? Could you spread them over the CPUs instead, and how many CPUs would you spread them over? Then: hart 2 calls kalloc and its list is empty, but other lists are not. What should happen? And when should kalloc finally give up and return 0?

Check yourself

1solidType a number

Memory is exhausted: all eight lists are empty. A process on hart 2 calls kalloc in the reference design (NCPU = 8, 3 harts running). How many kmem lock acquisitions does this one failing call make?

decimal, 0x hex or 0b binary

4Does a per-CPU list need a lock?

With the CPU number pinned down, a CPU’s own list is touched by that CPU’s kalloc and kfree, with interrupts off. Is a lock still needed for it? Think about who else could ever touch it: interrupt handlers on the same hart, other harts. And suppose, despite everything, a thread used a stale CPU number: with a lock per list, what exactly would go wrong? Without one?

Check yourself

1solidChoose all that apply

In the reference design, which of these can access hart 1’s list kmem[1]?

5Stealing, and the order of locks

Hart 1 holds kmem1, has just found its own list empty, and wants pages from kmem2. The natural code takes kmem2’s lock now, moves some pages, and releases both. What can happen on three harts if hart 2, at the same moment, holds kmem2, finds it empty, and walks towards kmem1? Write the interleaving. Then find a rule that makes it impossible, and say which rule you prefer and what it costs.

Check yourself

1deepFill in the machine state

In the deadlock above (clinic 2’s broken kernel), hart 0 is in a system call (sbrk) inside kalloc, holding kmem0, spinning in acquire for kmem2. kalloc did a push_off before it took kmem0. Fill in the hart’s state.

2solidChoose one

What makes the reference steal deadlock-free?

6How much to steal, and from whom?

When a CPU’s list is empty, how many pages should it take from its victim: one, a fixed batch, or half of the victim’s list? Count what each choice costs in lock acquisitions on another CPU’s list, and how long each holds the victim’s lock. And from whom: if every hart scanned starting at list 0, who would pay?

Check yourself

1solidType a number

Right after boot all free pages are on kmem0. A process on hart 2 allocates pages one after another with a kernel that steals one page at a time (scan order: own list, then (id + i) % 8 for i = 1…7). How many kmem lock acquisitions does each successful kalloc make?

decimal, 0x hex or 0b binary

7noff and intena inside the allocator

The allocator now does its own push_off before taking a list lock. Predict noff and intena at the moment a list lock is held, for three callers: sbrk growing memory (a system call), vmfault filling a lazily allocated page (a page fault), and kwait freeing a zombie child. Which is the deepest, and is that deeper than anything in the original kernel?

Check yourself

1solidFill in the machine state

A process touches a page it reserved with sbrklazy. The store faults, usertrap calls vmfault, which calls kalloc. Fill in the hart’s state just after kalloc has acquired its own list’s lock.

kernel/trap.c
69 } else if ((which_dev = devintr()) != 0) {
70 // ok
71 } else if ((r_scause() == 15 || r_scause() == 13) &&
73 (r_scause() == 13) ? 1 : 0) != 0) {
74 // page fault on lazily-allocated page
75 } else {
76 printk("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);
77 printk(" sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval());
79 }
2deepFill in the machine state

The shell waits for echo, finds it a ZOMBIE, and freeproc frees its pages. Fill in the state just after kfree has acquired its list lock, for one of those pages.

kernel/proc.c
381 for (pp = proc; pp < &proc[NPROC]; pp++) {
382 if (pp->parent == p) {
383 // make sure the child isn't still in exit() or swtch().
387 if (pp->state == ZOMBIE) {
388 // Found one.
390 if (addr != 0 &&
391 copyout(p->pagetable, p->sz, addr, (char *)&pp->xstate,
392 sizeof(pp->xstate)) < 0) {
395 return -1;
396 }
397 pp->parent = 0;
401 return pid;

8Where does memory end up?

Nothing in the design ever sends a page back to the CPU it came from. After a while, where are the free pages? Can kalloc now return 0 while a free page exists somewhere? Look for two different ways. Does either matter?

Check yourself

1deepTrue or false, and why

True or false: with the reference per-CPU allocator, if kalloc returns 0, then at some instant during the call there was no free page on any list.

Why?

3. Build it

Start.

git checkout -b my-percpu 06aad25

Add the test program first: user/kalloctest.c with the four checks of the spec, and $U/_kalloctest\ in UPROGS in the Makefile. It passes on the unmodified kernel; run it anyway, so that you know its output and its tick count before you change anything.

Milestones, in an order that keeps the system bootable after each one.

  1. An array of lists, all CPUs still using list 0. Turn kmem into struct kmem kmem[NCPU], give each lock a name of its own (kmem0 … kmem7, stored in the struct so that the name outlives kinit), initialize every lock in kinit, and make kalloc and kfree use kmem[0]. Nothing changes in behaviour. Test: kalloctest, usertests -q.
  2. Free onto, and allocate from, this CPU’s list. push_off, id = cpuid(), use kmem[id], pop_off. In kalloc, if the own list is empty, try the other lists one at a time and take one page from the first that has any. Hold one kmem lock at a time. Test: boot (if harts 1 and 2 cannot allocate, init or sh fails at once), kalloctest, usertests -q.
  3. Steal a batch. Take up to 64 pages from a victim, keep one, and put the rest on your own list, taking your own lock only after releasing the victim’s. Test: kalloctest (watch the exhaust line: three harts stealing at once), usertests -q, kalloctest again.
  4. Make the distribution visible. A page count per list, updated under the list’s lock, and a line printed from procdump (Ctrl-P) without taking any lock. Type Ctrl-P after boot, after kalloctest, and after usertests -q, and explain what you see.
  5. Measure contention (in a scratch copy, not on your branch). In acquire, count the failed amoswap attempts of each acquisition; keep per-hart tables keyed by lk->name (a shared table would need a lock of its own and would become the most contended lock in the kernel); print them on Ctrl-P and reset. Measure the original kernel and yours with the same workload.

Debugging advice. Start QEMU with make qemu-gdb, which waits for gdb on the port it prints, so breakpoints set before the first continue catch boot; for later events, and to inspect a hang, let it run and interrupt gdb (Ctrl-C).

4. Debugging clinic

Each of these bugs was put into the reference solution on purpose and run on three harts. The symptom is exactly what happened. Try to explain it before revealing why.

1cpuid() read with interrupts on

The push_off/pop_off bracket is left out of both kfree and kalloc, so the CPU number is read while a timer interrupt can still move the thread:

-  push_off();
   id = cpuid();
   acquire(&kmem[id].lock);
   ...
   release(&kmem[id].lock);
-  pop_off();

To see what happens, the experiment also counts every list operation done on a list other than the current hart’s: right after each acquire(&kmem[id].lock) (where interrupts are off), if (id != cpuid()) kmoved++, printed as moved on Ctrl-P. A second copy widens the window with a 200-iteration delay loop between cpuid() and the acquire, to make a migration in it likely enough to observe.

What happened when we ran it

# as written (no delay): kalloctest, Ctrl-P, usertests -q, kalloctest, Ctrl-P
[...]
kalloctest: ALL OK
[...]
free pages: kmem0 32545 kmem1 0 kmem2 0 kmem3 0 kmem4 0 kmem5 0 kmem6 0 kmem7 0 moved 0
[...]
ALL TESTS PASSED
[...]
kalloctest: ALL OK
[...]
free pages: kmem0 21213 kmem1 0 kmem2 11332 kmem3 0 kmem4 0 kmem5 0 kmem6 0 kmem7 0 moved 0

# with the window widened by a delay loop, same commands:
[...]
kalloctest: ALL OK
[...]
free pages: kmem0 0 kmem1 0 kmem2 32545 kmem3 0 kmem4 0 kmem5 0 kmem6 0 kmem7 0 moved 1
[...]
ALL TESTS PASSED
[...]
kalloctest: ALL OK
[...]
free pages: kmem0 32545 kmem1 0 kmem2 0 kmem3 0 kmem4 0 kmem5 0 kmem6 0 kmem7 0 moved 3

2Stealing while holding your own lock

kalloc keeps its own list’s lock while it steals, and steal puts the whole batch on the own list directly (its lock is already held):

   acquire(&kmem[id].lock);
   r = kmem[id].freelist;
+  if (r == 0)
+    r = steal(id);
   if (r) {
     kmem[id].freelist = r->next;
     kmem[id].nfree--;
   }
   release(&kmem[id].lock);
-  if (r == 0)
-    r = steal(id);

(and in steal, the acquire(&kmem[id].lock)/release around the push are removed). It is the natural first version: it even avoids the “batch in transit” window.

What happened when we ran it

$ kalloctest
kalloctest: churn: 3 processes x 2000 rounds x 64 pages, 36 ticks
kalloctest: churn: OK
kalloctest: one process got 32472 free pages, 32472 at start
kalloctest: gather: OK
kalloctest: exhaust: 3 processes got 32378 of 32472 free pages
kalloctest: exhaust: OK
kalloctest: free pages 32472 before, 32472 after
kalloctest: no leaks: OK
kalloctest: ALL OK
$ kalloctest
kalloctest: churn: 3 processes x 2000 rounds x 64 pages, 36 ticks
kalloctest: churn: OK
kalloctest: one process got 32472 free pages, 32472 at start
kalloctest: gather: OK
[... nothing more; after 120 s gdb was attached:]
[...]

  Id   Target Id                    Frame
* 1    Thread 1.1 (CPU#0 [running]) acquire (lk=lk@entry=0x8000f9f0 <kmem+96>) at kernel/spinlock.c:37
  2    Thread 1.2 (CPU#1 [halted ]) s_sstatus (x=2) at kernel/riscv.h:67
  3    Thread 1.3 (CPU#2 [running]) acquire (lk=lk@entry=0x8000f990 <kmem>) at kernel/spinlock.c:37

Thread 3 (Thread 1.3 (CPU#2 [running])):
#0  acquire (lk=lk@entry=0x8000f990 <kmem>) at kernel/spinlock.c:37
#1  0x0000000080000c3a in steal (id=<optimized out>) at kernel/kalloc.c:98
#2  kalloc () at kernel/kalloc.c:134
#3  0x00000000800014fc in uvmalloc (pagetable=0x87f38000, oldsz=46358528, newsz=46362624, xperm=xperm@entry=4) at kernel/vm.c:228
#4  0x0000000080001e4c in growproc (n=4096) at kernel/proc.c:246
#5  0x0000000080002bfc in sys_sbrk () at kernel/sysproc.c:51
#6  0x0000000080002b02 in syscall () at kernel/syscall.c:146
#7  0x0000000080002888 in usertrap () at kernel/trap.c:68
#8  0x0000003ffffff09c in ?? ()

Thread 2 (Thread 1.2 (CPU#1 [halted ])):
#0  s_sstatus (x=2) at kernel/riscv.h:67
#1  intr_on () at kernel/riscv.h:312
#2  scheduler () at kernel/proc.c:441
#3  0x00000000800010b4 in main () at kernel/main.c:44

Thread 1 (Thread 1.1 (CPU#0 [running])):
#0  acquire (lk=lk@entry=0x8000f9f0 <kmem+96>) at kernel/spinlock.c:37
#1  0x0000000080000c3a in steal (id=<optimized out>) at kernel/kalloc.c:98
#2  kalloc () at kernel/kalloc.c:134
#3  0x00000000800014fc in uvmalloc (pagetable=0x80cf8000, oldsz=43352064, newsz=43356160, xperm=xperm@entry=4) at kernel/vm.c:228
#4  0x0000000080001e4c in growproc (n=4096) at kernel/proc.c:246
#5  0x0000000080002bfc in sys_sbrk () at kernel/sysproc.c:51
#6  0x0000000080002b02 in syscall () at kernel/syscall.c:146
#7  0x0000000080002888 in usertrap () at kernel/trap.c:68
#8  0x0000003ffffff09c in ?? ()
kmem[0] kmem0 locked=1 held by hart 0 nfree=0 freelist=0x0
kmem[1] kmem1 locked=0 held by - nfree=0 freelist=0x0
kmem[2] kmem2 locked=1 held by hart 2 nfree=0 freelist=0x0
kmem[3] kmem3 locked=0 held by - nfree=0 freelist=0x0
[...]
cpus[0]: noff 3 intena 1 proc kalloctest pid 16
cpus[1]: noff 0 intena 0 proc none
cpus[2]: noff 3 intena 1 proc kalloctest pid 14
slot  0 pid   1 SLEEPING init
slot  1 pid   2 SLEEPING sh
slot  2 pid  10 SLEEPING kalloctest
slot  3 pid  14 RUNNING  kalloctest
slot  4 pid  15 SLEEPING kalloctest
slot  5 pid  16 RUNNING  kalloctest

3Stealing one page at a time

The batch size is 1, so every allocation by a CPU with an empty list is a remote steal:

-#define STEAL 64 // most pages taken from another CPU's list at once
+#define STEAL 1  // most pages taken from another CPU's list at once

(Commit 3 of the reference behaves like this too, before commit 4 adds batches; its own scan differs slightly, and its measured numbers are not the ones below.) Measured with the contention counter of the measure section, three boots, each running kchurn (the churn phase of kalloctest alone), kalloctest and usertests -q.

What happened when we ran it

# STEAL 1, boot 1: the contention counter's Ctrl-P after usertests -q (trimmed)
LS kmem0 acq 530711 contended 0 spins 0 maxspins 0
LS kmem1 acq 372287 contended 0 spins 0 maxspins 0
LS kmem2 acq 313524 contended 0 spins 0 maxspins 0
LS kmem3 acq 82048 contended 0 spins 0 maxspins 0
[...]
LS kmem7 acq 82048 contended 0 spins 0 maxspins 0
LSH hart 2 kmem2 acq 264176 contended 0 spins 0
LSH hart 2 kmem3 acq 74173 contended 0 spins 0
[...]
LSH hart 2 kmem0 acq 74173 contended 0 spins 0
LSH hart 2 kmem1 acq 44189 contended 0 spins 0

# summary of all such dumps: kmem* acquisitions per workload, all eight lists added up
# (3 boots each)
#                    total acquisitions      of another CPU's list   contended
# STEAL 1
kchurn               769,456 - 770,396       1,322 - 2,262           14 - 50
kalloctest           1,216,305 - 1,375,459   188,311 - 347,465       870 - 1,418
usertests -q         1,626,762 - 1,783,300   622,936 - 779,474       0
# STEAL 64 (the reference)
kchurn               768,159 - 768,163       20 - 25                 2 - 5
kalloctest           1,032,020 - 1,036,044   3,220 - 6,423           243 - 249
usertests -q         1,014,352 - 1,018,373   8,367 - 11,819          0

# every test passed in all six boots

4Only the first lock is initialized

kinit keeps its original shape and initializes only list 0:

-  for (int i = 0; i < NCPU; i++) {
-    safestrcpy(kmem[i].name, "kmem0", sizeof(kmem[i].name));
-    kmem[i].name[4] += i;
-    initlock(&kmem[i].lock, kmem[i].name);
-  }
+  safestrcpy(kmem[0].name, "kmem0", sizeof(kmem[0].name));
+  initlock(&kmem[0].lock, kmem[0].name);

What happened when we ran it

# the branch with this change: kalloctest, Ctrl-P, usertests -q, kalloctest, Ctrl-P
[...]
kalloctest: ALL OK
[...]
free pages: kmem0 32545  0  0  0  0  0  0  0
[...]
ALL TESTS PASSED
[...]
kalloctest: ALL OK
[...]
free pages: kmem0 32545  0  0  0  0  0  0  0

# gdb, breakpoint on userinit (after kinit):
$1 = {locked = 0, name = 0x8000f9b4 <kmem+36> "kmem0", cpu = 0x0}
$2 = {locked = 0, name = 0x0, cpu = 0x0}
$3 = "\000\000\000\000\000\000\000"
$4 = {lock = {locked = 0, name = 0x0, cpu = 0x0}, freelist = 0x0, nfree = 0, name = "\000\000\000\000\000\000\000"}

# the same change with the contention counter, after one kalloctest (Ctrl-P, trimmed):
LS kmem0 acq 362563 contended 172 spins 136346 maxspins 2527
LS time acq 49904 contended 0 spins 0 maxspins 0
LS cons acq 97500 contended 2 spins 2790 maxspins 1972
LS pipe acq 558091 contended 88 spins 36378 maxspins 1479
# (the reference over a kalloctest interval: time 65, cons 24, pipe 18 acquisitions)

5. The reference solution

Take the guided tour through the reference solution, one commit at a time, with the machine state at every step:

Open the reveal tour →

Or read the commits

  1. 0a94e8e Add kalloctest, a stress test for the page allocator

    Makefile

    @@ -149,8 +149,9 @@ UPROGS=\
    149149 $U/_logstress\
    150150 $U/_forphan\
    151151 $U/_dorphan\
    152152 $U/_sync\
    153 $U/_kalloctest\
    153154
    154155fs.img: mkfs/mkfs README $(UPROGS)
    155156 mkfs/mkfs fs.img README $(UPROGS)
    156157

    user/kalloctest.c

    @@ -0,0 +1,154 @@
    1//
    2// tests for the physical page allocator under load from
    3// several harts. each check prints "kalloctest: <name>: OK"
    4// or "... FAIL". the timing line is information, not a check.
    5//
    6
    7#include "kernel/types.h"
    8#include "kernel/riscv.h"
    9#include "user/user.h"
    10
    11#define NCHILD 3 // one per hart
    12#define ROUNDS 2000 // grow/shrink rounds per child
    13#define NPAGES 64 // pages per round
    14
    15int failed;
    16
    17void
    18result(char *name, int ok)
    19{
    20 printf("kalloctest: %s: %s\n", name, ok ? "OK" : "FAIL");
    21 if (!ok)
    22 failed = 1;
    23}
    24
    25// count free pages by allocating them all with sbrk.
    26int
    27countfree(void)
    28{
    29 char *sz0 = sbrk(0);
    30 int n = 0;
    31
    32 while (sbrk(PGSIZE) != SBRK_ERROR)
    33 n++;
    34 sbrk(-(sbrk(0) - sz0));
    35 return n;
    36}
    37
    38// grow by NPAGES pages, write and check every page, shrink
    39// again; ROUNDS times. exits 0 if every page held its value.
    40void
    41churn(int id)
    42{
    43 for (int r = 0; r < ROUNDS; r++) {
    44 char *p = sbrk(NPAGES * PGSIZE);
    45 if (p == SBRK_ERROR)
    46 exit(1);
    47 for (int i = 0; i < NPAGES; i++)
    48 p[i * PGSIZE] = id + r + i;
    49 for (int i = 0; i < NPAGES; i++)
    50 if (p[i * PGSIZE] != (char)(id + r + i))
    51 exit(1);
    52 sbrk(-(NPAGES * PGSIZE));
    53 }
    54 exit(0);
    55}
    56
    57// NCHILD processes allocate and free pages at the same time.
    58void
    59churntest(void)
    60{
    61 int ok = 1, xstatus;
    62 int t0 = uptime();
    63
    64 for (int i = 0; i < NCHILD; i++) {
    65 int pid = fork();
    66 if (pid < 0) {
    67 printf("kalloctest: fork failed\n");
    68 exit(1);
    69 }
    70 if (pid == 0)
    71 churn(i);
    72 }
    73 for (int i = 0; i < NCHILD; i++)
    74 if (wait(&xstatus) < 0 || xstatus != 0)
    75 ok = 0;
    76 printf("kalloctest: churn: %d processes x %d rounds x %d pages, %d ticks\n",
    77 NCHILD, ROUNDS, NPAGES, uptime() - t0);
    78 result("churn", ok);
    79}
    80
    81// NCHILD processes each take pages until none is left, all at
    82// the same time, and keep them until every one has run out.
    83// returns how many pages they got in all, or -1.
    84int
    85exhaust(void)
    86{
    87 int done[2], go[2], xstatus, total = 0;
    88 char c;
    89
    90 if (pipe(done) < 0 || pipe(go) < 0)
    91 return -1;
    92 for (int i = 0; i < NCHILD; i++) {
    93 int pid = fork();
    94 if (pid < 0)
    95 return -1;
    96 if (pid == 0) {
    97 int n = 0;
    98 char *p;
    99 close(done[0]);
    100 close(go[1]);
    101 while ((p = sbrk(PGSIZE)) != SBRK_ERROR) {
    102 *p = 1;
    103 n++;
    104 }
    105 write(done[1], "x", 1);
    106 read(go[0], &c, 1); // returns 0 when the parent closes go[1]
    107 exit(n);
    108 }
    109 }
    110 close(done[1]);
    111 close(go[0]);
    112 for (int i = 0; i < NCHILD; i++)
    113 if (read(done[0], &c, 1) != 1)
    114 return -1;
    115 close(go[1]);
    116 close(done[0]);
    117 for (int i = 0; i < NCHILD; i++) {
    118 if (wait(&xstatus) < 0)
    119 return -1;
    120 total += xstatus;
    121 }
    122 return total;
    123}
    124
    125int
    126main(int argc, char *argv[])
    127{
    128 int free0, free1, free2, got;
    129
    130 free0 = countfree();
    131 churntest();
    132
    133 // the freed pages may now be spread over several lists;
    134 // one process must still be able to get every one.
    135 free1 = countfree();
    136 printf("kalloctest: one process got %d free pages, %d at start\n", free1, free0);
    137 result("gather", free1 == free0);
    138
    139 got = exhaust();
    140 // the children's own page-table and fork pages come out of
    141 // the same memory, so they get a little less than free0.
    142 printf("kalloctest: exhaust: %d processes got %d of %d free pages\n", NCHILD, got, free0);
    143 result("exhaust", got > free0 - 256);
    144
    145 free2 = countfree();
    146 printf("kalloctest: free pages %d before, %d after\n", free0, free2);
    147 result("no leaks", free2 == free0);
    148
    149 if (failed)
    150 printf("kalloctest: SOME TESTS FAILED\n");
    151 else
    152 printf("kalloctest: ALL OK\n");
    153 exit(failed);
    154}
  2. bcb6dea Make kmem an array of free lists with named locks

    kernel/kalloc.c

    @@ -17,17 +17,25 @@ extern char end[]; // first address after kernel.
    1717struct run {
    1818 struct run *next;
    1919};
    2020
    21struct {
    21// one free list per CPU, each with its own lock.
    22struct kmem {
    2223 struct spinlock lock;
    2324 struct run *freelist;
    24} kmem;
    25 char name[8]; // "kmem0", "kmem1", ...
    26};
    27
    28struct kmem kmem[NCPU];
    2529
    2630void
    2731kinit()
    2832{
    29 initlock(&kmem.lock, "kmem");
    33 for (int i = 0; i < NCPU; i++) {
    34 safestrcpy(kmem[i].name, "kmem0", sizeof(kmem[i].name));
    35 kmem[i].name[4] += i;
    36 initlock(&kmem[i].lock, kmem[i].name);
    37 }
    3038 freerange(end, (void *)PHYSTOP);
    3139}
    3240
    3341void
    @@ -55,12 +63,13 @@ kfree(void *pa)
    5563 memset(pa, 1, PGSIZE);
    5664
    5765 r = (struct run *)pa;
    5866
    60 r->next = kmem.freelist;
    61 kmem.freelist = r;
    67 // for now every CPU uses list 0.
    68 acquire(&kmem[0].lock);
    69 r->next = kmem[0].freelist;
    70 kmem[0].freelist = r;
    71 release(&kmem[0].lock);
    6372}
    6473
    6574// Allocate one 4096-byte page of physical memory.
    6675// Returns a pointer that the kernel can use.
    @@ -69,13 +78,13 @@ void *
    6978kalloc(void)
    7079{
    7180 struct run *r;
    7281
    74 r = kmem.freelist;
    82 acquire(&kmem[0].lock);
    83 r = kmem[0].freelist;
    7584 if (r)
    76 kmem.freelist = r->next;
    85 kmem[0].freelist = r->next;
    86 release(&kmem[0].lock);
    7887
    7988 if (r)
    8089 memset((char *)r, 5, PGSIZE); // fill with junk
    8190 return (void *)r;
  3. 82dd2d6 Give each CPU its own free list

    kernel/kalloc.c

    @@ -34,8 +34,10 @@ kinit()
    3434 safestrcpy(kmem[i].name, "kmem0", sizeof(kmem[i].name));
    3535 kmem[i].name[4] += i;
    3636 initlock(&kmem[i].lock, kmem[i].name);
    3737 }
    38 // kinit runs on the boot CPU, so kfree puts every page on
    39 // that CPU's list; the others take from it when they need to.
    3840 freerange(end, (void *)PHYSTOP);
    3941}
    4042
    4143void
    @@ -54,8 +56,9 @@ freerange(void *pa_start, void *pa_end)
    5456void
    5557kfree(void *pa)
    5658{
    5759 struct run *r;
    60 int id;
    5861
    5962 if (((uint64)pa % PGSIZE) != 0 || (char *)pa < end || (uint64)pa >= PHYSTOP)
    6063 panic("kfree");
    6164
    @@ -63,28 +66,42 @@ kfree(void *pa)
    6366 memset(pa, 1, PGSIZE);
    6467
    6568 r = (struct run *)pa;
    6669
    67 // for now every CPU uses list 0.
    68 acquire(&kmem[0].lock);
    69 r->next = kmem[0].freelist;
    70 kmem[0].freelist = r;
    71 release(&kmem[0].lock);
    70 // put the page on this CPU's list. interrupts stay off
    71 // from cpuid() to release, so the thread cannot move to
    72 // another CPU in between.
    73 push_off();
    74 id = cpuid();
    75 acquire(&kmem[id].lock);
    76 r->next = kmem[id].freelist;
    77 kmem[id].freelist = r;
    78 release(&kmem[id].lock);
    79 pop_off();
    7280}
    7381
    7482// Allocate one 4096-byte page of physical memory.
    7583// Returns a pointer that the kernel can use.
    7684// Returns 0 if the memory cannot be allocated.
    7785void *
    7886kalloc(void)
    7987{
    80 struct run *r;
    81
    82 acquire(&kmem[0].lock);
    83 r = kmem[0].freelist;
    84 if (r)
    85 kmem[0].freelist = r->next;
    86 release(&kmem[0].lock);
    88 struct run *r = 0;
    89 int id;
    90
    91 push_off();
    92 id = cpuid();
    93 // this CPU's list first, then the other CPUs' lists.
    94 // only one kmem lock is held at a time.
    95 for (int i = 0; i < NCPU && r == 0; i++) {
    96 struct kmem *km = &kmem[(id + i) % NCPU];
    97 acquire(&km->lock);
    98 r = km->freelist;
    99 if (r)
    100 km->freelist = r->next;
    101 release(&km->lock);
    102 }
    103 pop_off();
    87104
    88105 if (r)
    89106 memset((char *)r, 5, PGSIZE); // fill with junk
    90107 return (void *)r;
  4. f3b9836 Steal pages from other CPUs in batches

    kernel/kalloc.c

    @@ -10,8 +10,10 @@
    1010#include "defs.h"
    1111
    1212void freerange(void *pa_start, void *pa_end);
    1313
    14#define STEAL 64 // most pages taken from another CPU's list at once
    15
    1416extern char end[]; // first address after kernel.
    1517 // defined by kernel.ld.
    1618
    1719struct run {
    @@ -78,29 +80,62 @@ kfree(void *pa)
    7880 release(&kmem[id].lock);
    7981 pop_off();
    8082}
    8183
    84// Take up to STEAL pages from another CPU's list: return the
    85// first, and put the rest on CPU id's list. Holds only one
    86// kmem lock at a time, so two stealing CPUs cannot deadlock.
    87// Caller must have interrupts off.
    88static struct run *
    89steal(int id)
    90{
    91 struct run *first, *last;
    92 int n;
    93
    94 for (int i = 1; i < NCPU; i++) {
    95 struct kmem *km = &kmem[(id + i) % NCPU];
    96 acquire(&km->lock);
    97 first = last = km->freelist;
    98 n = 0;
    99 if (first) {
    100 // cut the first STEAL pages (or all) off km's list.
    101 for (n = 1; n < STEAL && last->next; n++)
    102 last = last->next;
    103 km->freelist = last->next;
    104 }
    105 release(&km->lock);
    106 if (n == 0)
    107 continue;
    108 if (n > 1) {
    109 // keep the first page; the rest go on this CPU's list.
    110 acquire(&kmem[id].lock);
    111 last->next = kmem[id].freelist;
    112 kmem[id].freelist = first->next;
    113 release(&kmem[id].lock);
    114 }
    115 return first;
    116 }
    117 return 0;
    118}
    119
    82120// Allocate one 4096-byte page of physical memory.
    83121// Returns a pointer that the kernel can use.
    84122// Returns 0 if the memory cannot be allocated.
    85123void *
    86124kalloc(void)
    87125{
    88 struct run *r = 0;
    126 struct run *r;
    89127 int id;
    90128
    91129 push_off();
    92130 id = cpuid();
    93 // this CPU's list first, then the other CPUs' lists.
    94 // only one kmem lock is held at a time.
    95 for (int i = 0; i < NCPU && r == 0; i++) {
    96 struct kmem *km = &kmem[(id + i) % NCPU];
    97 acquire(&km->lock);
    98 r = km->freelist;
    99 if (r)
    100 km->freelist = r->next;
    101 release(&km->lock);
    102 }
    131 acquire(&kmem[id].lock);
    132 r = kmem[id].freelist;
    133 if (r)
    134 kmem[id].freelist = r->next;
    135 release(&kmem[id].lock);
    136 if (r == 0)
    137 r = steal(id);
    103138 pop_off();
    104139
    105140 if (r)
    106141 memset((char *)r, 5, PGSIZE); // fill with junk
  5. 4f697db Show each CPU's free-page count on Ctrl-P

    kernel/defs.h

    @@ -59,8 +59,9 @@ void ireclaim(int);
    5959// kalloc.c
    6060void* kalloc(void);
    6161void kfree(void *);
    6262void kinit(void);
    63void kmemdump(void);
    6364
    6465// log.c
    6566void initlog(int, struct superblock*);
    6667void log_write(struct buf*);

    kernel/kalloc.c

    @@ -23,8 +23,9 @@ struct run {
    2323// one free list per CPU, each with its own lock.
    2424struct kmem {
    2525 struct spinlock lock;
    2626 struct run *freelist;
    27 int nfree; // pages on freelist
    2728 char name[8]; // "kmem0", "kmem1", ...
    2829};
    2930
    3031struct kmem kmem[NCPU];
    @@ -76,8 +77,9 @@ kfree(void *pa)
    7677 id = cpuid();
    7778 acquire(&kmem[id].lock);
    7879 r->next = kmem[id].freelist;
    7980 kmem[id].freelist = r;
    81 kmem[id].nfree++;
    8082 release(&kmem[id].lock);
    8183 pop_off();
    8284}
    8385
    @@ -100,8 +102,9 @@ steal(int id)
    100102 // cut the first STEAL pages (or all) off km's list.
    101103 for (n = 1; n < STEAL && last->next; n++)
    102104 last = last->next;
    103105 km->freelist = last->next;
    106 km->nfree -= n;
    104107 }
    105108 release(&km->lock);
    106109 if (n == 0)
    107110 continue;
    @@ -109,8 +112,9 @@ steal(int id)
    109112 // keep the first page; the rest go on this CPU's list.
    110113 acquire(&kmem[id].lock);
    111114 last->next = kmem[id].freelist;
    112115 kmem[id].freelist = first->next;
    116 kmem[id].nfree += n - 1;
    113117 release(&kmem[id].lock);
    114118 }
    115119 return first;
    116120 }
    @@ -129,10 +133,12 @@ kalloc(void)
    129133 push_off();
    130134 id = cpuid();
    131135 acquire(&kmem[id].lock);
    132136 r = kmem[id].freelist;
    133 if (r)
    137 if (r) {
    134138 kmem[id].freelist = r->next;
    139 kmem[id].nfree--;
    140 }
    135141 release(&kmem[id].lock);
    136142 if (r == 0)
    137143 r = steal(id);
    138144 pop_off();
    @@ -140,4 +146,16 @@ kalloc(void)
    140146 if (r)
    141147 memset((char *)r, 5, PGSIZE); // fill with junk
    142148 return (void *)r;
    143149}
    150
    151// Print how many free pages each CPU's list holds. Called by
    152// procdump (^P). Like procdump it takes no lock, so that it
    153// works on a stuck machine; the counts may be a moment old.
    154void
    155kmemdump(void)
    156{
    157 printk("free pages:");
    158 for (int i = 0; i < NCPU; i++)
    159 printk(" %s %d", kmem[i].name, kmem[i].nfree);
    160 printk("\n");
    161}

    kernel/proc.c

    @@ -699,5 +699,6 @@ procdump(void)
    699699 state = "???";
    700700 printk("%d %s %s", p->pid, state, p->name);
    701701 printk("\n");
    702702 }
    703 kmemdump();
    703704}

6. Verify and measure

On the branch (ext/18-percpu-kalloc, 5 commits), built with the project toolchain and run on 3 harts (-smp 3 -m 128M), with Ctrl-P after boot and after the first test:

free pages: kmem0 32483 kmem1 62 kmem2 0 kmem3 0 kmem4 0 kmem5 0 kmem6 0 kmem7 0
kalloctest
kalloctest: churn: 3 processes x 2000 rounds x 64 pages, 15 ticks
kalloctest: churn: OK
kalloctest: one process got 32472 free pages, 32472 at start
kalloctest: gather: OK
kalloctest: exhaust: 3 processes got 32378 of 32472 free pages
kalloctest: exhaust: OK
kalloctest: free pages 32472 before, 32472 after
kalloctest: no leaks: OK
kalloctest: ALL OK
[...]
free pages: kmem0 0 kmem1 0 kmem2 32545 kmem3 0 kmem4 0 kmem5 0 kmem6 0 kmem7 0
usertests -q
usertests starting
test copyin: OK
test copyout: OK
[...]
test kernmem: usertrap(): unexpected scause 0xd pid=6483
[...]
ALL TESTS PASSED
$ kalloctest
kalloctest: churn: 3 processes x 2000 rounds x 64 pages, 16 ticks
kalloctest: churn: OK
kalloctest: one process got 32472 free pages, 32472 at start
kalloctest: gather: OK
kalloctest: exhaust: 3 processes got 32379 of 32472 free pages
kalloctest: exhaust: OK
kalloctest: free pages 32472 before, 32472 after
kalloctest: no leaks: OK
kalloctest: ALL OK

The usertrap() lines inside usertests are the expected kills of tests that touch memory they must not (kernmem, nowrite, …), as on the original kernel. usertests -q passing shows that everything that used the allocator still works, including the tests that exhaust memory (sbrkfail, sbrkmuch) and the free-page count that usertests compares at the end; kalloctest before and after it shows that no pages leaked (32,472 both times, the same number the original kernel reports). Every commit was built and passed kalloctest and usertests -q on 3 harts.

The Ctrl-P lines are checked by eye: right after boot the first process (on hart 1) has already taken a batch from hart 0’s list; after kalloctest all free memory sits on the list of the hart whose final countfree freed it.

How it was measured. A scratch copy of each kernel (never the branch) counted, in acquire, the failed amoswap attempts (“spins”) before each acquisition succeeded, in per-hart tables keyed by the lock’s name (per-hart, so that the counter itself adds no lock), and printed and reset them on Ctrl-P. Three boots per kernel, 3 harts each; between Ctrl-Ps: kchurn (the churn phase of kalloctest on its own: 3 processes × 2,000 rounds × 64 pages), then kalloctest, then usertests -q. “Remote” means an acquisition of another hart’s list; “contended” means at least one failed amoswap. Ranges are over the three boots.

workload kernel kmem acquisitions contended spins remote
kchurn original (one kmem) 768,134 43,429-49,770 617,603-683,545 —
kchurn per-CPU, STEAL 64 768,159-768,163 2-5 2,211-15,351 20-25
kalloctest original 1,028,002 42,574-51,664 661,559-718,654 —
kalloctest per-CPU 1,032,020-1,036,044 243-249 154,412-195,470 3,220-6,423
usertests -q original 1,003,854 30-33 455-488 —
usertests -q per-CPU 1,014,352-1,018,373 0 0 8,367-11,819

Where the memory went (Ctrl-P on the branch): after boot kmem0 32483 kmem1 62 kmem2 0; after one kalloctest kmem0 0 kmem1 0 kmem2 32545; after usertests -q in one boot of the measurement copy kmem0 19980 kmem2 12560. Free memory sits wherever it was last freed.

Time, with a warning. On QEMU the per-CPU allocator is not measurably faster: the churn phase took 15-22 ticks (1.5-2.2 s) on both kernels (one run of the original took 35). QEMU emulates the harts on host threads but does not model what makes contention expensive on real hardware (moving a lock’s cache line between cores), and a spinning hart costs only host CPU time. The tick counts also depend on things that have nothing to do with the allocator: in one instrumented build, memset’s three-instruction loop happened to straddle a page boundary (0x80000ffa-0x80001000), and every kalloctest churn took 307-389 ticks in that build, about twenty times longer, although its allocator code was correct and uncontended. A control rebuilt that tree twice and ran both at the same time: unchanged, 376, 377 and 406 ticks; with only memset moved off the page boundary (__attribute__((aligned(32))) on memset), 35, 39 and 42 ticks. Why a page-straddling loop is so slow is our inference, not something we measured: QEMU translates guest code in blocks, and a loop split across two guest pages appears to take a slower path from block to block on every iteration. The measurements above use -falign-functions=64 for all kernels. Plain builds of the same commits gave anywhere from 15 to 45 ticks depending on what else the computer was doing (your times will differ), which is one more reason to trust counts over ticks. Count events (acquisitions, spins) to judge a locking change on QEMU; time it on real hardware.

7. Go further