SchOS/kernel/dev/bio.c
Nexoniarz 62d2a85ac8 bio: a buffer cache that grows to 1/32 of RAM, looked up by hash
The cache was NBUF buffers, 1 MB, on every machine: enough for one with
tens of megabytes and nothing on one with gigabytes, which re-read the
same directory and binary blocks off the disk for every command. It
still starts as those 256 in the kernel image, and a miss with every
buffer busy now makes a new one rather than evicting, up to 1/32 of RAM
or 256 MB. Growing on demand means nothing is spent that is not used,
and binit() needs nothing from the page allocator.

Lookups go through a hash on (dev, blockno) instead of walking the LRU
list, and bflush() walks a creation-order list with an unlocked filter
first, so neither costs more at 65536 buffers than it did at 256.
struct buf's data is a pointer to its own page now; the drivers copy
through their own DMA buffers, so where it sits does not matter.

On a 2 GB QEMU guest: a 40 MB file read back in 0.89 s instead of 1.83,
the big binaries in 0.17 s instead of 0.26. /proc/fsstat shows the
cache as buf_cached now / most.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
2026-10-23 13:28:12 +02:00

456 lines
13 KiB
C

// Buffer cache.
//
// The buffer cache is a linked list of buf structures holding
// cached copies of disk block contents. Caching disk blocks
// in memory reduces the number of disk reads and also provides
// a synchronization point for disk blocks used by multiple processes.
//
// Interface:
// * To get a buffer for a particular disk block, call bread.
// * After changing buffer data, call bwrite to write it to disk.
// * When done with the buffer, call brelse.
// * Do not use the buffer after calling brelse.
// * Only one process at a time can use a buffer,
// so do not keep them longer than necessary.
//
// The implementation uses two state flags internally:
// * B_VALID: the buffer data has been read from the disk.
// * B_DIRTY: the buffer data has been modified
// and needs to be written to disk.
#include "types.h"
#include "defs.h"
#include "param.h"
#include "spinlock.h"
#include "sleeplock.h"
#include "fs.h"
#include "buf.h"
#include "x86.h"
#include "fsstat.h"
#include "blkdev.h"
#include "kmem.h"
#include "hwinfo.h"
struct fsstat fsstat;
// The cache starts as NBUF buffers in the kernel image, 1 MB, and grows on
// demand: a miss with every buffer in use for something makes a new one
// rather than evicting, until the cache is 1/32 of RAM (at most 256 MB).
//
// It was NBUF and nothing else, on every machine. 1 MB is a fine floor for
// a machine with tens of megabytes and nothing at all for one with sixteen
// gigabytes, where it was re-reading the same directory blocks off the disk
// for every command. Growing rather than sizing at boot means a small machine,
// or a large one doing little, spends nothing it does not use -- and that
// binit() needs nothing from the page allocator that early.
//
// Lookups go through a hash on (dev, blockno). The LRU list was also the
// lookup structure, a linear walk per bread(), which is fine at 256 and
// hopeless at 65536.
#define BUF_MAX_BYTES (256ULL * 1024 * 1024)
#define NHASH 8192 // a power of two
static uchar pooldata[NBUF][BSIZE];
struct {
struct spinlock lock;
struct buf buf[NBUF];
// Linked list of all buffers, through prev/next.
// head.next is most recently used.
struct buf head;
struct buf *hash[NHASH];
struct buf *all; // through anext
struct kmem_cache *hdrs;
uint n; // buffers that exist
uint max; // how many there may be; 0 until first needed
} bcache;
static inline uint
bhash(uint dev, uint64 blockno)
{
uint64 h = (blockno ^ ((uint64)dev << 40)) * 0x9E3779B97F4A7C15ULL;
return (uint)(h >> 51) & (NHASH - 1);
}
// Both under bcache.lock.
static void
hash_del(struct buf *b)
{
struct buf **pp = &bcache.hash[bhash(b->dev, b->blockno)];
while (*pp && *pp != b)
pp = &(*pp)->hnext;
if (*pp)
*pp = b->hnext;
b->hnext = 0;
}
static void
hash_add(struct buf *b)
{
struct buf **pp = &bcache.hash[bhash(b->dev, b->blockno)];
b->hnext = *pp;
*pp = b;
}
// Give b a new identity, moving it between chains. Under bcache.lock.
static void
rename_buf(struct buf *b, uint dev, uint64 blockno)
{
hash_del(b);
b->dev = dev;
b->blockno = blockno;
hash_add(b);
}
static void
link_new(struct buf *b)
{
b->next = bcache.head.next;
b->prev = &bcache.head;
initsleeplock(&b->lock, "buffer");
bcache.head.next->prev = b;
bcache.head.next = b;
b->anext = bcache.all;
bcache.all = b;
// An identity no block has, so the first rename has a chain to leave.
b->dev = (uint)-1;
b->blockno = (uint64)bcache.n;
hash_add(b);
bcache.n++;
}
void
binit(void)
{
initlock(&bcache.lock, "bcache");
bcache.head.prev = &bcache.head;
bcache.head.next = &bcache.head;
for (int i = 0; i < NBUF; i++) {
bcache.buf[i].data = pooldata[i];
link_new(&bcache.buf[i]);
}
}
// One more buffer, or 0 at the cap or when memory is short. Under
// bcache.lock; the allocators take only their own locks.
static struct buf *
grow(void)
{
if (bcache.max == 0) {
uint64 want = ram_total_bytes() / 32;
if (want > BUF_MAX_BYTES)
want = BUF_MAX_BYTES;
bcache.max = (uint)(want / BSIZE);
if (bcache.max < NBUF)
bcache.max = NBUF;
bcache.hdrs = kmem_cache_create("buf", sizeof(struct buf));
}
if (bcache.n >= bcache.max || bcache.hdrs == 0)
return 0;
struct buf *b = kmem_cache_alloc(bcache.hdrs);
if (b == 0)
return 0;
memset(b, 0, sizeof *b);
if ((b->data = (uchar *)kalloc()) == 0) {
kmem_cache_free(bcache.hdrs, b);
return 0;
}
link_new(b);
return b;
}
// How many buffers there are, and may be. For /proc/fsstat.
void
bcache_size(uint *n, uint *max)
{
*n = bcache.n;
*max = bcache.max ? bcache.max : NBUF;
}
// Route a request to the driver that owns b->dev. Everything above this line
// is device-agnostic; everything below picks a backend. RAMDISK_DEV is served
// out of memory (kernel/dev/ramdisk.c), USBDISK_DEV by bulk transfers to a
// USB stick, everything else by the IDE driver.
static void bdevrw_inner(struct buf *b);
static void
bdevrw(struct buf *b)
{
uint64 t0 = rdtsc();
if(b->flags & B_DIRTY) fsstat.disk_writes++; else fsstat.disk_reads++;
bdevrw_inner(b);
fsstat.disk_cycles += rdtsc() - t0;
}
static void
bdevrw_inner(struct buf *b)
{
if(b->dev == RAMDISK_DEV){
ramdiskrw(b);
return;
}
if(b->dev == USBDISK_DEV){
usb_storage_rw(b);
return;
}
// SATA, NVMe and every partition: registered at probe time rather than
// known at build time. See include/blkdev.h.
if(b->dev >= BLKDEV_FIRST){
blkdev_rw(b);
return;
}
iderw(b);
}
// Tell the device to commit whatever it has accepted but not yet made durable.
// Same dispatch as bdevrw, and the same rule: everything above this line is
// device-agnostic.
static int
bdevflush(uint dev)
{
if(dev == RAMDISK_DEV)
return 0; // memory has no volatile stage below it
if(dev == USBDISK_DEV)
return usb_storage_flush();
if(dev >= BLKDEV_FIRST)
return blkdev_flush(dev);
return ideflush(dev);
}
// Look through buffer cache for block on device dev.
// If not found, allocate a buffer.
// In either case, return locked buffer.
// Forget every cached block of a device that is no longer there: clean and
// invalid, so it is neither served to a reader nor written back on eviction.
// A buffer somebody is holding is left to them -- they are about to find out.
void
binval(uint dev)
{
struct buf *b;
acquire(&bcache.lock);
for(b = bcache.all; b; b = b->anext)
if(b->dev == dev && b->refcnt == 0)
b->flags = 0;
release(&bcache.lock);
}
static struct buf*
bget(uint dev, uint64 blockno)
{
struct buf *b;
retry:
acquire(&bcache.lock);
// Is the block already cached?
for(b = bcache.hash[bhash(dev, blockno)]; b; b = b->hnext){
if(b->dev == dev && b->blockno == blockno){
if(b->refcnt++ == 0)
FSSTAT_TAKE(fsstat.buf_pinned, fsstat.buf_pinned_peak);
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
// Not cached. A new buffer while the cache may still grow, so nothing
// cached is thrown away for it.
if((b = grow()) != 0){
rename_buf(b, dev, blockno);
b->flags = 0;
b->refcnt = 1;
FSSTAT_TAKE(fsstat.buf_pinned, fsstat.buf_pinned_peak);
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
// Otherwise recycle some unused buffer and clean buffer
// "clean" because B_DIRTY and not locked means log.c
// hasn't yet committed the changes to the buffer.
for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
if(b->refcnt == 0 && (b->flags & B_DIRTY) == 0) {
rename_buf(b, dev, blockno);
b->flags = 0;
b->refcnt = 1;
FSSTAT_TAKE(fsstat.buf_pinned, fsstat.buf_pinned_peak);
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
// Every buffer is dirty and none is clean enough to take.
//
// This used to be a straight panic, which was right while every writer went
// through bwrite and a dirty buffer therefore meant "the log has not
// committed yet". SchFS marks tree blocks dirty and leaves them there (see
// bdirty), so an all-dirty cache is now an ordinary state: a transaction that
// touches more than NBUF blocks reaches it long before it commits.
//
// Writing one out early is always safe. The only ordering the format needs is
// that every block of the new tree precede the uberblock that names it, and
// the uberblock goes down after an explicit bflush -- so nothing here can
// land on the wrong side of that barrier.
for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
if(b->refcnt == 0){
fsstat.bget_evict_dirty++;
b->refcnt++; // claim it, so nobody else recycles it either
FSSTAT_TAKE(fsstat.buf_pinned, fsstat.buf_pinned_peak);
release(&bcache.lock);
acquiresleep(&b->lock);
if(b->flags & B_DIRTY)
bdevrw(b);
// Between dropping bcache.lock and taking b's, another process may have
// found this buffer under its *old* identity and be waiting on it. It is
// clean now either way, so hand it back rather than renaming a block out
// from under them, and look for another victim.
acquire(&bcache.lock);
if(b->refcnt != 1){
b->refcnt--;
FSSTAT_DROP(fsstat.buf_pinned);
release(&bcache.lock);
releasesleep(&b->lock);
goto retry;
}
rename_buf(b, dev, blockno);
b->flags = 0;
release(&bcache.lock);
return b; // b->lock still held, refcnt 1
}
}
panic("bget: no buffers");
}
// Return a locked buf with the contents of the indicated block.
struct buf*
bread(uint dev, uint64 blockno)
{
struct buf *b;
fsstat.bread_calls++;
b = bget(dev, blockno);
if(!(b->flags & B_VALID)) {
fsstat.bread_miss++;
bdevrw(b);
}
return b;
}
// A locked buf for a block the caller is about to overwrite whole, without
// reading what is there first: Linux's getblk(). The caller must fill every
// byte and then bdirty() it.
//
// A copy-on-write filesystem writes almost nothing but blocks it has just
// allocated, and reading each one off the disk only to replace it was every
// fourth disk operation of a SchFS write.
struct buf*
bgetblk(uint dev, uint64 blockno)
{
fsstat.bread_calls++;
return bget(dev, blockno);
}
// Write b's contents to disk. Must be locked.
void
bwrite(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("bwrite");
b->flags |= B_DIRTY;
bdevrw(b);
}
// Mark b modified but do not write it. The buffer stays dirty in the cache
// until something evicts it or bflush() comes past.
//
// This is what makes a copy-on-write filesystem affordable. SchFS's B-tree
// seals and writes a node on every single record insert, so a hot leaf -- the
// one collecting a file's data checksums, say -- was written to the disk once
// per 4 KB of file. Measured over 1 MB: 1288 block writes for 256 blocks of
// data. Deferring collapses every one of those repeats into the last one.
//
// It is only safe because the filesystem asks for the ordering it needs
// explicitly, at commit, instead of assuming that write order is medium order.
// A filesystem that did assume that would be broken here -- and was already
// broken on any drive with a write cache, which is all of them.
void
bdirty(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("bdirty");
b->flags |= B_DIRTY;
b->flags |= B_VALID;
}
// Push every dirty buffer for `dev` out, then make the device itself commit
// what it has taken. Returns 0, or -1 if the device could not confirm it.
//
// Walks the list of every buffer rather than the MRU list: brelse relinks
// buffers as it goes, and creation order is the one order that cannot change
// underneath the walk. The unlocked look first is only a filter -- the lock is
// taken before anything is believed -- and it is what keeps a flush cheap when
// tens of thousands of buffers are clean.
int
bflush(uint dev)
{
struct buf *b;
for(b = bcache.all; b; b = b->anext){
if(b->dev != dev || !(b->flags & B_DIRTY))
continue;
acquire(&bcache.lock);
if(b->dev != dev || !(b->flags & B_DIRTY)){
release(&bcache.lock);
continue;
}
b->refcnt++;
release(&bcache.lock);
acquiresleep(&b->lock);
if(b->flags & B_DIRTY) // recheck: an eviction may have beaten us to it
bdevrw(b);
releasesleep(&b->lock);
acquire(&bcache.lock);
b->refcnt--;
release(&bcache.lock);
}
return bdevflush(dev);
}
// Release a locked buffer.
// Move to the head of the MRU list.
void
brelse(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
acquire(&bcache.lock);
b->refcnt--;
if (b->refcnt == 0) {
FSSTAT_DROP(fsstat.buf_pinned);
// no one is waiting for it.
b->next->prev = b->prev;
b->prev->next = b->next;
b->next = bcache.head.next;
b->prev = &bcache.head;
bcache.head.next->prev = b;
bcache.head.next = b;
}
release(&bcache.lock);
}
//PAGEBREAK!