From c5e119f6be931b94d547a067b476111b0b44a4a2 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 12 Jul 2026 15:04:37 +0200 Subject: [PATCH] perf(git): cache emulated pack mmap to avoid re-reading the pack p_mmap emulated a mapping by malloc+read()ing the range from SD on every call. libgit2 re-hits the pack idx/windows on every object write (via git_odb__freshen -> git_odb_refresh), so a commit re-read pack bytes over SPI repeatedly -- ~500ms-1.1s/op on a small repo, far worse on the real 570 MB pack. Cache read-only mappings >= 64 KB (pack idx/windows, commit-graph, midx, packed-refs -- all immutable on this device) in a refcounted, PSRAM-backed slot table keyed by (dev, ino, size, mtime, offset, len), LRU-evicted under a soft cap. Small mutable working-tree maps (diff_file.c) fall below the floor and are never served stale. --- firmware/components/libgit2/esp_map.c | 179 +++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 19 deletions(-) diff --git a/firmware/components/libgit2/esp_map.c b/firmware/components/libgit2/esp_map.c index 1a04836..0018799 100644 --- a/firmware/components/libgit2/esp_map.c +++ b/firmware/components/libgit2/esp_map.c @@ -7,9 +7,24 @@ * Allocations go through git__malloc, so with PSRAM in the heap they land in * the 8 MB external RAM rather than the ~340 KB internal DRAM. * - * Limitation: writable/shared mappings are not written back (libgit2 does not - * mmap for writing in the paths we exercise). If that ever changes it will - * surface at runtime, not here. + * CACHE (2026-07-12): the emulation reads the range from SD on every call, so + * libgit2's repeated pack access — `git_odb_write` → `git_odb__freshen` → + * `git_odb_refresh` re-reads the pack idx/windows on *every* object write — + * turned a 45 ms object write into 500 ms–1.1 s on a small repo, and would be far + * worse on the real 570 MB-pack clone (see + * docs/tradeoff-curves/sync-commit-staging.md). We cache the read buffers so a + * given file region is read from the card once and reused. + * + * Correctness: cache ONLY read-only mappings >= ESP_MAP_CACHE_MIN. libgit2 maps + * pack idx/data, the commit-graph, midx and packed-refs — all immutable on this + * device (only loose objects/refs/index are written, none via mmap). The one + * mutable mmap is diff_file.c on small working-tree files (notes.md), which the + * size floor excludes, so a mutable file is never served stale. The writable + * mapping the pack indexer uses (fetch/clone) is excluded by the prot check. + * Identity is (dev, ino, size, mtime, offset, len); for an immutable pack any of + * size/mtime already differs if the file is ever replaced. + * + * Limitation: writable/shared mappings are not written back (and not cached). */ #include "git2_util.h" @@ -18,6 +33,8 @@ #include #include #include +#include +#include int git__page_size(size_t *page_size) { @@ -31,43 +48,154 @@ int git__mmap_alignment(size_t *alignment) return 0; } -int p_mmap(git_map *out, size_t len, int prot, int flags, int fd, off64_t offset) -{ +/* Only cache mappings at least this large: covers pack idx/windows, excludes the + * small mutable working-tree files diff_file.c maps. */ +#define ESP_MAP_CACHE_MIN (64 * 1024) +/* Cached-buffer budget in PSRAM. Entries with no live ref are LRU-evicted to + * stay under this; pinned entries may exceed it transiently. */ +#define ESP_MAP_CACHE_CAP (4 * 1024 * 1024) +#define ESP_MAP_CACHE_SLOTS 24 + +struct map_entry { unsigned char *data; + size_t len; + off64_t offset; + dev_t dev; + ino_t ino; + off_t size; + time_t mtime; + int refcount; + uint32_t used; /* LRU stamp; 0 = empty slot */ +}; + +static struct map_entry g_cache[ESP_MAP_CACHE_SLOTS]; +static uint32_t g_clock; +static size_t g_cached_bytes; + +/* Diagnostics, read from the bench via esp_map_stats(). */ +static uint32_t g_hits, g_misses; +static uint64_t g_read_bytes; + +void esp_map_stats(uint32_t *hits, uint32_t *misses, uint32_t *read_kb, uint32_t *cached_kb) +{ + if (hits) *hits = g_hits; + if (misses) *misses = g_misses; + if (read_kb) *read_kb = (uint32_t)(g_read_bytes / 1024); + if (cached_kb) *cached_kb = (uint32_t)(g_cached_bytes / 1024); +} + +static int read_range(int fd, off64_t offset, size_t len, unsigned char *data) +{ size_t got = 0; - GIT_UNUSED(prot); - GIT_UNUSED(flags); - GIT_MMAP_VALIDATE(out, len, prot, flags); - - out->data = NULL; - out->len = 0; - - data = git__malloc(len); - GIT_ERROR_CHECK_ALLOC(data); - if (lseek(fd, offset, SEEK_SET) < 0) { git_error_set(GIT_ERROR_OS, "failed to seek for mmap emulation"); - git__free(data); return -1; } - while (got < len) { ssize_t n = read(fd, data + got, len - got); if (n < 0) { if (errno == EINTR) continue; git_error_set(GIT_ERROR_OS, "failed to read for mmap emulation"); - git__free(data); return -1; } if (n == 0) break; /* short file: zero-fill the tail, like a real mapping */ got += (size_t)n; } - if (got < len) memset(data + got, 0, len - got); + return 0; +} + +/* Best-effort: evict unreferenced LRU entries until `need` more bytes fit the + * cap. Pinned entries (refcount > 0) can't be evicted, so the cap is soft. */ +static void evict_for(size_t need) +{ + while (g_cached_bytes + need > ESP_MAP_CACHE_CAP) { + int victim = -1; + uint32_t oldest = 0xFFFFFFFFu; + int i; + + for (i = 0; i < ESP_MAP_CACHE_SLOTS; i++) { + if (g_cache[i].used && g_cache[i].refcount == 0 && + g_cache[i].used < oldest) { + oldest = g_cache[i].used; + victim = i; + } + } + if (victim < 0) + break; + git__free(g_cache[victim].data); + g_cached_bytes -= g_cache[victim].len; + memset(&g_cache[victim], 0, sizeof(g_cache[victim])); + } +} + +int p_mmap(git_map *out, size_t len, int prot, int flags, int fd, off64_t offset) +{ + unsigned char *data; + struct stat st; + int cacheable, i; + + GIT_UNUSED(flags); + GIT_MMAP_VALIDATE(out, len, prot, flags); + + out->data = NULL; + out->len = 0; + + /* Cache only large, read-only mappings whose file we can identify. */ + cacheable = (len >= ESP_MAP_CACHE_MIN) && !(prot & GIT_PROT_WRITE); + if (cacheable && fstat(fd, &st) != 0) + cacheable = 0; + + if (cacheable) { + for (i = 0; i < ESP_MAP_CACHE_SLOTS; i++) { + struct map_entry *e = &g_cache[i]; + if (e->used && e->len == len && e->offset == offset && + e->dev == st.st_dev && e->ino == st.st_ino && + e->size == st.st_size && e->mtime == st.st_mtime) { + e->refcount++; + e->used = ++g_clock; + g_hits++; + out->data = e->data; + out->len = len; + return 0; + } + } + } + + data = git__malloc(len); + GIT_ERROR_CHECK_ALLOC(data); + if (read_range(fd, offset, len, data) < 0) { + git__free(data); + return -1; + } + g_misses++; + g_read_bytes += len; + + if (cacheable) { + evict_for(len); + for (i = 0; i < ESP_MAP_CACHE_SLOTS; i++) { + if (!g_cache[i].used) { + g_cache[i].data = data; + g_cache[i].len = len; + g_cache[i].offset = offset; + g_cache[i].dev = st.st_dev; + g_cache[i].ino = st.st_ino; + g_cache[i].size = st.st_size; + g_cache[i].mtime = st.st_mtime; + g_cache[i].refcount = 1; + g_cache[i].used = ++g_clock; + g_cached_bytes += len; + out->data = data; + out->len = len; + return 0; + } + } + /* All slots pinned: return uncached (freed directly at munmap). */ + } out->data = data; out->len = len; @@ -76,7 +204,20 @@ int p_mmap(git_map *out, size_t len, int prot, int flags, int fd, off64_t offset int p_munmap(git_map *map) { + int i; + GIT_ASSERT_ARG(map); + + /* Cached buffer: drop a ref, keep it for reuse. Otherwise free it. */ + for (i = 0; i < ESP_MAP_CACHE_SLOTS; i++) { + if (g_cache[i].used && g_cache[i].data == map->data) { + if (g_cache[i].refcount > 0) + g_cache[i].refcount--; + map->data = NULL; + map->len = 0; + return 0; + } + } git__free(map->data); map->data = NULL; map->len = 0;