From 32660ddd1a63eee4fef7a6113976e8f7ddb9f740 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Fri, 2 Oct 2026 15:19:41 +0200 Subject: [PATCH] feat: add ext2 block cache --- kernel/src/Api/BuildNo.hpp | 2 +- kernel/src/Fs/BlockCache.cpp | 356 +++++++++++++++++++++++++++++++++++ kernel/src/Fs/BlockCache.hpp | 45 +++++ kernel/src/Fs/Ext2.cpp | 29 +++ 4 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 kernel/src/Fs/BlockCache.cpp create mode 100644 kernel/src/Fs/BlockCache.hpp diff --git a/kernel/src/Api/BuildNo.hpp b/kernel/src/Api/BuildNo.hpp index 49dde4f..9524fa8 100644 --- a/kernel/src/Api/BuildNo.hpp +++ b/kernel/src/Api/BuildNo.hpp @@ -12,4 +12,4 @@ #pragma once -#define MONTAUK_BUILD_NUMBER 288 +#define MONTAUK_BUILD_NUMBER 289 diff --git a/kernel/src/Fs/BlockCache.cpp b/kernel/src/Fs/BlockCache.cpp new file mode 100644 index 0000000..f707373 --- /dev/null +++ b/kernel/src/Fs/BlockCache.cpp @@ -0,0 +1,356 @@ +/* + * BlockCache.cpp + * Per-mount LRU cache of filesystem blocks + * Copyright (c) 2026 Daniel Hammer +*/ + +#include "BlockCache.hpp" +#include +#include +#include +#include +#include + +using namespace Kt; + +namespace Fs::BlockCache { + + // ========================================================================= + // Sizing + // ========================================================================= + + // Capacity is free memory / FreeMemDivisor at creation, clamped to + // [MinEntries, MaxEntries]. 32768 4 KiB blocks = 128 MiB, enough to keep + // the whole native toolchain (cc1plus, as, ld, headers, libstdc++) hot. + static constexpr uint32_t MinEntries = 256; + static constexpr uint32_t MaxEntries = 32768; + static constexpr uint64_t FreeMemDivisor = 8; + + // Every storage driver accepts at least 128 sectors per command (AHCI and + // the NVMe fallback both cap there), so prefetch runs stay within it. + static constexpr uint32_t MaxRunSectors = 128; + + static constexpr uint32_t Nil = 0xFFFFFFFF; + + // ========================================================================= + // Types + // ========================================================================= + + // Entries live in one array and link by index. An entry is in exactly one + // of three states: never used (index >= nextFresh, no page), on the free + // list (has a page, not hashed), or live (hashed and on the LRU list). + struct Entry { + uint32_t block; + uint32_t hashNext; // bucket chain, or free-list link + uint32_t lruPrev; + uint32_t lruNext; + uint8_t* data; + }; + + struct Cache { + int blockDevIndex; + uint64_t partStartLba; + uint32_t blockSize; + uint32_t sectorsPerBlock; + + Entry* entries; + uint32_t* buckets; + uint32_t capacity; + uint32_t hashShift; + int tablePages; + + uint32_t nextFresh; + uint32_t freeHead; + uint32_t lruHead; // most recently used + uint32_t lruTail; // eviction candidate + + // Staging buffer for multi-block prefetch reads (MaxRunSectors long). + uint8_t* staging; + int stagingPages; + }; + + // ========================================================================= + // Device I/O + // ========================================================================= + + static bool DevRead(Cache* c, uint32_t block, uint32_t count, void* buf) { + auto* dev = Drivers::Storage::GetBlockDevice(c->blockDevIndex); + if (!dev) return false; + return dev->ReadSectors(dev->Ctx, + c->partStartLba + (uint64_t)block * c->sectorsPerBlock, + count * c->sectorsPerBlock, buf); + } + + static bool DevWrite(Cache* c, uint32_t block, const void* buf) { + auto* dev = Drivers::Storage::GetBlockDevice(c->blockDevIndex); + if (!dev) return false; + return dev->WriteSectors(dev->Ctx, + c->partStartLba + (uint64_t)block * c->sectorsPerBlock, + c->sectorsPerBlock, buf); + } + + // ========================================================================= + // Hash and LRU bookkeeping + // ========================================================================= + + static uint32_t Bucket(const Cache* c, uint32_t block) { + return (block * 0x9E3779B1u) >> c->hashShift; + } + + static uint32_t Lookup(Cache* c, uint32_t block) { + uint32_t i = c->buckets[Bucket(c, block)]; + while (i != Nil) { + if (c->entries[i].block == block) return i; + i = c->entries[i].hashNext; + } + return Nil; + } + + static void LruUnlink(Cache* c, uint32_t i) { + Entry& e = c->entries[i]; + if (e.lruPrev != Nil) c->entries[e.lruPrev].lruNext = e.lruNext; + else c->lruHead = e.lruNext; + if (e.lruNext != Nil) c->entries[e.lruNext].lruPrev = e.lruPrev; + else c->lruTail = e.lruPrev; + e.lruPrev = e.lruNext = Nil; + } + + static void LruPushFront(Cache* c, uint32_t i) { + Entry& e = c->entries[i]; + e.lruPrev = Nil; + e.lruNext = c->lruHead; + if (c->lruHead != Nil) c->entries[c->lruHead].lruPrev = i; + c->lruHead = i; + if (c->lruTail == Nil) c->lruTail = i; + } + + static void Touch(Cache* c, uint32_t i) { + if (c->lruHead == i) return; + LruUnlink(c, i); + LruPushFront(c, i); + } + + static void HashInsert(Cache* c, uint32_t i) { + uint32_t b = Bucket(c, c->entries[i].block); + c->entries[i].hashNext = c->buckets[b]; + c->buckets[b] = i; + } + + static void HashRemove(Cache* c, uint32_t i) { + uint32_t* link = &c->buckets[Bucket(c, c->entries[i].block)]; + while (*link != Nil) { + if (*link == i) { + *link = c->entries[i].hashNext; + break; + } + link = &c->entries[*link].hashNext; + } + c->entries[i].hashNext = Nil; + } + + // Take a live entry out of the cache and park it (and its page) on the + // free list. + static void Drop(Cache* c, uint32_t i) { + HashRemove(c, i); + LruUnlink(c, i); + c->entries[i].block = 0; + c->entries[i].hashNext = c->freeHead; + c->freeHead = i; + } + + // Get an entry with a page, not hashed and not on the LRU. Prefers the + // free list, then a fresh entry, then evicts the least recently used. + // Returns Nil only if the cache is empty and no page can be allocated. + static uint32_t Acquire(Cache* c) { + if (c->freeHead != Nil) { + uint32_t i = c->freeHead; + c->freeHead = c->entries[i].hashNext; + c->entries[i].hashNext = Nil; + return i; + } + + if (c->nextFresh < c->capacity) { + void* page = Memory::g_pfa->Allocate(); + if (page != nullptr) { + uint32_t i = c->nextFresh++; + Entry& e = c->entries[i]; + e.block = 0; + e.hashNext = e.lruPrev = e.lruNext = Nil; + e.data = (uint8_t*)page; + return i; + } + // Out of memory: fall through and recycle what we already hold. + } + + uint32_t victim = c->lruTail; + if (victim == Nil) return Nil; + HashRemove(c, victim); + LruUnlink(c, victim); + return victim; + } + + // Publish a freshly filled entry under `block`. + static void Insert(Cache* c, uint32_t i, uint32_t block) { + c->entries[i].block = block; + HashInsert(c, i); + LruPushFront(c, i); + } + + // ========================================================================= + // Public API + // ========================================================================= + + Cache* Create(int blockDevIndex, uint64_t partStartLba, uint32_t blockSize) { + if (blockSize < 512 || blockSize > 0x1000 || (blockSize % 512) != 0) { + return nullptr; + } + + montauk::abi::MemStats stats{}; + Memory::g_pfa->GetStats(&stats); + uint64_t want = stats.freeBytes / FreeMemDivisor / blockSize; + if (want > MaxEntries) want = MaxEntries; + if (want < MinEntries) want = MinEntries; + + // Round down to a power of two so the bucket count can equal capacity. + uint32_t capacity = MinEntries; + while ((uint64_t)capacity * 2 <= want) capacity *= 2; + + auto* c = (Cache*)Memory::g_heap->Request(sizeof(Cache)); + if (c == nullptr) return nullptr; + memset(c, 0, sizeof(Cache)); + + // Entries and buckets share one allocation. Physically consecutive + // runs can be scarce on a fragmented PFA, so halve until it fits. + while (capacity >= MinEntries) { + uint64_t bytes = (uint64_t)capacity * (sizeof(Entry) + sizeof(uint32_t)); + int pages = (int)((bytes + 0xFFF) / 0x1000); + void* table = Memory::g_pfa->ReallocConsecutive(nullptr, pages); + if (table != nullptr) { + c->entries = (Entry*)table; + c->buckets = (uint32_t*)((uint8_t*)table + (uint64_t)capacity * sizeof(Entry)); + c->tablePages = pages; + break; + } + capacity /= 2; + } + if (c->entries == nullptr) { + Memory::g_heap->Free(c); + return nullptr; + } + + uint32_t log2 = 0; + while ((1u << log2) < capacity) log2++; + + c->blockDevIndex = blockDevIndex; + c->partStartLba = partStartLba; + c->blockSize = blockSize; + c->sectorsPerBlock = blockSize / 512; + c->capacity = capacity; + c->hashShift = 32 - log2; + c->nextFresh = 0; + c->freeHead = Nil; + c->lruHead = Nil; + c->lruTail = Nil; + for (uint32_t b = 0; b < capacity; b++) c->buckets[b] = Nil; + + // Prefetch is an optimization; without a staging buffer it is a no-op. + c->stagingPages = (int)((MaxRunSectors * 512 + 0xFFF) / 0x1000); + c->staging = (uint8_t*)Memory::g_pfa->ReallocConsecutive(nullptr, c->stagingPages); + if (c->staging == nullptr) c->stagingPages = 0; + + KernelLogStream(OK, "BlockCache") << "Block cache: up to " + << base::dec << (uint64_t)capacity << " blocks (" + << (uint64_t)capacity * blockSize / (1024 * 1024) << " MiB)"; + return c; + } + + void Destroy(Cache* c) { + if (c == nullptr) return; + for (uint32_t i = 0; i < c->nextFresh; i++) { + if (c->entries[i].data != nullptr) Memory::g_pfa->Free(c->entries[i].data); + } + if (c->staging != nullptr) Memory::g_pfa->Free(c->staging, c->stagingPages); + Memory::g_pfa->Free(c->entries, c->tablePages); + Memory::g_heap->Free(c); + } + + bool Read(Cache* c, uint32_t block, void* buf) { + if (block == 0) return false; + + uint32_t i = Lookup(c, block); + if (i != Nil) { + Touch(c, i); + memcpy(buf, c->entries[i].data, c->blockSize); + return true; + } + + i = Acquire(c); + if (i == Nil) return DevRead(c, block, 1, buf); + + if (!DevRead(c, block, 1, c->entries[i].data)) { + c->entries[i].hashNext = c->freeHead; + c->freeHead = i; + return false; + } + Insert(c, i, block); + memcpy(buf, c->entries[i].data, c->blockSize); + return true; + } + + bool Write(Cache* c, uint32_t block, const void* buf) { + if (block == 0) return false; + + uint32_t i = Lookup(c, block); + if (!DevWrite(c, block, buf)) { + // The on-disk contents are now unknown; don't serve a stale copy. + if (i != Nil) Drop(c, i); + return false; + } + + if (i != Nil) { + Touch(c, i); + } else { + i = Acquire(c); + if (i == Nil) return true; + Insert(c, i, block); + } + memcpy(c->entries[i].data, buf, c->blockSize); + return true; + } + + bool Contains(Cache* c, uint32_t block) { + return block != 0 && Lookup(c, block) != Nil; + } + + uint32_t MaxRunBlocks(Cache* c) { + return MaxRunSectors / c->sectorsPerBlock; + } + + void Prefetch(Cache* c, uint32_t first, uint32_t count) { + if (c->staging == nullptr || first == 0) return; + + uint32_t maxRun = MaxRunBlocks(c); + uint32_t end = first + count; + if (end < first) return; // wrapped + + uint32_t b = first; + while (b < end) { + if (Lookup(c, b) != Nil) { b++; continue; } + + uint32_t run = 1; + while (run < maxRun && b + run < end && Lookup(c, b + run) == Nil) run++; + + if (!DevRead(c, b, run, c->staging)) return; + + for (uint32_t k = 0; k < run; k++) { + uint32_t i = Acquire(c); + if (i == Nil) return; + memcpy(c->entries[i].data, c->staging + (uint64_t)k * c->blockSize, + c->blockSize); + Insert(c, i, b + k); + } + b += run; + } + } + +}; diff --git a/kernel/src/Fs/BlockCache.hpp b/kernel/src/Fs/BlockCache.hpp new file mode 100644 index 0000000..7820824 --- /dev/null +++ b/kernel/src/Fs/BlockCache.hpp @@ -0,0 +1,45 @@ +/* + * BlockCache.hpp + * Per-mount LRU cache of filesystem blocks + * Copyright (c) 2026 Daniel Hammer +*/ + +#pragma once +#include + +namespace Fs::BlockCache { + + // A cache over one partition of a block device, in units of the + // filesystem's block size (512..4096 bytes, addressed in 512-byte sectors + // from the partition start). Write-through: Write reaches the device + // before it returns, so the cache never holds dirty data and there is + // nothing to flush on sync or unmount. + // + // Not internally locked. Callers serialize access; the VFS already holds + // vfsLock across every driver call. + struct Cache; + + // Create a cache for the partition. Capacity is sized from free memory at + // the time of the call; block pages are allocated lazily as the cache + // fills and returned on Destroy. Returns nullptr if the bookkeeping + // cannot be allocated (callers then do uncached I/O). + Cache* Create(int blockDevIndex, uint64_t partStartLba, uint32_t blockSize); + void Destroy(Cache* cache); + + // Read one block, from the cache or the device. Block 0 is never cached. + bool Read(Cache* cache, uint32_t block, void* buf); + + // Write one block to the device, then update the cached copy. + bool Write(Cache* cache, uint32_t block, const void* buf); + + bool Contains(Cache* cache, uint32_t block); + + // Pull `count` physically contiguous blocks starting at `first` into the + // cache, in as few device commands as possible. Blocks already cached are + // skipped. Best effort: a failed read just leaves those blocks uncached. + void Prefetch(Cache* cache, uint32_t first, uint32_t count); + + // Largest run Prefetch reads in one device command. + uint32_t MaxRunBlocks(Cache* cache); + +}; diff --git a/kernel/src/Fs/Ext2.cpp b/kernel/src/Fs/Ext2.cpp index 3f21539..aa1baa9 100644 --- a/kernel/src/Fs/Ext2.cpp +++ b/kernel/src/Fs/Ext2.cpp @@ -6,6 +6,7 @@ #include "Ext2.hpp" #include "FsProbe.hpp" +#include "BlockCache.hpp" #include #include #include @@ -171,6 +172,10 @@ namespace Fs::Ext2 { uint8_t* blockBuf; int blockBufPages; + // Write-through cache that every ReadBlock/WriteBlock goes through. + // Null if it could not be allocated, in which case I/O is uncached. + BlockCache::Cache* cache; + // Open file handles Ext2File files[MaxFilesPerInstance]; @@ -238,12 +243,14 @@ namespace Fs::Ext2 { static bool ReadBlock(const Ext2Instance& inst, uint32_t blockNum, void* buf) { if (blockNum == 0) return false; + if (inst.cache) return BlockCache::Read(inst.cache, blockNum, buf); return ReadPartSectors(inst, BlockToPartSector(inst, blockNum), SectorsPerBlock(inst), buf); } static bool WriteBlock(const Ext2Instance& inst, uint32_t blockNum, const void* buf) { if (blockNum == 0) return false; + if (inst.cache) return BlockCache::Write(inst.cache, blockNum, buf); return WritePartSectors(inst, BlockToPartSector(inst, blockNum), SectorsPerBlock(inst), buf); } @@ -1294,6 +1301,20 @@ namespace Fs::Ext2 { uint32_t physBlock = GetPhysicalBlock(self, file.inode, logicalBlock); if (physBlock == 0) break; + // On a miss, read ahead the physically contiguous run of this + // file's following blocks in one device command. Resolving the + // run is cheap: the indirect blocks it walks are cached too. + if (self.cache && !BlockCache::Contains(self.cache, physBlock)) { + uint32_t lastLogical = (fileSize - 1) / blockSize; + uint32_t maxRun = BlockCache::MaxRunBlocks(self.cache); + uint32_t run = 1; + while (run < maxRun && logicalBlock + run <= lastLogical && + GetPhysicalBlock(self, file.inode, logicalBlock + run) == physBlock + run) { + run++; + } + BlockCache::Prefetch(self.cache, physBlock, run); + } + if (!ReadBlock(self, physBlock, dataBuf)) break; uint32_t available = blockSize - blockOff; @@ -1882,6 +1903,7 @@ namespace Fs::Ext2 { if (self->dirNames != nullptr && self->dirNamesPages > 0) { Memory::g_pfa->Free(self->dirNames, self->dirNamesPages); } + BlockCache::Destroy(self->cache); self->active = false; g_instanceSlots[(std::size_t)inst] = nullptr; @@ -1916,6 +1938,7 @@ namespace Fs::Ext2 { if (self->bgdt != nullptr && self->bgdtPages > 0) { Memory::g_pfa->Free(self->bgdt, self->bgdtPages); } + BlockCache::Destroy(self->cache); if (self->driver != nullptr) Memory::g_heap->Free(self->driver); Memory::g_heap->Free(self); } @@ -2025,6 +2048,12 @@ namespace Fs::Ext2 { return AbortMount(idx); } + inst.cache = BlockCache::Create(blockDevIndex, startLba, blockSize); + if (!inst.cache) { + KernelLogStream(WARNING, "Ext2") << "No memory for a block cache; " + "volume I/O will be uncached"; + } + // Load block group descriptor table // BGDT starts at the block after the superblock uint32_t bgdtStartBlock = inst.firstDataBlock + 1;