Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/forward_serve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
14 changes: 5 additions & 9 deletions app/q4nx_cache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
};
Expand All @@ -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)
Expand Down
11 changes: 4 additions & 7 deletions app/serve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2952,14 +2952,11 @@ std::string command_text(const std::vector<std::string>& 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
Expand Down
18 changes: 11 additions & 7 deletions docs/npu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<digest>`, 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
(`<file>.tmp.<pid>`) 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
Expand Down
5 changes: 5 additions & 0 deletions npu/forward/src/q4nx_head_tile_cache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

#include <cstdio>
#include <cstring>
#include <filesystem>
#include <vector>

namespace {
Expand Down Expand Up @@ -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
Expand Down
97 changes: 93 additions & 4 deletions npu/q4nx_cache_sweep.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
#include <filesystem>
#include <vector>

#ifndef _WIN32
#include <pwd.h>
#include <unistd.h>
#endif

namespace fs = std::filesystem;

std::string q4nx_cache_size_text(int64_t bytes) {
Expand Down Expand Up @@ -50,19 +55,70 @@ 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<std::string> files; };

// The key a head-image file belongs to: "headimg-<16 hex>" from "headimg-<16 hex>-t<tile>.q4hi" (or its ".tmp.<pid>" 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<HeadGroup> head_image_groups(const fs::path& dir) {
std::vector<HeadGroup> out;
std::vector<std::string> 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<Q4nxCacheCandidate> q4nx_cache_candidates(const std::string& root, const std::string& keep,
int64_t max_bytes, int64_t min_age_seconds) {
std::vector<Q4nxCacheCandidate> 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<std::string> files; };
std::vector<Ent> all;
for (auto it = fs::directory_iterator(root, fs::directory_options::skip_permission_denied, ec);
!ec && it != fs::directory_iterator(); it.increment(ec)) {
Ent e;
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);
Expand All @@ -80,7 +136,14 @@ std::vector<Q4nxCacheCandidate> 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-<digest>"), 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;
Expand All @@ -95,8 +158,18 @@ Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep
const std::vector<Q4nxCacheCandidate> 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;
}
Expand Down Expand Up @@ -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")));
}
21 changes: 20 additions & 1 deletion npu/q4nx_cache_sweep.h
Original file line number Diff line number Diff line change
Expand Up @@ -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-<digest>" for a key
int64_t bytes = 0;
std::vector<std::string> files; // engine#2263: a head-image key's files (removed one by one); empty for a directory
};

// engine#2263: `<root>/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-<digest>", 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<Q4nxCacheCandidate> q4nx_cache_candidates(const std::string& root, const std::string& keep, int64_t max_bytes,
int64_t min_age_seconds = 600);

Expand All @@ -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();

Loading
Loading