From 60204a2c3349c4c0ae43eb1f27e14658a0142296 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 05:59:56 -0400 Subject: [PATCH] q4nx cache: head images are swept per key, a load of a pack that needs no conversion sweeps, and the command sweeps the writers' root (refs #2263) On top of #2271, which makes device_form() sweep and moves the sweep to npu/q4nx_cache_sweep. - npu/q4nx_cache_sweep.cpp: each head-image key (headimg-, all its tiles) is its own LRU entry, aged by its newest tile. Before, head-images/ was one entry, so the sweep could keep every pack's head or remove all of them. On strixhalo 11-12 it was 16 G of 23 G in 36 keys, most unread for a day. - npu/forward/src/q4nx_head_tile_cache.cpp: a head-image hit marks the tile it read. - app/forward_serve.cpp: both forward loads sweep once init succeeds (q4nx_cache_sweep_after_load()). A pack that needs no conversion writes head images, and device_form() sweeps only when it converts. - q4nx_cache_default_root(): $ONEBIT_Q4NX_CACHE, else ~/.cache/1bit/q4nx, shared by serve and `1bit q4nx-cache evict`. The command read ONEBIT_Q4NX_CACHE_DIR / $XDG_CACHE_HOME, which no writer uses. - tests/q4nx_cache_test.cpp: 8 new arms (40); docs/npu.md updated. Co-Authored-By: Claude Opus 5.5 --- app/forward_serve.cpp | 5 ++ app/q4nx_cache.cpp | 14 ++-- app/serve.cpp | 11 +-- docs/npu.md | 18 +++-- npu/forward/src/q4nx_head_tile_cache.cpp | 5 ++ npu/q4nx_cache_sweep.cpp | 97 +++++++++++++++++++++++- npu/q4nx_cache_sweep.h | 21 ++++- tests/q4nx_cache_test.cpp | 78 ++++++++++++++++++- 8 files changed, 219 insertions(+), 30 deletions(-) diff --git a/app/forward_serve.cpp b/app/forward_serve.cpp index fa9204753..40ec8f201 100644 --- a/app/forward_serve.cpp +++ b/app/forward_serve.cpp @@ -34,6 +34,7 @@ // Requests run one at a time: the forward holds one KV cache, and each request // resets it from position 0. #include "forward_serve.h" +#include "q4nx_cache.h" // engine#2263: the sweep after a forward load #include "../npu/forward/include/file_create.h" // dumps are created 0600, never through a symlink #include "npu_decode_note.h" @@ -1105,6 +1106,7 @@ int run_forward_ids(const std::string& model_dir, const std::string& kernels, co Q4nxNpuForward fwd; const bool ok = dev ? fwd.init(*dev, model.c_str(), kernels) : fwd.init_host(model.c_str(), kernels); if (!ok) throw std::runtime_error("forward init failed: " + std::string(fwd.last_error())); + q4nx_cache_sweep_after_load(); // engine#2263: the load wrote the device form and head images into the cache const int width = fwd.max_batch_rows(); if (int(ids.size()) > width) std::fprintf(stderr, "1bit npu-run: %zu ids, so this runs in passes of %d rows (the kernel's width)\n", ids.size(), @@ -1386,6 +1388,9 @@ int run_forward_serve(int argc, char** argv) { } throw std::runtime_error(why); } + // engine#2263: loading a pack writes into the Q4NX cache (the device-form copy, the head images) and, until this, only a + // GGUF repack swept it -- so a box that serves packs, or runs reviews through npu-run, grew it without bound. + q4nx_cache_sweep_after_load(); // A d3 pack keeps its decision head next to model.q4nx (npu/decision.h). Both files present means // the decision routes come up beside the chat ones; a pack without them is a chat model, which is diff --git a/app/q4nx_cache.cpp b/app/q4nx_cache.cpp index 5d2eb8d27..722ee97b5 100644 --- a/app/q4nx_cache.cpp +++ b/app/q4nx_cache.cpp @@ -32,7 +32,7 @@ int run_q4nx_cache(int argc, char** argv) { " [--dry-run]\n" " Sweeps the container cache the way a serve does on start: least-recently-used first, never the\n" " entry named by --keep, never one written within --min-age seconds (a serve may be reading it).\n" - " --root defaults to ONEBIT_Q4NX_CACHE_DIR, then $XDG_CACHE_HOME/1bit/q4nx, then ~/.cache/1bit/q4nx.\n" + " --root defaults to ONEBIT_Q4NX_CACHE, then ~/.cache/1bit/q4nx: the directory serve and the forward write.\n" " --max-gib defaults to ONEBIT_Q4NX_CACHE_MAX_GIB(), then 60; 0 disables the sweep.\n" " --dry-run prints what it would remove and removes nothing.\n"); }; @@ -55,14 +55,10 @@ int run_q4nx_cache(int argc, char** argv) { else { std::fprintf(stderr, "q4nx-cache: unknown argument %s\n", a.c_str()); usage(); return 2; } } if (root.empty()) { - const char* env = std::getenv("ONEBIT_Q4NX_CACHE_DIR"); - if (env && *env) { - root = env; - } else { - const char* xdg = std::getenv("XDG_CACHE_HOME"); - const char* home = std::getenv("HOME"); - root = (xdg && *xdg) ? std::string(xdg) : std::string(home ? home : ".") + "/.cache"; - root += "/1bit/q4nx"; + root = q4nx_cache_default_root(); + if (root.empty()) { + std::fprintf(stderr, "q4nx-cache: no home directory for the Q4NX cache: set HOME or ONEBIT_Q4NX_CACHE, or pass --root\n"); + return 2; } } const int64_t max_bytes = (max_mib >= 0) ? max_mib * (int64_t(1) << 20) diff --git a/app/serve.cpp b/app/serve.cpp index c708eb3d7..bf4bffdb2 100644 --- a/app/serve.cpp +++ b/app/serve.cpp @@ -2952,14 +2952,11 @@ std::string command_text(const std::vector& argv) { // be /tmp/.cache, which every local user can write; a cached directory there would be served // (weights, tokenizer, kernel directory) without knowing who made it. The home directory now // comes from the user database instead, and serve refuses rather than guess. +// engine#2263: one resolution (app/q4nx_cache.h), shared with `1bit q4nx-cache evict`, so the command sweeps this directory. std::string q4nx_cache_root() { - if (const char* c = std::getenv("ONEBIT_Q4NX_CACHE"); c && *c) return c; - if (const char* h = std::getenv("HOME"); h && *h) return std::string(h) + "/.cache/1bit/q4nx"; -#ifndef _WIN32 - if (const passwd* pw = ::getpwuid(::geteuid()); pw && pw->pw_dir && *pw->pw_dir) - return std::string(pw->pw_dir) + "/.cache/1bit/q4nx"; -#endif - throw std::runtime_error("no home directory for the Q4NX cache: set HOME or ONEBIT_Q4NX_CACHE"); + std::string root = q4nx_cache_default_root(); + if (root.empty()) throw std::runtime_error("no home directory for the Q4NX cache: set HOME or ONEBIT_Q4NX_CACHE"); + return root; } // Repack a GGUF into a Q4NX model directory with the FLM_Q4NX_Converter diff --git a/docs/npu.md b/docs/npu.md index 10bf0be04..08148b335 100644 --- a/docs/npu.md +++ b/docs/npu.md @@ -306,9 +306,12 @@ entry, `1bit serve` removes the least-recently-used entries until the cache is w `ONEBIT_Q4NX_CACHE_MAX_GIB` (default 60; `0` or a negative number turns the sweep off). The forward's device-form conversion (`npu/q4nx_device.cpp`) sweeps the same way after it writes a new entry (engine#2263), so a box that converts through the forward and never starts a serve stays -within the bound too. The sweep itself is `npu/q4nx_cache_sweep.h`, shared by both writers and by -`1bit q4nx-cache evict`. A box that writes nothing never sweeps; `1bit q4nx-cache evict` is the -manual trigger for that case. The entry +within the bound too. A forward load (`1bit forward-serve`, `1bit npu-run` on a sealed set, the +host-only route too) also sweeps once it has loaded, because a pack that needs no conversion still +writes its head images (below) into the cache. The sweep itself is `npu/q4nx_cache_sweep.h`, +shared by every writer and by `1bit q4nx-cache evict`, which without `--root` sweeps the directory +every writer uses (`$ONEBIT_Q4NX_CACHE`, else `~/.cache/1bit/q4nx`). A box that writes nothing never +sweeps; `1bit q4nx-cache evict` is the manual trigger for that case. The entry just written and any entry used in the last ten minutes are never removed. "Used" means reused, not only written: a cache hit — a repacked GGUF found in the cache, or a device-form conversion reused at load — marks the entry's directory as used now, so a container imported once and served @@ -340,10 +343,11 @@ The serve log line says what happened: `lm head: N packed weight tile(s) residen image cache, P packed (W written for the next serve) in S s`. `ONEBIT_HEAD_IMAGE_CACHE=0` turns the cache off (nothing is read or written). The files are `kd × nd` bytes plus `nd` scales per tile, so a head costs about `kpad × vocabulary` bytes on disk per kernel set. The Q4NX cache sweep -(`ONEBIT_Q4NX_CACHE_MAX_GIB`, above) counts `head-images/` as one entry of the cache and removes it -whole when the cache is over its bound and that directory is the least recently written one. Its -age is the last time a tile was written into it: a serve that only reads its tiles does not refresh -it. Losing the directory costs the next serve one pack of its head, nothing else. Ternary (`pb16i`) heads and the gemv head do not +(`ONEBIT_Q4NX_CACHE_MAX_GIB`, above) counts each key in `head-images/` (`headimg-`, all its +tiles) as one entry of the cache (engine#2263; it used to count the directory as one entry, so the +sweep could only keep every pack's head or remove all of them). A key's age is its newest tile, and +a cache hit marks the tile it read as used, so a head served every day is not aged from the day it +was packed. Losing a key costs the next serve of that pack one pack of its head, nothing else. Ternary (`pb16i`) heads and the gemv head do not use it: their image already is a file. Each writer builds the file under its own name (`.tmp.`) and renames it into place, so two serves that start cold on the same pack both finish their write and a reader only ever sees a whole image. The behaviour is pinned host-side in diff --git a/npu/forward/src/q4nx_head_tile_cache.cpp b/npu/forward/src/q4nx_head_tile_cache.cpp index 184d879d3..8f74c910c 100644 --- a/npu/forward/src/q4nx_head_tile_cache.cpp +++ b/npu/forward/src/q4nx_head_tile_cache.cpp @@ -25,6 +25,7 @@ #include #include +#include #include namespace { @@ -139,6 +140,10 @@ extern "C" int q4nx_head_tile_image(const char* dir, const Q4nxHeadImageKey* key if (q4nx_head_image_read(path, codes, (int)image_bytes, scales, nd, &info, why, (int)sizeof why)) { if (sample_agrees(*key, n0, ncols, k, kd, nd, col, ctx, codes, scales, res)) { res->hit = 1; + // engine#2263: a hit is a use. The Q4NX cache sweep ages a head-image key by its newest file, and a read + // does not move a file's mtime, so without this a pack served every day aged from the day it was packed. + std::error_code tec; + std::filesystem::last_write_time(path, std::filesystem::file_time_type::clock::now(), tec); return 1; } res->refused = 1; // `why` was set by the sample diff --git a/npu/q4nx_cache_sweep.cpp b/npu/q4nx_cache_sweep.cpp index 58ab2cb4e..e65a594fa 100644 --- a/npu/q4nx_cache_sweep.cpp +++ b/npu/q4nx_cache_sweep.cpp @@ -22,6 +22,11 @@ #include #include +#ifndef _WIN32 +#include +#include +#endif + namespace fs = std::filesystem; std::string q4nx_cache_size_text(int64_t bytes) { @@ -50,12 +55,57 @@ int64_t q4nx_dir_bytes(const std::string& path) { return total; } +namespace { +struct HeadGroup { std::string path; int64_t bytes = 0; fs::file_time_type mtime{}; std::vector files; }; + +// The key a head-image file belongs to: "headimg-<16 hex>" from "headimg-<16 hex>-t.q4hi" (or its ".tmp." temp). +// Any other name is its own key, so a file this sweep does not recognise is still counted and still evictable. +std::string head_image_key(const std::string& name) { + static const std::string pre = "headimg-"; + const size_t n = pre.size() + 16; + if (name.size() > n && name.compare(0, pre.size(), pre) == 0 && name[n] == '-') return name.substr(0, n); + return name; +} + +// The groups of one head-images directory: bytes summed over a key's files, age = its newest file (a hit marks its tile used, +// npu/forward/src/q4nx_head_tile_cache.cpp), so a key a serve is still loading from is inside the age window. +std::vector head_image_groups(const fs::path& dir) { + std::vector out; + std::vector keys; + std::error_code ec; + for (auto it = fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec); + !ec && it != fs::directory_iterator(); it.increment(ec)) { + std::error_code fec; + if (!it->is_regular_file(fec)) continue; + const int64_t b = int64_t(fs::file_size(it->path(), fec)); + if (fec) continue; + const auto t = fs::last_write_time(it->path(), fec); + if (fec) continue; + const std::string key = head_image_key(it->path().filename().string()); + size_t i = 0; + while (i < keys.size() && keys[i] != key) ++i; + if (i == keys.size()) { + keys.push_back(key); + HeadGroup g; + g.path = (dir / key).string(); + g.mtime = t; + out.push_back(std::move(g)); + } + HeadGroup& g = out[i]; + g.bytes += b; + if (t > g.mtime) g.mtime = t; + g.files.push_back(it->path().string()); + } + return out; +} +} // namespace + std::vector q4nx_cache_candidates(const std::string& root, const std::string& keep, int64_t max_bytes, int64_t min_age_seconds) { std::vector out; std::error_code ec; if (max_bytes <= 0 || !fs::is_directory(root, ec)) return out; - struct Ent { std::string path; int64_t bytes; fs::file_time_type mtime; }; + struct Ent { std::string path; int64_t bytes; fs::file_time_type mtime; std::vector files; }; std::vector all; for (auto it = fs::directory_iterator(root, fs::directory_options::skip_permission_denied, ec); !ec && it != fs::directory_iterator(); it.increment(ec)) { @@ -63,6 +113,12 @@ std::vector q4nx_cache_candidates(const std::string& root, c e.path = it->path().string(); std::error_code tec; if (!it->is_directory(tec)) continue; + if (it->path().filename() == kQ4nxHeadImagesDir) { + // engine#2263: one entry per head-image key, not one for the directory. As one entry the sweep could only + // keep every pack's head or remove all of them (16 G in 36 keys on strixhalo 11-12, most unread for a day). + for (auto& g : head_image_groups(it->path())) all.push_back({g.path, g.bytes, g.mtime, std::move(g.files)}); + continue; + } e.bytes = q4nx_dir_bytes(e.path); if (!keep.empty() && (e.path == keep || fs::path(e.path).filename() == fs::path(keep).filename())) continue; e.mtime = fs::last_write_time(it->path(), tec); @@ -80,7 +136,14 @@ std::vector q4nx_cache_candidates(const std::string& root, c for (const auto& e : all) after += e.bytes; for (Ent* e : cands) { if (after <= max_bytes) break; - out.push_back({fs::path(e->path).filename().string(), e->bytes}); + // A head-image key is named by its path under the root ("head-images/headimg-"), a directory entry by + // its own name, so `--dry-run` and the sweep's line say which one it is. + Q4nxCacheCandidate c; + c.name = fs::path(e->path).lexically_relative(root).string(); + if (c.name.empty() || c.name == ".") c.name = fs::path(e->path).filename().string(); + c.bytes = e->bytes; + c.files = e->files; + out.push_back(std::move(c)); after -= e->bytes; } return out; @@ -95,8 +158,18 @@ Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep const std::vector cands = q4nx_cache_candidates(root, keep, max_bytes, min_age_seconds); for (const auto& cand : cands) { std::error_code rec; - fs::remove_all(fs::path(root) / cand.name, rec); - if (rec) continue; + if (!cand.files.empty()) { + // A head-image key: its tile files (and any temp a crashed writer left under the same key), not the directory. + bool any = false; + for (const auto& f : cand.files) { + std::error_code fec; + if (fs::remove(f, fec)) any = true; + } + if (!any) continue; + } else { + fs::remove_all(fs::path(root) / cand.name, rec); + if (rec) continue; + } r.bytes_after -= cand.bytes; ++r.removed; } @@ -130,3 +203,19 @@ void q4nx_cache_touch(const std::string& entry) { std::error_code ec; if (fs::is_directory(entry, ec)) fs::last_write_time(entry, fs::file_time_type::clock::now(), ec); } + +std::string q4nx_cache_default_root() { + if (const char* c = std::getenv("ONEBIT_Q4NX_CACHE"); c && *c) return c; + if (const char* h = std::getenv("HOME"); h && *h) return std::string(h) + "/.cache/1bit/q4nx"; +#ifndef _WIN32 + if (const struct passwd* pw = ::getpwuid(::geteuid()); pw && pw->pw_dir && *pw->pw_dir) + return std::string(pw->pw_dir) + "/.cache/1bit/q4nx"; +#endif + return std::string(); +} + +Q4nxCacheSweep q4nx_cache_sweep_after_load() { + const std::string root = q4nx_cache_default_root(); + if (root.empty()) return Q4nxCacheSweep{}; + return q4nx_cache_evict(root, std::string(), q4nx_cache_bound_bytes(std::getenv("ONEBIT_Q4NX_CACHE_MAX_GIB"))); +} diff --git a/npu/q4nx_cache_sweep.h b/npu/q4nx_cache_sweep.h index fce248c6e..63618c7c7 100644 --- a/npu/q4nx_cache_sweep.h +++ b/npu/q4nx_cache_sweep.h @@ -51,9 +51,15 @@ Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep // q4nx_cache_evict() makes (LRU first, `keep` and the age window respected, stop at max_bytes), returned instead of applied, // so the two cannot drift: evict() is now this list plus a remove loop. struct Q4nxCacheCandidate { - std::string name; // the entry directory's name, as it appears under the root + std::string name; // the entry's path under the root: a directory's name, or "head-images/headimg-" for a key int64_t bytes = 0; + std::vector files; // engine#2263: a head-image key's files (removed one by one); empty for a directory }; + +// engine#2263: `/head-images` holds the forward's lm-head images, one file per tile, for every pack and kernel set a +// serve has loaded. The sweep treats each key ("headimg-", all its tiles) as an entry of its own, aged by its newest +// file, instead of the directory as one entry it could only keep or remove whole. +inline constexpr const char* kQ4nxHeadImagesDir = "head-images"; std::vector q4nx_cache_candidates(const std::string& root, const std::string& keep, int64_t max_bytes, int64_t min_age_seconds = 600); @@ -77,3 +83,16 @@ int64_t q4nx_cache_bound_bytes(const char* text, int64_t default_gib = 60); // just-opened entry inside the sweep's age window, which is what makes "a serve may be reading it" true for a reused entry // and not only for a new one. Failure is ignored (a read-only cache still serves). void q4nx_cache_touch(const std::string& entry); + +// engine#2263: the cache root every writer uses (app/serve.cpp's repack, npu/q4nx_device.cpp's device form, the forward's +// head images): $ONEBIT_Q4NX_CACHE, else ~/.cache/1bit/q4nx with the home from HOME or the user database. Empty when there +// is no home. `1bit q4nx-cache evict` used to resolve ONEBIT_Q4NX_CACHE_DIR and $XDG_CACHE_HOME instead, which no writer +// reads, so with either set it swept a directory nothing wrote to. +std::string q4nx_cache_default_root(); + +// engine#2263: the sweep a forward load runs (app/forward_serve.cpp). A pack that needs no conversion still writes its head +// images into the cache, and device_form() sweeps only when it converts. Same bound (ONEBIT_Q4NX_CACHE_MAX_GIB) and age +// window; nothing is kept by name, since a load marks what it uses (q4nx_cache_touch, the head-image hit) and the age +// window protects it. +Q4nxCacheSweep q4nx_cache_sweep_after_load(); + diff --git a/tests/q4nx_cache_test.cpp b/tests/q4nx_cache_test.cpp index 98e1461f0..c1a9877b4 100644 --- a/tests/q4nx_cache_test.cpp +++ b/tests/q4nx_cache_test.cpp @@ -21,10 +21,12 @@ #include #include +#include #include #include #include #include +#include #include "tmp_root.h" // engine#2029: scratch goes on disk, not in tmpfs namespace fs = std::filesystem; @@ -189,9 +191,81 @@ int main() { check(q4nx_cache_size_text((int64_t(1) << 30) - 1) == "1.0 GiB", "size text: just under 1 GiB is 1.0 GiB, not \"1024.0 MiB\""); check(q4nx_cache_size_text(int64_t(1023) << 20) == "1023.0 MiB", "size text: a real 1023 MiB still reads MiB"); check(q4nx_cache_size_text(int64_t(60) << 30) == "60.0 GiB", "size text: 60 GiB is GiB"); + // engine#2263: head-images/ is swept per key, not as one entry. Two keys of 2 MiB (two 1 MiB tiles each) beside a 2 MiB + // container: over a 4 MiB bound only the older KEY goes, its tiles and nothing else. + { + const fs::path cr = root / "heads"; + const fs::path hd = cr / kQ4nxHeadImagesDir; + fs::create_directories(hd); + make_entry(cr, "pack-a", 2, 300); + const std::string ka = "headimg-00000000000000aa", kb = "headimg-00000000000000bb"; + const auto tile = [&](const std::string& key, int t, int age_minutes) { + char name[64]; + std::snprintf(name, sizeof name, "%s-t%03d.q4hi", key.c_str(), t); + const fs::path f = hd / name; + std::ofstream o(f, std::ios::binary); + o << std::string(size_t(1024) * 1024, 'h'); + o.close(); + std::error_code tec; + fs::last_write_time(f, fs::file_time_type::clock::now() - std::chrono::minutes(age_minutes), tec); + return f; + }; + const fs::path a0 = tile(ka, 0, 600), a1 = tile(ka, 1, 590), b0 = tile(kb, 0, 200), b1 = tile(kb, 1, 190); + const int64_t bound = 4 * 1024 * 1024; + const std::vector c = q4nx_cache_candidates(cr.string(), "", bound, 0); + check(c.size() == 1 && c[0].name == std::string(kQ4nxHeadImagesDir) + "/" + ka, + "q4nx-cache head images: the oldest KEY is the candidate, not the whole directory"); + check(c.size() == 1 && c[0].bytes == 2 * 1024 * 1024 && c[0].files.size() == 2, + "q4nx-cache head images: a key carries all its tiles and their bytes"); + q4nx_cache_evict(cr.string(), "", bound, 0); + check(!fs::exists(a0) && !fs::exists(a1), "q4nx-cache head images: the evicted key's tiles are removed"); + check(fs::exists(b0) && fs::exists(b1) && fs::exists(cr / "pack-a"), + "q4nx-cache head images: the other key and the container survive"); + // a key whose newest tile is recent is inside the age window, even with an old tile (a hit marks the tile it read) + tile(ka, 0, 600); + tile(ka, 1, 1); + check(q4nx_cache_candidates(cr.string(), "", 1, 3600).size() == 2, + "q4nx-cache head images: a key is aged by its newest tile (only the old key and the container qualify)"); + fs::remove_all(cr, ec); + } + // engine#2263: the command resolves the root every writer uses ($ONEBIT_Q4NX_CACHE, else ~/.cache/1bit/q4nx), not + // ONEBIT_Q4NX_CACHE_DIR / $XDG_CACHE_HOME, which no writer reads. + { + const char* keep_env[] = {"ONEBIT_Q4NX_CACHE", "ONEBIT_Q4NX_CACHE_DIR", "XDG_CACHE_HOME", "HOME"}; + std::string saved[4]; + bool had[4]; + for (int i = 0; i < 4; ++i) { + const char* v = std::getenv(keep_env[i]); + had[i] = v != nullptr; + if (v) saved[i] = v; + } + const fs::path cr = root / "envroot"; + setenv("ONEBIT_Q4NX_CACHE", cr.string().c_str(), 1); + setenv("ONEBIT_Q4NX_CACHE_DIR", (root / "elsewhere").string().c_str(), 1); + check(q4nx_cache_default_root() == cr.string(), "q4nx-cache root: ONEBIT_Q4NX_CACHE wins, as for every writer"); + unsetenv("ONEBIT_Q4NX_CACHE"); + setenv("HOME", (root / "home").string().c_str(), 1); + setenv("XDG_CACHE_HOME", (root / "xdg").string().c_str(), 1); + check(q4nx_cache_default_root() == (root / "home").string() + "/.cache/1bit/q4nx", + "q4nx-cache root: then HOME, and neither ONEBIT_Q4NX_CACHE_DIR nor XDG_CACHE_HOME moves it"); + // and the command without --root sweeps that directory + setenv("ONEBIT_Q4NX_CACHE", cr.string().c_str(), 1); + make_entry(cr, "e-old", 2, 500); + make_entry(cr, "e-new", 2, 400); + std::vector a = {"evict", "--max-mib", "3", "--min-age", "0"}; + std::vector av = mkargv(a); + ::run_q4nx_cache(int(av.size()), av.data()); + check(!fs::exists(cr / "e-old") && fs::exists(cr / "e-new"), + "q4nx-cache evict: with no --root it sweeps $ONEBIT_Q4NX_CACHE"); + for (int i = 0; i < 4; ++i) { + if (had[i]) setenv(keep_env[i], saved[i].c_str(), 1); + else unsetenv(keep_env[i]); + } + fs::remove_all(cr, ec); + } fs::remove_all(root, ec); std::printf("q4nx cache: %d arm(s) over the sweep (bound, LRU order, keep, age window, disabled, touch on a hit, bound text, " - "size text)\n", - failures ? 0 : 36); + "size text, head images per key, one root)\n", + failures ? 0 : 44); return failures ? 1 : 0; }