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
9 changes: 8 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
Expand Down
85 changes: 85 additions & 0 deletions app/q4nx_cache.cpp
Original file line number Diff line number Diff line change
@@ -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 <algorithm>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <vector>

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<Ent> 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<Ent*> 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;
}
46 changes: 46 additions & 0 deletions app/q4nx_cache.h
Original file line number Diff line number Diff line change
@@ -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 <cstdint>
#include <string>

// 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);
14 changes: 14 additions & 0 deletions app/serve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
94 changes: 94 additions & 0 deletions tests/q4nx_cache_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <chrono>
#include <cstdio>
#include <filesystem>
#include <unistd.h>
#include <fstream>
#include <string>

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;
}
Loading