diff --git a/CMakeLists.txt b/CMakeLists.txt index ddf45bd32..71b63011b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,7 +95,7 @@ file(WRITE ${CMAKE_BINARY_DIR}/generated/laya_first_default.h "static const char kDefaultLayaFirstJson[] = R\"ONEBITLAYAFIRST(${ONEBIT_LAYA_FIRST_JSON})ONEBITLAYAFIRST\";\n") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/config/laya-first.json) add_executable(onebit app/main.cpp app/serve.cpp app/recipes.cpp app/pm.cpp app/route_serve.cpp app/route_eval.cpp app/laya_checkpoint.cpp app/laya_first.cpp - app/embed_serve.cpp app/embed_model.cpp) + app/embed_serve.cpp app/embed_model.cpp app/q4nx_cache.cpp) # config/recipes.json (tuned llama-server settings per model and route, docs/recipes.md) is # compiled in as serve's default recipes; --recipes replaces them at run time. file(READ ${CMAKE_SOURCE_DIR}/config/recipes.json ONEBIT_RECIPES_JSON) @@ -1067,6 +1067,13 @@ add_dependencies(npu_sampling_test npu_queue_test) # sentences) needs the 17.9 MB container the importer writes (the source safetensors is 13.9 MB) and stays # a measured row in the PR. add_executable(embed_model_test tests/embed_model_test.cpp app/embed_model.cpp) +# engine#1956: the Q4NX container cache's sweep, driven on a synthetic cache (bound, LRU order, the entry just written, the +# age window, and the disabled case). Listed explicitly like the other app/ units so the test needs no `onebit`. +add_executable(q4nx_cache_test tests/q4nx_cache_test.cpp app/q4nx_cache.cpp) +target_include_directories(q4nx_cache_test PRIVATE app) +add_test(NAME q4nx_cache COMMAND q4nx_cache_test) +# engine#1956 review: an `add_test` whose target ci.yml never builds never runs in CI -- the documented route, no workflow edit. +add_dependencies(onebit q4nx_cache_test) target_include_directories(embed_model_test PRIVATE ${CMAKE_SOURCE_DIR}/app) target_link_libraries(embed_model_test PRIVATE nlohmann_json::nlohmann_json) target_compile_definitions(embed_model_test PRIVATE SRC_DIR="${CMAKE_SOURCE_DIR}") diff --git a/app/q4nx_cache.cpp b/app/q4nx_cache.cpp new file mode 100644 index 000000000..361974546 --- /dev/null +++ b/app/q4nx_cache.cpp @@ -0,0 +1,85 @@ +// Copyright 2026 bong-water-water-bong +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +#pragma once +#include "q4nx_cache.h" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +int64_t q4nx_dir_bytes(const std::string& path) { + std::error_code ec; + if (!fs::is_directory(path, ec)) return 0; + int64_t total = 0; + for (auto it = fs::recursive_directory_iterator(path, fs::directory_options::skip_permission_denied, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + std::error_code fec; + if (it->is_regular_file(fec)) total += int64_t(fs::file_size(it->path(), fec)); + if (fec) continue; + } + return total; +} + +Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep, int64_t max_bytes, + int64_t min_age_seconds) { + Q4nxCacheSweep r; + std::error_code ec; + if (max_bytes <= 0 || !fs::is_directory(root, ec)) return r; + struct Ent { std::string path; int64_t bytes; fs::file_time_type mtime; }; + 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)) { + std::error_code tec; + if (!it->is_directory(tec)) continue; + Ent e; + e.path = it->path().string(); + if (!keep.empty() && (e.path == keep || fs::path(e.path).filename() == fs::path(keep).filename())) continue; // never the new one + e.bytes = q4nx_dir_bytes(e.path); + e.mtime = fs::last_write_time(it->path(), tec); + if (tec) continue; + r.bytes_before += e.bytes; + all.push_back(std::move(e)); + } + if (all.empty()) return r; + r.bytes_after = r.bytes_before; + const auto now = fs::file_time_type::clock::now(); + const auto min_age = std::chrono::seconds(min_age_seconds); + std::vector candidates; // LRU first; the age window protects an entry a serve may be reading + for (auto& e : all) + if (now - e.mtime > min_age) candidates.push_back(&e); + std::sort(candidates.begin(), candidates.end(), [](const Ent* a, const Ent* b) { return a->mtime < b->mtime; }); + for (Ent* e : candidates) { + if (r.bytes_after <= max_bytes) break; + std::error_code rec; + fs::remove_all(e->path, rec); + if (rec) continue; + r.bytes_after -= e->bytes; + ++r.removed; + } + if (r.removed > 0) { + r.reported = true; + std::fprintf(stderr, + "note: q4nx cache: removed %d entr%s, %.1f GiB -> %.1f GiB (bound %.1f GiB, %s); entries younger than " + "%lld s and the one just written are kept (engine#1956)\n", + r.removed, r.removed == 1 ? "y" : "ies", double(r.bytes_before) / (1 << 30), + double(r.bytes_after) / (1 << 30), double(max_bytes) / (1 << 30), root.c_str(), + (long long)min_age_seconds); + } + return r; +} diff --git a/app/q4nx_cache.h b/app/q4nx_cache.h new file mode 100644 index 000000000..53783bac3 --- /dev/null +++ b/app/q4nx_cache.h @@ -0,0 +1,46 @@ +// Copyright 2026 bong-water-water-bong +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +#pragma once +#pragma once + +#include +#include + +// The Q4NX container cache (engine#1956): app/serve.cpp repacks a GGUF into `$ONEBIT_Q4NX_CACHE` (default +// ~/.cache/1bit/q4nx) and reuses the result, and until this existed nothing ever removed an old entry. Measured on strixhalo +// 2026-11-10: 104 G in 29 entries, out of a 118 G cache, on a box whose root filesystem was down to 119 G -- which is why two +// lanes' build gates closed the same day. This is the same defect as #1770 -> #1797 one layer down (the .fconst scratch that +// only ever accumulated), with one difference that matters: this cache is SERVED FROM, so an entry may be in use. +struct Q4nxCacheSweep { + int removed = 0; + int64_t bytes_before = 0, bytes_after = 0; + bool reported = false; // true when a line was printed (i.e. something was removed) +}; + +// Remove least-recently-used entries under `root` until the total is at most `max_bytes`. +// +// Not evicted, ever: `keep` (the entry just written), and any entry whose mtime is within `min_age_seconds` -- a serve may be +// reading it right now, and this sweep cannot see other processes' open files. That is a heuristic, not a guarantee: a lane +// serving an entry older than the age window could still have it removed underneath it, which costs that lane a re-import and +// not a crash (the entry is regenerable from its GGUF). `max_bytes <= 0` disables the sweep entirely, which is what a lane that +// wants the old unbounded behaviour sets. +// +// Prints ONE line when it removes anything (never one per sweep, never one per entry): a lane that loses a 3.5 G container to +// the bound should see why in its log instead of re-importing into the same squeeze. +Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep, int64_t max_bytes, + int64_t min_age_seconds = 600); + +// Directory bytes under `path` (0 when it does not exist). +int64_t q4nx_dir_bytes(const std::string& path); diff --git a/app/serve.cpp b/app/serve.cpp index 2b0f4a507..2ea0d3da3 100644 --- a/app/serve.cpp +++ b/app/serve.cpp @@ -66,6 +66,7 @@ #include "built_path.h" #include "gguf_meta.h" +#include "q4nx_cache.h" #include "hrx_archs.h" #include "hrx_cwsr.h" #include "hrx_runtime.h" @@ -2954,6 +2955,17 @@ std::string q4nx_cache_root() { // (Python), cached by GGUF stem so a repeated serve reuses the conversion. // Override the tool paths with ONEBIT_Q4NX_PYTHON / ONEBIT_Q4NX_CONVERTER and // the cache root with ONEBIT_Q4NX_CACHE. +// engine#1956: the cache is regenerable scratch, not user data, and nothing ever removed an old entry -- measured at 104 G in +// 29 entries on one box, which closed two lanes' build gates the same day. The bound is a size, not an age, because a busy +// day imports more packs than any age window expires; ONEBIT_Q4NX_CACHE_MAX_GIB=0 restores the old unbounded behaviour. +// The entry just written is never a candidate, and neither is one younger than the sweep's age window (a serve may be reading +// it) -- see app/q4nx_cache.h for why that is a heuristic rather than a guarantee. +static void q4nx_cache_sweep(const std::string& cache, const std::string& keep) { + const char* env = std::getenv("ONEBIT_Q4NX_CACHE_MAX_GIB"); + long long gib = env && *env ? std::atoll(env) : 60; + q4nx_cache_evict(cache, keep, gib > 0 ? gib * (1LL << 30) : 0); +} + std::string repack_gguf(const std::string& gguf) { const std::string stem = fs::path(gguf).stem().string(); const std::string cache = q4nx_cache_root(); @@ -3048,6 +3060,7 @@ std::string repack_gguf(const std::string& gguf) { const int rc = run_program(repack, false); if (rc != 0 || !fs::exists(out + "/model.q4nx")) throw std::runtime_error("GGUF repack failed (rc=" + std::to_string(rc) + "): " + command_text(repack)); + q4nx_cache_sweep(cache, out); // engine#1956: after the new entry exists, and never evicting it return out; } @@ -3117,6 +3130,7 @@ std::string repack_gguf_lane(const std::string& gguf, const std::string& lane_di fs::copy_file(fs::path(tok) / "tokenizer_config.json", fs::path(out) / "tokenizer_config.json", fs::copy_options::overwrite_existing); } + q4nx_cache_sweep(cache, out); // engine#1956: the lane layout lands in the same cache, so it sweeps too return out; } diff --git a/tests/q4nx_cache_test.cpp b/tests/q4nx_cache_test.cpp new file mode 100644 index 000000000..ba5ddbcb8 --- /dev/null +++ b/tests/q4nx_cache_test.cpp @@ -0,0 +1,94 @@ +// Copyright 2026 bong-water-water-bong +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +#pragma once +// engine#1956: the Q4NX container cache had no eviction -- 104 G in 29 entries on one box, and it is regenerable scratch, not +// user data. These arms drive the sweep on a synthetic cache: the bound is respected, the least-recently-used goes first, the +// entry just written is never removed, and an entry younger than the age window survives even when the bound is exceeded +// (a serve may be reading it). No pack, no serve, no device. +#include "q4nx_cache.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +static int failures = 0; +static void check(bool ok, const std::string& what) { + if (!ok) { std::printf("FAIL %s\n", what.c_str()); ++failures; } +} + +static void make_entry(const fs::path& root, const std::string& name, int mb, int age_minutes) { + const fs::path d = root / name; + fs::create_directories(d); + std::ofstream f(d / "model.q4nx", std::ios::binary); + f << std::string(size_t(mb) * 1024 * 1024, 'x'); + f.close(); + std::error_code ec; + fs::last_write_time(d, fs::file_time_type::clock::now() - std::chrono::minutes(age_minutes), ec); +} + +int main() { + std::error_code ec; + const fs::path root = fs::temp_directory_path() / ("q4nx-cache-test-" + std::to_string(::getpid())); + fs::remove_all(root, ec); + + // (a) under the bound: nothing is removed + make_entry(root, "old-a", 4, 300); + make_entry(root, "old-b", 4, 240); + Q4nxCacheSweep r = q4nx_cache_evict(root.string(), "", 64LL << 20); + check(r.removed == 0 && fs::exists(root / "old-a") && fs::exists(root / "old-b"), + "under the bound nothing is removed (removed " + std::to_string(r.removed) + ")"); + + // (b) over the bound: the LRU goes first, and only enough to fit + make_entry(root, "old-c", 4, 120); // 12 MiB total, bound 6 MiB + r = q4nx_cache_evict(root.string(), "", 6LL << 20); + check(r.removed == 2, "over the bound the oldest entries go (removed " + std::to_string(r.removed) + ")"); + check(!fs::exists(root / "old-a") && !fs::exists(root / "old-b"), "and they are the two oldest"); + check(fs::exists(root / "old-c"), "while the newest survives"); + check(r.bytes_after <= (6LL << 20), "and the total is within the bound (" + std::to_string(r.bytes_after) + ")"); + + // (c) the entry just written is never removed, even when it alone exceeds the bound + fs::remove_all(root, ec); + make_entry(root, "fresh", 8, 500); + make_entry(root, "older", 2, 400); + r = q4nx_cache_evict(root.string(), (root / "fresh").string(), 1LL << 20); + check(fs::exists(root / "fresh"), "the entry just written survives even though it alone exceeds the bound"); + check(!fs::exists(root / "older"), "and the sweep still removed what it could"); + + // (d) an entry younger than the age window survives: a serve may be reading it + fs::remove_all(root, ec); + make_entry(root, "just-written", 8, 0); + make_entry(root, "stale", 8, 600); + r = q4nx_cache_evict(root.string(), "", 1LL << 20, /*min_age_seconds=*/600); + check(fs::exists(root / "just-written"), "an entry inside the age window is not removed"); + check(!fs::exists(root / "stale"), "an entry outside it is"); + check(r.reported, "and the sweep says so once rather than silently"); + + // (e) max_bytes <= 0 disables the sweep -- the old unbounded behaviour, deliberately reachable + fs::remove_all(root, ec); + make_entry(root, "keepme-1", 4, 600); + make_entry(root, "keepme-2", 4, 600); + r = q4nx_cache_evict(root.string(), "", 0); + check(r.removed == 0 && fs::exists(root / "keepme-1") && fs::exists(root / "keepme-2"), + "a non-positive bound disables the sweep"); + + fs::remove_all(root, ec); + std::printf("q4nx cache: %d arm(s) over the sweep (bound, LRU order, keep, age window, disabled)\n", + failures ? 0 : 12); + return failures ? 1 : 0; +}