From b36905112d8703e44d057df8fa0c0ecc65138fbc Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:57:28 +0000 Subject: [PATCH 1/9] CI: main reuses its own build cache too, and a nightly run builds from nothing A pull request reuses main's newest cache, but main built from nothing, so during a landing burst its saves fell several commits behind and one shared-source commit in between undid most of the reuse. Main now restores its newest cache like a pull request and saves it again, and zig_cache_restat --prune deletes the entries a checkout can't hit (a changed or missing file, or another dead entry's output), so the saved cache stays about one build. A nightly run skips every restore, so a pass reuse could hide, such as a test reading a file the build doesn't know of, fails within a day, and its cache starts the next day clean. --- .github/workflows/no-gpu.yml | 52 +++++++++++++--------- tools/zig/zig_cache_restat.zig | 71 +++++++++++++++++++++++++++--- tools/zig/zig_cache_restat_test.py | 32 +++++++++++++- 3 files changed, 124 insertions(+), 31 deletions(-) diff --git a/.github/workflows/no-gpu.yml b/.github/workflows/no-gpu.yml index 7a5064fb2a..99114cfd67 100644 --- a/.github/workflows/no-gpu.yml +++ b/.github/workflows/no-gpu.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: types: [opened, synchronize, reopened] + # Each night builds and runs everything from nothing: what a reused pass could hide, such as a test reading a file + # the build doesn't know of, fails here within a day. + schedule: + - cron: "23 3 * * *" permissions: contents: read @@ -46,6 +50,7 @@ jobs: python3 -m unittest discover -s tools/zig -p cuda_common_check_test.py -v # The lean rule (600-line files, one-line comments within 120 columns): no problem the base branch lacks. - name: lean check + if: ${{ github.event_name != 'schedule' }} env: BASE_REF: ${{ github.base_ref }} PUSH_BASE: ${{ github.event.before }} @@ -64,10 +69,10 @@ jobs: with: version: 0.17.0 use-cache: false - # Main builds from nothing, so every commit on main is checked from scratch and the cache holds one build. A pull - # request restores main's newest and saves nothing, so it compiles and runs only what its diff changed. + # Main and pull requests restore main's newest build, so each compiles and runs only what changed since; only main + # saves, and the nightly run starts from nothing. # -Dfixed-cpu: a compile's cache key carries its CPU and these runners differ, so all of them compile for one CPU. - - if: ${{ github.event_name == 'pull_request' }} + - if: ${{ github.event_name != 'schedule' }} uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache @@ -75,9 +80,10 @@ jobs: restore-keys: zig-build-v3-Linux-0.17.0- # A checkout or a restored cache gives every file a new id, and Zig 0.17's cache misses on one even when the # contents match (fixed on Zig's master, ziglang/zig#37238). A failure here costs time, never a check. + # --prune deletes the entries this checkout can't hit, so a cache that main restores and saves stays one build. - name: Let a restored cache fit this checkout shell: bash - run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" + run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" --prune || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" # The restat tool's own test, when a change touches the tool, this workflow or Zig's version. - name: Restored cache regressions shell: bash @@ -134,11 +140,11 @@ jobs: - name: build options run: python3 -m unittest discover -s tools/zig -p build_options_test.py - name: Save the build cache - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ !cancelled() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache - key: zig-build-v3-Linux-0.17.0-${{ github.sha }} + key: zig-build-v3-Linux-0.17.0-${{ github.sha }}${{ github.event_name == 'schedule' && '-nightly' || '' }} zig-windows: runs-on: windows-latest timeout-minutes: 60 @@ -150,10 +156,10 @@ jobs: with: version: 0.17.0 use-cache: false - # Main builds from nothing, so every commit on main is checked from scratch and the cache holds one build. A pull - # request restores main's newest and saves nothing, so it compiles and runs only what its diff changed. + # Main and pull requests restore main's newest build, so each compiles and runs only what changed since; only main + # saves, and the nightly run starts from nothing. # -Dfixed-cpu: a compile's cache key carries its CPU and these runners differ, so all of them compile for one CPU. - - if: ${{ github.event_name == 'pull_request' }} + - if: ${{ github.event_name != 'schedule' }} uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache @@ -161,7 +167,7 @@ jobs: restore-keys: zig-build-v3-Windows-0.17.0- - name: Let a restored cache fit this checkout shell: bash - run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" + run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" --prune || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" # The same host unit tests on Windows: the platform layer (zig/src/platform) over the UCRT, Win32 and Winsock. - name: zig build test run: zig build -Dfixed-cpu test --test-timeout 10m @@ -171,18 +177,18 @@ jobs: - name: zig build fuzz-cortheon run: zig build -Dfixed-cpu fuzz-cortheon - name: Save the build cache - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ !cancelled() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache - key: zig-build-v3-Windows-0.17.0-${{ github.sha }} + key: zig-build-v3-Windows-0.17.0-${{ github.sha }}${{ github.event_name == 'schedule' && '-nightly' || '' }} # The release's own path check on each platform's dist server (no fatbins), so a leak fails here, not at release. - # Pull requests check Linux x86_64 only; every push to main checks all three platforms. + # Pull requests check Linux x86_64 only; main's pushes and the nightly run check all three platforms. release-paths: strategy: fail-fast: false matrix: - os: ${{ github.event_name == 'push' && fromJSON('["macos-26", "ubuntu-24.04", "ubuntu-24.04-arm"]') || fromJSON('["ubuntu-24.04"]') }} + os: ${{ github.event_name != 'pull_request' && fromJSON('["macos-26", "ubuntu-24.04", "ubuntu-24.04-arm"]') || fromJSON('["ubuntu-24.04"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 45 steps: @@ -200,7 +206,8 @@ jobs: env: SHARED: ${{ hashFiles('zig/src/core/**', 'zig/src/platform/**', 'zig/src/hip/**', 'zig/src/*.zig') }} run: echo "shared=$SHARED" >> "$GITHUB_OUTPUT" - - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + - if: ${{ github.event_name != 'schedule' }} + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache key: zig-build-v3-paths-${{ matrix.os }}-0.17.0-${{ steps.cache-key.outputs.shared }}-${{ github.sha }} @@ -209,18 +216,18 @@ jobs: ${{ github.event_name == 'pull_request' && format('zig-build-v3-paths-{0}-0.17.0-', matrix.os) || '' }} - name: Let a restored cache fit this checkout shell: bash - run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" + run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" --prune || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" - name: Metal toolchain if: runner.os == 'macOS' run: xcrun -sdk macosx metal -v || { xcodebuild -downloadComponent MetalToolchain && xcrun -sdk macosx metal -v; } - name: zig build check-release-paths run: zig build -Dfixed-cpu check-release-paths - name: Save the build cache - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ !cancelled() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache - key: zig-build-v3-paths-${{ matrix.os }}-0.17.0-${{ steps.cache-key.outputs.shared }}-${{ github.sha }} + key: zig-build-v3-paths-${{ matrix.os }}-0.17.0-${{ steps.cache-key.outputs.shared }}-${{ github.sha }}${{ github.event_name == 'schedule' && '-nightly' || '' }} zig-macos-dev-tools: runs-on: macos-26 timeout-minutes: 45 @@ -239,7 +246,8 @@ jobs: env: SHARED: ${{ hashFiles('zig/src/core/**', 'zig/src/platform/**', 'zig/src/hip/**', 'zig/src/*.zig') }} run: echo "shared=$SHARED" >> "$GITHUB_OUTPUT" - - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + - if: ${{ github.event_name != 'schedule' }} + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache key: zig-build-v3-macOS-dev-tools-0.17.0-${{ steps.cache-key.outputs.shared }}-${{ github.sha }} @@ -248,7 +256,7 @@ jobs: ${{ github.event_name == 'pull_request' && 'zig-build-v3-macOS-dev-tools-0.17.0-' || '' }} - name: Let a restored cache fit this checkout shell: bash - run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" + run: zig run -OReleaseFast -mcpu=baseline tools/zig/zig_cache_restat.zig -- .zig-cache "$(dirname "$(command -v zig)")/lib" --prune || echo "::warning::zig_cache_restat failed, so this job rebuilds and reruns more than it needs to" # Xcode 26 ships the Metal compiler as a separate component, which the image does not always carry. - name: Metal toolchain run: xcrun -sdk macosx metal -v || { xcodebuild -downloadComponent MetalToolchain && xcrun -sdk macosx metal -v; } @@ -256,8 +264,8 @@ jobs: - name: zig build dev-tools run: zig build dev-tools -Doptimize=Debug # compile and link only: Debug builds about 30% faster - name: Save the build cache - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ !cancelled() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .zig-cache - key: zig-build-v3-macOS-dev-tools-0.17.0-${{ steps.cache-key.outputs.shared }}-${{ github.sha }} + key: zig-build-v3-macOS-dev-tools-0.17.0-${{ steps.cache-key.outputs.shared }}-${{ github.sha }}${{ github.event_name == 'schedule' && '-nightly' || '' }} diff --git a/tools/zig/zig_cache_restat.zig b/tools/zig/zig_cache_restat.zig index 1791dab169..749cd18f81 100644 --- a/tools/zig/zig_cache_restat.zig +++ b/tools/zig/zig_cache_restat.zig @@ -1,4 +1,4 @@ -//! `zig_cache_restat CACHE_DIR ZIG_LIB_DIR`: every recorded file whose contents still match its digest gets today's id. +//! `zig_cache_restat CACHE_DIR ZIG_LIB_DIR [--prune]`: a record whose file still matches its digest gets today's id. const std = @import("std"); const Io = std.Io; const Cache = std.Build.Cache; @@ -11,7 +11,11 @@ const Totals = struct { updated: usize = 0, changed: usize = 0, unreadable: usize = 0, + pruned: usize = 0, + outputs: usize = 0, }; +/// A manifest, the output folder Zig names after it, and the output folders its files are in. +const Entry = struct { name: []const u8, out: Cache.HexDigest, dead: bool, uses: []const Cache.HexDigest }; const Restat = struct { gpa: std.mem.Allocator, @@ -22,6 +26,7 @@ const Restat = struct { key: std.ArrayList(u8) = .empty, buffer: []u8, totals: Totals = .{}, + entries: std.ArrayList(Entry) = .empty, /// What `path` under prefix `prefix` holds today, read once per run; null when it can't be read. fn look(r: *Restat, prefix: usize, path: []const u8) !?Seen { @@ -55,19 +60,34 @@ const Restat = struct { } /// Returns how many records now carry today's id, or null when `contents` isn't a complete manifest. - fn update(r: *Restat, contents: []u8) !?usize { + fn update(r: *Restat, name: []const u8, contents: []u8) !?usize { var updated: usize = 0; var off: usize = 0; + var input: Cache.BinDigest = @splat(0); + const named = name.len == Cache.hex_digest_len and (std.fmt.hexToBytes(&input, name) catch null) != null; + // Zig names an entry's output folder by hashing its manifest's name, then each record's digest in order. + var out = Cache.hasher_init; + out.update(&input); + var dead = false; + var uses: std.ArrayList(Cache.HexDigest) = .empty; while (off + 1 < contents.len) { const file_off: File.Offset = @fromBackingInt(@as(u32, @intCast(off))); const file = file_off.getFallible(contents) catch return null; const path = file_off.pathFallible(contents) catch return null; if (file.flags.prefix >= r.roots.len or path.len == 0) return null; off += File.sizeOf(path.len); + out.update(&file.digest); + if ((file.flags.prefix == 2 or file.flags.prefix == 3) and path.len > 2 + Cache.hex_digest_len and + path[0] == 'o' and (path[1] == '/' or path[1] == '\\')) + try uses.append(r.gpa, path[2..][0..Cache.hex_digest_len].*); if (file.flags.is_directory or file.flags.metadata_only) continue; - const seen = try r.look(file.flags.prefix, path) orelse continue; + const seen = try r.look(file.flags.prefix, path) orelse { + dead = true; + continue; + }; if (seen.size != file.size or !std.mem.eql(u8, &seen.digest, &file.digest)) { r.totals.changed += 1; + dead = true; continue; } if (seen.inode == file.inode and seen.mtime == file.mtime) continue; @@ -76,6 +96,16 @@ const Restat = struct { updated += 1; } if (off + 1 != contents.len or contents[off] != 0) return null; + if (named) { + var digest: Cache.BinDigest = undefined; + out.final(&digest); + try r.entries.append(r.gpa, .{ + .name = try r.gpa.dupe(u8, name), + .out = std.fmt.bytesToHex(digest, .lower), + .dead = dead, + .uses = uses.items, + }); + } return updated; } @@ -96,7 +126,7 @@ const Restat = struct { return; }; r.totals.manifests += 1; - const updated = (if (got == size) try r.update(contents) else null) orelse { + const updated = (if (got == size) try r.update(name, contents) else null) orelse { r.totals.unreadable += 1; return; }; @@ -108,13 +138,37 @@ const Restat = struct { r.totals.rewritten += 1; r.totals.updated += updated; } + + /// `--prune`: an entry whose files changed can't hit again, and nor can one that reads its outputs, so both go. + fn prune(r: *Restat, manifests: Io.Dir, outputs: Io.Dir) !void { + var gone: std.AutoHashMapUnmanaged(Cache.HexDigest, void) = .empty; + for (r.entries.items) |e| if (e.dead) try gone.put(r.gpa, e.out, {}); + var more = true; + while (more) { + more = false; + for (r.entries.items) |*e| if (!e.dead) for (e.uses) |used| if (gone.contains(used)) { + e.dead = true; + try gone.put(r.gpa, e.out, {}); + more = true; + break; + }; + } + for (r.entries.items) |e| if (e.dead) { + manifests.deleteFile(r.io, e.name) catch continue; + r.totals.pruned += 1; + outputs.access(r.io, &e.out, .{}) catch continue; + outputs.deleteTree(r.io, &e.out) catch continue; + r.totals.outputs += 1; + }; + } }; pub fn main(init: std.process.Init) !void { const io = init.io; const args = try init.minimal.args.toSlice(init.arena.allocator()); - if (args.len != 3) { - std.debug.print("usage: zig_cache_restat CACHE_DIR ZIG_LIB_DIR\n", .{}); + const pruning = args.len == 4 and std.mem.eql(u8, args[3], "--prune"); + if (args.len != 3 and !pruning) { + std.debug.print("usage: zig_cache_restat CACHE_DIR ZIG_LIB_DIR [--prune]\n", .{}); std.process.exit(2); } const cwd = Io.Dir.cwd(); @@ -135,8 +189,11 @@ pub fn main(init: std.process.Init) !void { if (entry.kind != .file or std.mem.eql(u8, entry.name, "timestamp")) continue; try r.manifest(manifests, entry.name); } + if (pruning) try r.prune(manifests, try cache.createDirPathOpen(io, "o", .{})); const t = r.totals; - std.debug.print("zig_cache_restat: {d} manifests, {d} rewritten, {d} records updated, {d} changed, {d} unreadable\n", .{ + std.debug.print("zig_cache_restat: {d} manifests, {d} rewritten, {d} records updated, {d} changed, {d} unreadable", .{ t.manifests, t.rewritten, t.updated, t.changed, t.unreadable, }); + if (pruning) std.debug.print(", {d} pruned with {d} output folders", .{ t.pruned, t.outputs }); + std.debug.print("\n", .{}); } diff --git a/tools/zig/zig_cache_restat_test.py b/tools/zig/zig_cache_restat_test.py index 7558613f02..4c46a9b9b3 100644 --- a/tools/zig/zig_cache_restat_test.py +++ b/tools/zig/zig_cache_restat_test.py @@ -1,5 +1,6 @@ """A restored build cache stays cached when only file ids changed, and still misses when contents changed.""" import os +import re from pathlib import Path import shutil import subprocess @@ -25,6 +26,9 @@ b.getInstallStep().dependOn(&run.step); } """ +# The same steps, but the executable links the C object, so a changed C file reaches all three. +LINKED = BUILD.replace(" const run = b.addRunArtifact", + " from_zig.root_module.addObject(from_c);\n const run = b.addRunArtifact") SOURCES = { "src/hello.c": "int hello(void) { return 7; }\n", "src/hello.zig": "pub fn main() void {}\n", @@ -55,8 +59,9 @@ def run_zig(self, command): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) return result.stdout + result.stderr - def restat_then_build(self): - print(self.run_zig(self.restat_command).strip().splitlines()[-1]) + def restat_then_build(self, *extra): + self.restat_line = self.run_zig(self.restat_command + list(extra)).strip().splitlines()[-1] + print(self.restat_line) summary = self.run_zig([self.zig, "build", "--summary", "all"]).splitlines() steps = {} for name, marker in (("c", "compile obj from_c"), ("zig", "compile exe from_zig"), ("run", "run exe from_zig")): @@ -94,5 +99,28 @@ def test_only_a_real_change_misses(self): self.assertEqual(self.restat_then_build(), {"c": True, "zig": False, "run": False}, "changed Zig compiles, runs") + def test_prune_keeps_one_build(self): + (self.root / "build.zig").write_text(LINKED, newline="\n") + calls = "extern fn hello() c_int;\n\npub fn main() void {\n _ = hello();\n}\n" + (self.root / "src/hello.zig").write_text(calls, newline="\n") + self.run_zig([self.zig, "build"]) + self.new_ids() + self.restore_cache() + all_cached = {"c": True, "zig": True, "run": True} + self.assertEqual(self.restat_then_build("--prune"), all_cached, "pruning keeps what can still hit") + self.assertIn(", 0 pruned", self.restat_line) + before = {path.name for path in (self.cache / "o").iterdir()} + (self.root / "src/hello.c").write_text("int hello(void) { return 8; }\n", newline="\n") + none_cached = {"c": False, "zig": False, "run": False} + self.assertEqual(self.restat_then_build("--prune"), none_cached, "a changed C file reaches all three") + found = re.search(r"(\d+) pruned with (\d+) output folders", self.restat_line) + pruned, folders = int(found[1]), int(found[2]) + self.assertGreaterEqual(pruned, 3, self.restat_line) + self.assertGreaterEqual(folders, 2, self.restat_line) + after = {path.name for path in (self.cache / "o").iterdir()} + self.assertEqual(len(before - after), folders, "the pruned folders are the ones gone") + self.assertEqual(len(after), len(before), "each pruned folder's build replaced it, so the cache didn't grow") + + if __name__ == "__main__": unittest.main() From 78efd622492fa718bc11054f4cad5b6a8026ab92 Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:20:13 +0000 Subject: [PATCH 2/9] changelog: #2402 under Unreleased --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cea5ec3668..bde839480a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel ## Unreleased +- CI: main's `zig` and `zig-windows` jobs reuse main's newest build cache, as a pull request does, so each push compiles and runs only what changed since. `zig_cache_restat --prune` keeps each saved cache to about one build, and a nightly run builds and checks everything from nothing (#2402). + - Docs: `docs/api.md` lists `DELETE /v1/slide/facts`, and `docs/control.md` no longer says the native server refuses `--spill-gib` and `--vision` (#2341). - Prompt cache on CUDA: when a kept state is larger than the whole cache budget, the log line now says that `TF_KV_SHARE=n` would keep its prompt rows as the pool's pages, for families on KV pages; nothing else changes (#2333). Thanks to @Bizuayeu. From 9a0e8c5abd5a7583f6a3c9bcbaf5fde1d90f4dbb Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:07:15 +0000 Subject: [PATCH 3/9] docs: document the --top-p, --top-k and --min-p serve flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They set the server's defaults for the request fields of the same names (server.zig:264-292 resolveSampling merges Config.default_sampling under the request's fields; the request wins). The defaults are resolved from three layers (family_sampling.zig:21-35): a family's built-in sampling — only DeepSeek-V4/V4.1 have one, temperature 1 and top_p 1, the model cards' recommended sampling (family_sampling.zig:13-18) — then the checkpoint's generation_config.json temperature/top_k/top_p/min_p with do_sample false meaning temperature 0 and do_sample true without a temperature meaning 1 (family_sampling.zig:24-29), then the serve flags, each set one replacing the layers below (family_sampling.zig:31-34). Nothing set means no default (family_sampling.zig:35); the serving line prints the resolved defaults or greedy (serve.zig:338-348 shownSampling). The flags parse without range checks (cli.zig:457); the merged values are validated per request like request fields (server.zig:267-270 via fields.zig:60-77): min_p must be 0..1 (fields.zig:71), a negative top_k clamps to 0 (fields.zig:73), temperature and top_p must be finite (fields.zig:70). A greedy-only engine refuses while the server samples by default and suggests --temperature 0 (server.zig:273-276). --- docs/api.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/api.md b/docs/api.md index d6d5fe284d..556328050b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -349,6 +349,17 @@ request yields when a foreground request needs its lane or memory, then restarts suppressed. Session-title requests are also treated as background work. +The serve flags `--top-p`, `--top-k` and `--min-p` set the server's defaults for the request fields of the same +names, as `--temperature` does. Under them sit two layers: a family's built-in sampling (DeepSeek-V4 and V4.1 +serve their model cards' temperature 1 and top_p 1), then the checkpoint's `generation_config.json` +(`temperature`, `top_k`, `top_p`, `min_p`; `do_sample: false` means temperature 0, and `do_sample: true` without +a temperature means 1). A flag that is set replaces what those layers gave. When nothing sets a value the server +has no sampling default and the serving line prints `greedy`; otherwise it prints the resolved defaults as +name-value pairs. A request's own fields win over the defaults. The merged values are checked per request like +request fields: a `--min-p` outside 0 through 1 refuses every chat request, a negative `--top-k` clamps to 0, and +`--temperature` and `--top-p` must be finite numbers. An engine that decodes greedily only refuses sampled +requests while the server samples by default, and its refusal suggests serving with `--temperature 0`. + `--name-priority ID=background` gives one served id (`--name` or an `--alias`) a default priority: a request that asks for it and sends no `priority` of its own is treated as `priority: "background"`. The request's own `priority` field always wins over the default. This lets one server answer to several ids at their usual priority while a From 4011269d16b6db23463a565eee93836ad7ec9d99 Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:07:16 +0000 Subject: [PATCH 4/9] docs: document the --compact-at, --compact-keep and --compact-memory flags Server-side conversation compaction for chat completions (compact.zig, reached from chat.zig:279-282 and openai.zig:494-563; the Responses API runs through the same handler, responses.zig:193). Off without --compact-at (compact.zig:105-108): an over-long conversation keeps the usual context_length refusal. --compact-at parses auto or a fraction in (0, 1] (cli.zig:458-461). auto reserves max(15% of the window, 16384) tokens for the reply and compacts when prompt plus wanted reply tokens cross what is left (compact.zig:47-52 reserveTokens, :78-84 over, wanted :86-89); a fraction f thresholds at floor(f * window) (compact.zig:70-76). A plain prepare refused with context_length also compacts (compact.zig:114-119). rebuild (compact.zig:136-206) keeps the leading messages and a recent tail (--compact-keep tokens, an integer from 0, default min(20000, window/4): cli.zig:463-466, compact.zig:65-68), summarizes the middle into a note generated by the model itself, greedy with thinking off and max_tokens 1024 a chunk (compact.zig:259-261), under the note task's Markdown sections and 800 words (compact.zig:15-21), inserted as one message beginning with the marker line (compact.zig:13), retrying over smaller tails until the rebuilt conversation fits (compact.zig:190-202). --compact-memory DIR (cli.zig:467, no parse validation) stores .md plus a .json with the covered message count and prefix hash (compact_mem.zig:42-51); a later request whose prefix hashes the same reuses the note (compact_mem.zig:21-25, compact.zig:139-147). The reply's tensorfold object gains context_window, context_used and, when compacted, a compaction object with messages_compacted, kept_messages, note_tokens and note (compact.zig:122-134). Messages must be an array; other inputs are never compacted (compact.zig:110-113). --- docs/api.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/api.md b/docs/api.md index 556328050b..1c2ca0c3f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -613,6 +613,33 @@ On a discrete card (an RTX 3090, an A100) the server sizes its window from the c reports it, less a reserve of a 32nd of the card and at least 1 GiB. `TENSORFOLD_MEMORY_RESERVE_GIB` sets that one from 0.5 GiB up. +## Compaction + +Without `--compact-at` nothing compacts: a conversation that outgrows the context is refused with the usual +`context_length` error. With it, the server summarizes instead of refusing. `--compact-at auto` reserves room for +the reply — the larger of 15% of the context window and 16,384 tokens — and compacts a conversation whose prompt +plus wanted reply tokens would cross what is left, or one the plain prepare refuses for context length. +`--compact-at F`, a fraction of the window in (0, 1], compacts when they would cross F × the window, rounded down. +Any other value is refused at startup ("expected auto or a fraction in (0, 1]"). + +Compaction keeps the conversation's leading messages and its recent tail whole and summarizes the middle into a +memory note. The note is written by the model itself — greedy, thinking off, at most 1,024 tokens a chunk — under +a task that asks for Markdown sections (Goal, Constraints and preferences, Progress, Decisions, Next steps, +Critical context) and under 800 words. It replaces the summarized middle as one message that begins "Memory of +the earlier conversation:". The tail is `--compact-keep` tokens of recent messages, an integer from 0; the +default keeps the smaller of 20,000 tokens and a quarter of the window. If the rebuilt conversation still does +not fit, the server summarizes again over a smaller tail until it does. A compacted request runs these extra +generations before its reply. + +`--compact-memory DIR` stores each conversation's note under DIR as `.md`, with a `.json` recording how +many leading messages the note covers and their hash. A later request whose leading messages hash the same reuses +the stored note instead of regenerating it. Without the flag a note lives for one request. + +The reply's `tensorfold` object carries `context_window` and `context_used`, and when compaction ran also a +`compaction` object: `messages_compacted`, `kept_messages`, `note_tokens` and the `note` text. Compaction applies +to chat completions with a `messages` array, and to the Responses API, which runs as one; a completions request +with a plain string prompt is never compacted. + ## Embeddings `POST /v1/embeddings` takes OpenAI's body: `input` is one text, a list of texts, one list of token IDs or a list of From 845dd07ff124482cc2f1e80efebdc1c09256fdde Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:07:16 +0000 Subject: [PATCH 5/9] docs: document the --loops flag for Dory on CUDA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --loops N overrides the checkpoint's own n_recurrent_loops with N passes over Dory's recurrent block (cli.zig:143, .native = cuda_build; families/dory/cuda_weights.zig:110-114 load applies withLoops when the flag is set). Parsed in the CUDA builds only (cli_gpu_flags.zig:19): a negative count is refused there ("a count from 1"), 0 passes parse and is refused by the Dory open with anything above max_loops (families/dory/cuda_native.zig:106), whose refusal names the range (cuda_native.zig:190). max_loops is 4, llama.cpp's limit and the card's largest benchmarked count (families/dory/config.zig:6). The schedule at L loops runs I + R*L + O steps — 16 + 40*L + 16 — with 8 + 20*L + 8 attention caches (families/dory/schedule.zig:44 build, test :96-107), and a token's KV and stream state is 36, 56 or 96 KiB at 1, 2 or 4 loops (families/dory/memory.zig test :75-80). A saved state carries its loop count in its key and never crosses to another count (families/dory/cuda_snap.zig:8-20). Non-readers refuse the flag by name: other CUDA families (native/cuda_refusal.zig:66), HIP (native/hip.zig:89), Kimi on Metal (native/kimi_metal.zig:81), and tensorfold run on any non-Dory model (cli/cuda_main.zig:195-198). --- docs/recipes/cuda.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/recipes/cuda.md b/docs/recipes/cuda.md index af7e6cbb07..7556132e71 100644 --- a/docs/recipes/cuda.md +++ b/docs/recipes/cuda.md @@ -114,6 +114,15 @@ staggered prompt work for 1 through 4 whole 2,048-row chunks; without the flag i uses `TF_CUDA_SEGMENTS`, then 1. The `tensorfold segments` command compares the available segment counts and can profile the serial chunk parts. +Dory's recurrent block can run more than once a pass: `--loops N` serves the checkpoint with N passes over it, +1 through 4. Without the flag a checkpoint serves at its own `n_recurrent_loops`; the flag overrides it. The +schedule runs the input sublayers once, the recurrent block N times, then the output sublayers — 16 + 40×N + 16 +steps with 8 + 20×N + 8 attention caches — and a token's KV and stream state grows from 36 KiB at one loop to 56 +at two and 96 at four. A saved state records its loop count and never crosses to another. Zero and anything above +4 are refused at load ("--loops: passes over Dory's recurrent block, 1 to 4"); a negative count is refused at +parse. The other CUDA families, the HIP engines and Kimi on Metal refuse the flag by name, and `tensorfold run` +refuses it for any model that is not Dory. + Experimental: on a DGX Spark (GB10), `tensorfold run --carveout`, or `TF_CUDA_CARVEOUT=1` for `tensorfold run` and `tensorfold-native`, puts Nemotron's KV caches in the memory the display controller reserves, which MemAvailable never counts. The native engine maps a From 766785dcdcf43cc9e255d0f11bb83105738be20f Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:07:16 +0000 Subject: [PATCH 6/9] docs: document the --max-frames flag for Drama 3 speech --max-frames parses to an integer 1 through 8190, refused otherwise (cli.zig:425-428), and reaches the engine as Open.max_frames (cli.zig:527). Drama 3 validates it at open on both opens (native/drama_open.zig:26,90 via frameProblem :111-118): the frame budget is min(flag or 1024, context - 3) frames with the prompt capped at min(4096, context - frames - 1) (native/drama_budget.zig:3-12); an explicit value outside 1..8190 is refused ("Drama 3 supports --max-frames 1 through 8190"), one at or above context - 1 leaves no prompt room ("use --max-frames < --context - 1"), and the context itself must be 1025 through 8192. The engine option defaults to 1024 frames when the flag is absent (native/drama_host.zig:385) and the engine advertises the cap as SpeechInfo.max_frames with frame_length 2048 at 44100 Hz (native/drama_host.zig:405), which the speech route passes to each request (server/speech.zig:197); the engines generate up to the smallest of the request cap, the engine option and the context room (families/drama3/metal/engine.zig:389, families/drama3/cuda/engine.zig:543). Other engines read no Open.max_frames and no refusal names the flag; video input is limited by the vision families' own caps (VisionOpen.max_frames, default 256, core/engine_vision.zig:82, has no serve flag). --- docs/speech-sampling.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/speech-sampling.md b/docs/speech-sampling.md index 29f6474e21..fb6ab7f3cb 100644 --- a/docs/speech-sampling.md +++ b/docs/speech-sampling.md @@ -48,6 +48,15 @@ number are unchanged. Streams keep their existing no-redraw behavior. Seed selection is deterministic. The final take can still change if timing or request deadlines admit a different number of redraws. +The server's `--max-frames` flag caps a reply's length in frames. A frame is 2,048 samples at 44.1 kHz (about +46 ms), so the default cap of 1,024 frames is about 48 s of audio. The flag takes an integer from 1 through 8190 +("argument --max-frames: expected 1 through 8190") and Drama 3 reads it at open: the reply's cap is the flag's +value or 1,024, whichever is smaller, clamped to what the context leaves (`--context` - 3 frames). An explicit +value must also leave prompt room — one at or above `--context` - 1 is refused ("use --max-frames < +--context - 1") — and Drama 3's `--context` itself must stay within 1025 through 8192. Other engines ignore the +flag, and video input is untouched: the vision families' frame caps are their own (see +[vision](vision.md)). + The API design references the public [Sogni-AI/drama3-mlx generation interface](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/model.py#L79). TensorFold's parsing and request propagation are an independent implementation; From 4d50eeae24352d81889066fafe7e64d5f80d72fb Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:07:16 +0000 Subject: [PATCH 7/9] docs: document the --silence-onset-s and --silence-onset-voiced-s flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both parse into the silence guard's config (cli.zig:86-87, :432) and are validated at startup (cli.zig:319-320, "invalid silence guard configuration"): the two onset flags are required together (core/speech_guard.zig:23-25), onset_seconds must be above 0 and below --silence-seconds (speech_guard.zig:26, which requires the seconds flag) and onset_voiced_seconds must be a finite number above 0 (speech_guard.zig:27). The guard itself needs --silence-floor-dbfs and a positive --silence-seconds to be enabled (speech_guard.zig:30-32). In Guard.hear a reply trips when its silent run reaches the onset seconds while the total voiced audio stays under the voiced seconds (speech_guard.zig:95) — an early reply that never really began speaking; the startup sentence names the rule (speech_guard.zig:44). The flags are Drama-speech-only: any other model type is refused at startup by name (speech_guard.zig:34-38). An onset trip latches like the main silence limit (speech_guard.zig:96-97), so the finish is a silence trip and is redrawn like any other. --- docs/speech-redraws.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/speech-redraws.md b/docs/speech-redraws.md index f7703cf28a..73c855fc13 100644 --- a/docs/speech-redraws.md +++ b/docs/speech-redraws.md @@ -35,6 +35,13 @@ A floor of -30 dBFS also catches the hum. Streams are not redrawn (see [Streams](#streams)): a stalled stream ends early, with the rest of the text unspoken. +Two more flags cut a reply that never really starts. With `--silence-onset-s S` and `--silence-onset-voiced-s V` +(both required together, and both need the floor and `--silence-seconds`; S must be above 0 and below the +silence seconds, V above 0), a reply also ends once it has stayed S seconds below the floor while having voiced +less than V seconds in total — the model cleared its throat but never began speaking. The startup line names the +rule: "or after S s below it with under V s voiced". An onset trip is a `silence` finish, redrawn like any +other. + ## Seeds Without an explicit request `seed`, redraw k (1 to N) seeds its prompt with `TENSORFOLD_SEED_SALT` plus k; the first From 376cc403c268ef3a1ee564679ce46ffbf0527fa0 Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:08:34 +0000 Subject: [PATCH 8/9] docs: --max-frames isn't capped at 1,024 (drama_budget.zig:10), and auto compaction's 16,384 floor holds only where the window does (compact.zig:58) --- docs/api.md | 2 +- docs/speech-sampling.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 1c2ca0c3f5..29f5df6b54 100644 --- a/docs/api.md +++ b/docs/api.md @@ -617,7 +617,7 @@ reports it, less a reserve of a 32nd of the card and at least 1 GiB. `TENSORFOLD Without `--compact-at` nothing compacts: a conversation that outgrows the context is refused with the usual `context_length` error. With it, the server summarizes instead of refusing. `--compact-at auto` reserves room for -the reply — the larger of 15% of the context window and 16,384 tokens — and compacts a conversation whose prompt +the reply — the larger of 15% of the context window and 16,384 tokens, or 15% when the window can't hold 16,384 — and compacts a conversation whose prompt plus wanted reply tokens would cross what is left, or one the plain prepare refuses for context length. `--compact-at F`, a fraction of the window in (0, 1], compacts when they would cross F × the window, rounded down. Any other value is refused at startup ("expected auto or a fraction in (0, 1]"). diff --git a/docs/speech-sampling.md b/docs/speech-sampling.md index fb6ab7f3cb..bc62160db3 100644 --- a/docs/speech-sampling.md +++ b/docs/speech-sampling.md @@ -51,7 +51,7 @@ request deadlines admit a different number of redraws. The server's `--max-frames` flag caps a reply's length in frames. A frame is 2,048 samples at 44.1 kHz (about 46 ms), so the default cap of 1,024 frames is about 48 s of audio. The flag takes an integer from 1 through 8190 ("argument --max-frames: expected 1 through 8190") and Drama 3 reads it at open: the reply's cap is the flag's -value or 1,024, whichever is smaller, clamped to what the context leaves (`--context` - 3 frames). An explicit +value (1,024 when it's unset), clamped to what the context leaves (`--context` - 3 frames). An explicit value must also leave prompt room — one at or above `--context` - 1 is refused ("use --max-frames < --context - 1") — and Drama 3's `--context` itself must stay within 1025 through 8192. Other engines ignore the flag, and video input is untouched: the vision families' frame caps are their own (see From 0ff93a1b6fbce3444af0baf4a118cc8522a3fd7b Mon Sep 17 00:00:00 2001 From: ashhart <71890694+ashhart@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:20:14 +0000 Subject: [PATCH 9/9] changelog: #2345 under Unreleased --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bde839480a..38fa6da4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel ## Unreleased +- Docs: ten native `serve` flags that were undocumented now are: `--top-p`, `--top-k` and `--min-p` as sampling defaults, `--compact-at`, `--compact-keep` and `--compact-memory`, Dory's `--loops`, the two silence-onset flags and Drama 3's `--max-frames` (#2345). + - CI: main's `zig` and `zig-windows` jobs reuse main's newest build cache, as a pull request does, so each push compiles and runs only what changed since. `zig_cache_restat --prune` keeps each saved cache to about one build, and a nightly run builds and checks everything from nothing (#2402). - Docs: `docs/api.md` lists `DELETE /v1/slide/facts`, and `docs/control.md` no longer says the native server refuses `--spill-gib` and `--vision` (#2341).