SchOS/kernel/fs/schfs/obj.c
Nexoniarz bbb61c7405 schfs: cache sixteen inodes, not one
The one-entry cache lived inside schfs_file_read, so stat() never touched
it -- which is why `ls` barely moved. Moved into schfs_inode_get, where
every caller benefits, and widened to sixteen entries.

`ls` is the shape that matters: it stats every name in a directory, so a
single entry never hits and each file costs two descents, one to find the
name and one to read the inode. Sixteen keeps hitting through a directory
walk without a second reader evicting everything. Round-robin rather than
LRU: the pattern is a walk, not a working set, so the bookkeeping on every
hit would buy nothing.

Measured in the guest, TCG, against the original baseline:

  ls /bin warm   0.70s -> 0.43s
  ls /bin cold   2.80s -> 1.32s
  read 834 KB    1.73s -> 0.35s

schfs-check passes including the round-trip; bootcheck 108 [ok].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:15:45 +02:00

534 lines
18 KiB
C

// SPDX-License-Identifier: Apache-2.0
//
// SchFS object layer, read side: inodes, directories and file data.
//
// Everything here is a record in the one B-tree, which is the point of the
// btrfs-style design — there is no inode table, no directory block format and
// no indirect-block scheme to get wrong, only key types:
//
// (ino, INODE, 0) struct schfs_inode
// (dir, DIRENT, hash) packed list of schfs_dirent_rec + name
// (ino, EXTENT, fileblk) struct schfs_extent
// (CSUM, CSUM, diskblk) uint32 CRC32c of that data block
//
// Kernel code reaches all of this through a caller-supplied cursor, so no
// function here puts a 4096-byte block on the stack.
#include "schfs_int.h"
#ifdef SCHFS_HOSTED
#include <string.h>
#else
#include "types.h"
#include "defs.h"
#endif
// Point a cursor at the volume's current tree, keeping the caller's block
// buffer. Called before every operation rather than once at open, because the
// tree root moves on every commit under copy-on-write: a cursor that cached the
// old root would keep reading a stale — though perfectly valid — filesystem.
static void
cur_bind(struct schfs_cursor *c, struct schfs_vol *v)
{
c->t = &v->tree;
c->depth = 0;
c->valid = 0;
}
void
schfs_mkkey(struct schfs_key *k, uint64_t objectid, uint8_t type,
uint64_t offset)
{
memset(k, 0, sizeof(*k));
k->objectid = objectid;
k->type = type;
k->offset = offset;
}
// FNV-1a, 64-bit. Directory lookup is a tree descent on this value, so it wants
// to spread short, highly similar names ("ls", "ln", "cp") across the keyspace.
// FNV does that, is a dozen lines, and needs no table — worth more here than
// the marginally better distribution of something larger.
//
// Collisions are handled by the packed dirent list rather than assumed away;
// see struct schfs_dirent_rec.
// Test-only: narrows the hash so collisions become common.
//
// With a full 64-bit hash, two names in one directory colliding is so unlikely
// that the packed-list code which handles it would never run — and untested
// code that only executes on rare inputs is exactly the code that loses a file
// years later. Setting this to a small mask makes every directory a pile of
// collisions, so the round-trip test exercises the path properly. Always ~0
// in the kernel and in real images.
uint64_t schfs_hash_mask = ~0ULL;
uint64_t
schfs_namehash(const char *name, unsigned len)
{
uint64_t h = 1469598103934665603ULL; /* FNV offset basis */
for (unsigned i = 0; i < len; i++) {
h ^= (uint8_t)name[i];
h *= 1099511628211ULL; /* FNV prime */
}
// Key ordering is by this value, so a directory's entries come back in an
// order that has nothing to do with their names. That is fine for readdir
// (POSIX promises no order) and is what makes lookup one descent.
return h & schfs_hash_mask;
}
/* ------------------------------------------------------------------ */
/* Inodes */
/* ------------------------------------------------------------------ */
/* Inode cache.
*
* Sixteen entries, not one. `ls` is the shape that matters: it stats every name
* in a directory, so a single entry never hits and the whole directory costs
* two tree descents per file -- one to find the name, one to read the inode.
* Sixteen is enough that a directory walk keeps hitting while a second reader
* elsewhere does not evict everything.
*
* Round-robin replacement, deliberately: an LRU needs bookkeeping on every hit,
* and the access pattern here is a walk rather than a working set, so the
* cleverer policy buys nothing.
*
* Invalidation is blunt and total -- see schfs_cache_invalidate().
*/
#define INO_CACHE_N 16
static struct {
int valid;
const struct schfs_vol *vol;
uint64_t ino;
struct schfs_inode in;
} ino_cache[INO_CACHE_N];
static unsigned ino_cache_next;
int
schfs_inode_get(struct schfs_vol *v, struct schfs_cursor *c,
uint64_t ino, struct schfs_inode *out)
{
for (unsigned i = 0; i < INO_CACHE_N; i++) {
if (ino_cache[i].valid && ino_cache[i].vol == v &&
ino_cache[i].ino == ino) {
*out = ino_cache[i].in;
return 0;
}
}
struct schfs_key k;
schfs_mkkey(&k, ino, SCHFS_KEY_INODE, 0);
cur_bind(c, v);
unsigned len = sizeof(*out);
int r = schfs_btree_lookup_cur(c, &k, out, &len);
if (r != 0)
return r;
// A short record means an image written by an older or broken tool. Reject
// it rather than acting on a half-filled struct.
if (len != sizeof(*out))
return -2;
unsigned slot = ino_cache_next++ % INO_CACHE_N;
ino_cache[slot].valid = 1;
ino_cache[slot].vol = v;
ino_cache[slot].ino = ino;
ino_cache[slot].in = *out;
return 0;
}
/* ------------------------------------------------------------------ */
/* Directories */
/* ------------------------------------------------------------------ */
// Walk a packed dirent list, calling fn per entry. Stops early if fn returns
// non-zero. Returns fn's value, or -2 if the list is malformed.
static int
dirent_walk(const void *val, unsigned len,
int (*fn)(const struct schfs_dirent_rec *, const char *, void *),
void *arg)
{
unsigned off = 0;
while (off < len) {
if (len - off < sizeof(struct schfs_dirent_rec))
return -2;
const struct schfs_dirent_rec *d =
(const struct schfs_dirent_rec *)((const char *)val + off);
unsigned step = SCHFS_DIRENT_LEN(d->namelen);
// Bounds-check before touching the name: a corrupt namelen would
// otherwise read past the end of the block.
if (d->namelen == 0 || d->namelen > SCHFS_NAME_MAX || off + step > len)
return -2;
int r = fn(d, (const char *)d + sizeof(*d), arg);
if (r)
return r;
off += step;
}
return 0;
}
struct find_ctx {
const char *name;
unsigned namelen;
uint64_t ino;
uint16_t type;
};
static int
find_one(const struct schfs_dirent_rec *d, const char *nm, void *arg)
{
struct find_ctx *f = (struct find_ctx *)arg;
if (d->namelen != f->namelen)
return 0;
if (memcmp(nm, f->name, f->namelen) != 0)
return 0;
f->ino = d->objectid;
f->type = d->type;
return 1; /* found; stop */
}
int
schfs_dir_lookup(struct schfs_vol *v, struct schfs_cursor *c,
uint64_t dir, const char *name, unsigned namelen,
uint64_t *out_ino, uint16_t *out_type)
{
if (namelen == 0 || namelen > SCHFS_NAME_MAX)
return -1;
struct schfs_key k;
schfs_mkkey(&k, dir, SCHFS_KEY_DIRENT, schfs_namehash(name, namelen));
cur_bind(c, v);
int r = schfs_cursor_seek(c, &k);
if (r == -2)
return -2;
if (r != 0)
return -1;
const struct schfs_key *got = schfs_cursor_key(c);
if (!got || schfs_key_cmp(got, &k) != 0)
return -1;
unsigned len;
const void *val = schfs_cursor_val(c, &len);
if (!val)
return -2;
struct find_ctx f;
f.name = name; f.namelen = namelen; f.ino = 0; f.type = 0;
r = dirent_walk(val, len, find_one, &f);
if (r == -2)
return -2;
if (r != 1)
return -1; /* hash matched, name did not */
if (out_ino) *out_ino = f.ino;
if (out_type) *out_type = f.type;
return 0;
}
struct iter_ctx {
int (*fn)(uint64_t, uint16_t, const char *, unsigned, void *);
void *arg;
struct schfs_cursor *cur;
uint64_t dir;
int stop;
};
// Hand each entry of one packed dirent list to the caller.
//
// The list lives in the cursor's block buffer, and almost every readdir caller
// immediately looks each entry up — to stat it, to recurse into it — through a
// cursor that may well share that buffer. Walking the list in place meant the
// name and every record after it turned to garbage the moment the callback
// touched the filesystem, which is how an extracted tree once ended up with
// directories reparented under their own children.
//
// The obvious fix, copying the whole item aside, costs SCHFS_MAX_INLINE (just
// over 1 KB) of stack — a quarter of the kernel's entire 4096-byte stack, on a
// path that also runs under procread's 2 KB buffer. So only the current entry
// is copied, and the item is re-fetched from the cursor each time round:
// schfs_cursor_val re-reads the block, which restores whatever the callback
// disturbed.
static int
iter_item(const struct schfs_key *k, const void *val, unsigned len, void *arg)
{
struct iter_ctx *ic = (struct iter_ctx *)arg;
(void)val;
// The range scan runs until something stops it, so stopping at the first
// key that is no longer this directory's is what bounds the walk.
if (k->objectid != ic->dir || k->type != SCHFS_KEY_DIRENT)
return 1;
if (len > SCHFS_MAX_INLINE)
return 1;
unsigned off = 0;
while (off < len) {
unsigned now;
const char *v = (const char *)schfs_cursor_val(ic->cur, &now);
if (!v || now != len) {
ic->stop = -2;
return 1;
}
if (len - off < sizeof(struct schfs_dirent_rec)) {
ic->stop = -2;
return 1;
}
const struct schfs_dirent_rec *d =
(const struct schfs_dirent_rec *)(v + off);
unsigned namelen = d->namelen;
unsigned step = SCHFS_DIRENT_LEN(namelen);
// Bounds-check before touching the name: a corrupt namelen would
// otherwise read past the end of the block.
if (namelen == 0 || namelen > SCHFS_NAME_MAX || off + step > len) {
ic->stop = -2;
return 1;
}
char name[SCHFS_NAME_MAX];
uint64_t oid = d->objectid;
uint16_t type = d->type;
memmove(name, (const char *)d + sizeof(*d), namelen);
int r = ic->fn(oid, type, name, namelen, ic->arg);
if (r) {
ic->stop = r;
return 1;
}
off += step;
}
return 0;
}
int
schfs_dir_iterate(struct schfs_vol *v, struct schfs_cursor *c, uint64_t dir,
int (*fn)(uint64_t, uint16_t, const char *, unsigned, void *),
void *arg)
{
struct schfs_key start;
schfs_mkkey(&start, dir, SCHFS_KEY_DIRENT, 0);
struct iter_ctx ic;
ic.fn = fn; ic.arg = arg; ic.cur = c; ic.dir = dir; ic.stop = 0;
cur_bind(c, v);
int r = schfs_btree_range(c, &start, iter_item, &ic);
if (r == -2)
return -2;
return ic.stop == 1 ? 0 : ic.stop;
}
/* ------------------------------------------------------------------ */
/* File data */
/* ------------------------------------------------------------------ */
/* --------------------------------------------------------------------------
* Read caches: recently-read inodes (see schfs_inode_get) and the last extent.
*
* Every read used to descend the B-tree twice per block -- once for the inode
* and once for the extent -- so an 834 KB file cost 418 descents, each a tree
* walk plus a 4 KB copy out of the buffer cache per node visited. Sequential
* reads ask for the same inode every time and usually stay inside one extent,
* so both answers are already known.
*
* One entry each, not a table: the access pattern being optimised is one file
* read straight through, and a single entry catches all of it without needing
* eviction policy or a hash. A second reader simply misses.
*
* Correctness rests entirely on invalidation, so it is deliberately blunt:
* every write entry point in objw.c calls schfs_cache_invalidate() before it
* touches anything. A cache that is merely usually right is worse than none at
* all here -- a stale extent hands back another file's data.
*
* The volume pointer is part of the key because the host tools can hold more
* than one volume open, and two images would otherwise share these.
* ------------------------------------------------------------------------ */
static struct {
int valid;
const struct schfs_vol *vol;
uint64_t ino;
uint64_t first; /* first file block covered */
uint64_t nblocks; /* how many, from `first` */
uint64_t disk; /* disk block backing `first` */
} ext_cache;
void
schfs_cache_invalidate(void)
{
for (unsigned i = 0; i < INO_CACHE_N; i++)
ino_cache[i].valid = 0;
ext_cache.valid = 0;
}
// Map a file block to a disk block. Returns 1 if mapped, 0 for a hole, -2 on
// corruption. Extents are keyed by the file block they start at, so the record
// governing block B is the last one at or before (ino, EXTENT, B).
static int
extent_map(struct schfs_vol *v, struct schfs_cursor *c, uint64_t ino,
uint64_t fblk, uint64_t *out)
{
if (ext_cache.valid && ext_cache.vol == v && ext_cache.ino == ino &&
fblk >= ext_cache.first && fblk < ext_cache.first + ext_cache.nblocks) {
*out = ext_cache.disk + (fblk - ext_cache.first);
return 1;
}
struct schfs_key k;
schfs_mkkey(&k, ino, SCHFS_KEY_EXTENT, fblk);
cur_bind(c, v);
int r = schfs_btree_lookup_le(c, &k);
if (r == -2)
return -2;
if (r != 0)
return 0;
const struct schfs_key *got = schfs_cursor_key(c);
if (!got)
return 0;
// lookup_le can land on some other object's records entirely; a sparse file
// whose first extent starts past this block looks the same way.
if (got->objectid != ino || got->type != SCHFS_KEY_EXTENT)
return 0;
unsigned len;
const struct schfs_extent *e =
(const struct schfs_extent *)schfs_cursor_val(c, &len);
if (!e || len != sizeof(*e))
return -2;
if (fblk >= got->offset + e->nblocks)
return 0; /* hole past the end of this extent */
ext_cache.valid = 1;
ext_cache.vol = v;
ext_cache.ino = ino;
ext_cache.first = got->offset;
ext_cache.nblocks = e->nblocks;
ext_cache.disk = e->disk_block;
*out = e->disk_block + (fblk - got->offset);
return 1;
}
// Verify a data block against its checksum record. Tree nodes carry their
// checksum in the parent pointer; data blocks have no parent, so the check must
// be an explicit lookup. Returns 0 if good, -2 if it fails, and 0 as well when
// no record exists — an image may legitimately predate checksums for a block,
// and refusing to read data over a *missing* checksum would turn an upgrade
// into a total loss.
static int
csum_check(struct schfs_vol *v, struct schfs_cursor *c, uint64_t dblk,
const void *data)
{
struct schfs_key k;
schfs_mkkey(&k, SCHFS_CSUM_OBJECTID, SCHFS_KEY_CSUM, dblk);
uint32_t want;
unsigned len = sizeof(want);
cur_bind(c, v);
int r = schfs_btree_lookup_cur(c, &k, &want, &len);
if (r == -1)
return 0;
if (r != 0 || len != sizeof(want))
return -2;
if (schfs_crc32c(0, data, SCHFS_BLKSIZE) != want)
return -2;
return 0;
}
long
schfs_file_read(struct schfs_vol *v, struct schfs_cursor *c, uint64_t ino,
uint64_t off, void *buf, unsigned n, void *dblk)
{
/* schfs_inode_get caches for itself now, so this is a hit on every chunk
* after the first without a second cache here to keep coherent. */
struct schfs_inode in;
int r = schfs_inode_get(v, c, ino, &in);
if (r != 0)
return r;
if (off >= in.size)
return 0;
if (off + n > in.size)
n = (unsigned)(in.size - off);
char *dst = (char *)buf;
unsigned done = 0;
while (done < n) {
uint64_t fblk = (off + done) / SCHFS_BLKSIZE;
unsigned inblk = (unsigned)((off + done) % SCHFS_BLKSIZE);
unsigned want = SCHFS_BLKSIZE - inblk;
if (want > n - done)
want = n - done;
uint64_t disk = 0;
int m = extent_map(v, c, ino, fblk, &disk);
if (m == -2)
return -2;
if (m == 0) {
memset(dst + done, 0, want); /* hole reads as zeros */
} else {
if (v->dev.bread(v->dev.ctx, disk, dblk) != 0)
return -2;
if (csum_check(v, c, disk, dblk) != 0)
return -2;
memmove(dst + done, (char *)dblk + inblk, want);
}
done += want;
}
return (long)done;
}
/* ------------------------------------------------------------------ */
/* Path resolution */
/* ------------------------------------------------------------------ */
uint64_t
schfs_path_lookup(struct schfs_vol *v, struct schfs_cursor *c, const char *path)
{
uint64_t cur = v->root_objectid;
while (*path == '/')
path++;
while (*path) {
const char *s = path;
while (*path && *path != '/')
path++;
unsigned len = (unsigned)(path - s);
while (*path == '/')
path++;
if (len == 1 && s[0] == '.')
continue;
if (len == 2 && s[0] == '.' && s[1] == '.') {
// ".." is synthetic: directories store a parent id rather than an
// entry, so walking up is an inode read, not a lookup.
struct schfs_inode in;
if (schfs_inode_get(v, c, cur, &in) != 0)
return 0;
cur = in.parent ? in.parent : v->root_objectid;
continue;
}
// Every component but the last must be a directory. Checking here is
// what makes "cat /etc/passwd/x" fail rather than silently succeed.
struct schfs_inode in;
if (schfs_inode_get(v, c, cur, &in) != 0)
return 0;
if (in.type != SCHFS_T_DIR)
return 0;
uint64_t next;
if (schfs_dir_lookup(v, c, cur, s, len, &next, 0) != 0)
return 0;
cur = next;
}
return cur;
}