From 32c7ddab3e960bb108db744d4f628260cd19a26d Mon Sep 17 00:00:00 2001 From: Ben Gittins <5601410+ihavespoons@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:09:39 +1000 Subject: [PATCH 1/2] Optimizer phase 1: batch settings, the agent's tune request, batched-bench The design is in docs/design/optimizer.md: a measured search for faster llama.cpp settings, with an optional LLM advisor that only proposes. - Model settings batch, ubatch and split_mode, with their llama-server flags, the shared form, the TUI and the console. Old --batch-size and --ubatch-size raw args move to the typed fields on load. - The host agent's tune. request runs llama-bench, llama-batched-bench or a llama-server load check in a backend image, as the benchmark does. Every argument must match a pattern for its flag, so a container cannot pass a model path, a network flag or a device. - The hip, vulkan and cuda images ship llama-batched-bench. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- containers/llama-swap-cuda/Containerfile | 2 +- containers/llama-swap/Containerfile.hip | 4 +- containers/llama-swap/Containerfile.vulkan | 4 +- docs/design/optimizer.md | 104 +++++++++++ docs/index.md | 1 + docs/reference/configuration.md | 3 + docs/reference/system-api.md | 2 +- internal/console/models.go | 1 + internal/console/templates/model.html | 6 + internal/hostctl/agent_test.go | 194 +++++++++++++++++++++ internal/hostctl/hostctl.go | 7 +- internal/llamaswap/generate.go | 12 +- internal/llamaswap/generate_test.go | 19 ++ internal/modelform/modelform.go | 21 +++ internal/models/models.go | 64 +++++++ internal/models/models_test.go | 25 ++- internal/models/tuning.go | 1 + internal/tui/formsections.go | 2 +- internal/tui/models.go | 6 + os/rootfs/usr/libexec/sibyl/agent | 185 ++++++++++++++++++-- 21 files changed, 643 insertions(+), 22 deletions(-) create mode 100644 docs/design/optimizer.md create mode 100644 internal/hostctl/agent_test.go diff --git a/AGENTS.md b/AGENTS.md index 829d8e8..44d6329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Sibyl is a Fedora bootc host image plus the container images that it runs. Read - `os/rootfs/` is copied to `/` of the host. Quadlet units are in `os/rootfs/etc/containers/systemd/users/1001/`, and they run rootless as the `sibyl` user (UID 1001). - `containers/` holds one directory per service image. `containers/system-api` builds from the repo root. - `cmd/` and `internal/` are the Go System API. The design is in the "System API and host agent" section of `docs/design/overview.md`, and every route is in `docs/reference/system-api.md`. -- `os/rootfs/usr/libexec/sibyl/agent` is the host agent. It is the only path from a container to systemd. Keep its allowlist in step with `hostctl.Units`. +- `os/rootfs/usr/libexec/sibyl/agent` is the host agent. It is the only path from a container to systemd and Podman. Keep its allowlist in step with `hostctl.Units`. Its `tune` request checks every optimizer argument against a pattern for each flag, and `internal/hostctl/agent_test.go` tests the refusals. - `cmd/sibyl-hostd` is the root agent for hostname, network and accounts. `os/Containerfile` builds it into the host image. Its checks in `internal/hostd` are the security boundary, so keep every new action in `hostd.Decode` and test the refusals. The one exception is `password-check` in `internal/hostd/auth.go`. It is kept out of `Decode` on purpose, so the disk queue refuses it, and only the memory queue `/run/sibyl-auth/req` takes it. - `internal/modelform` is the model settings form that the TUI and the console share. - `internal/console` is the web console at `/console/`. It calls the System API through `internal/client` in process. Keep every console change on an existing API route, and `docs/design/web-console.md` in step. diff --git a/containers/llama-swap-cuda/Containerfile b/containers/llama-swap-cuda/Containerfile index e2b2a80..b0022e6 100644 --- a/containers/llama-swap-cuda/Containerfile +++ b/containers/llama-swap-cuda/Containerfile @@ -22,7 +22,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # The upstream image keeps llama.cpp in /app, where llama-swap lives in the # other Sibyl images. Move it to /opt/llama, so every image has one layout. RUN mkdir -p /opt/llama/bin /opt/llama/lib \ - && mv /app/llama-server /app/llama-bench /opt/llama/bin/ \ + && mv /app/llama-server /app/llama-bench /app/llama-batched-bench /opt/llama/bin/ \ && mv /app/*.so* /opt/llama/lib/ \ && rm -rf /app \ && mkdir -p /app/config \ diff --git a/containers/llama-swap/Containerfile.hip b/containers/llama-swap/Containerfile.hip index f6f82eb..3ccabe4 100644 --- a/containers/llama-swap/Containerfile.hip +++ b/containers/llama-swap/Containerfile.hip @@ -29,12 +29,12 @@ RUN HIPCXX="$(hipconfig -l)/clang" HIP_PATH="$(hipconfig -R)" \ -DLLAMA_BUILD_EXAMPLES=OFF \ -DLLAMA_BUILD_UI=OFF \ -DLLAMA_USE_PREBUILT_UI=OFF \ - && cmake --build build --config Release -j"$(nproc)" --target llama-server llama-bench + && cmake --build build --config Release -j"$(nproc)" --target llama-server llama-bench llama-batched-bench # Collect the binaries, their shared libraries, and only the rocBLAS and # hipBLASLt kernel files for GPU_TARGETS. The full ROCm tree is ~20 GB. COPY collect-rocm-libs.sh /usr/local/bin/collect-rocm-libs.sh -RUN /usr/local/bin/collect-rocm-libs.sh /out "${GPU_TARGETS}" build/bin/llama-server build/bin/llama-bench +RUN /usr/local/bin/collect-rocm-libs.sh /out "${GPU_TARGETS}" build/bin/llama-server build/bin/llama-bench build/bin/llama-batched-bench FROM docker.io/ubuntu:24.04 AS llama-swap-dl diff --git a/containers/llama-swap/Containerfile.vulkan b/containers/llama-swap/Containerfile.vulkan index 879acaf..053f486 100644 --- a/containers/llama-swap/Containerfile.vulkan +++ b/containers/llama-swap/Containerfile.vulkan @@ -21,9 +21,9 @@ RUN cmake -S . -B build \ -DLLAMA_BUILD_EXAMPLES=OFF \ -DLLAMA_BUILD_UI=OFF \ -DLLAMA_USE_PREBUILT_UI=OFF \ - && cmake --build build --config Release -j"$(nproc)" --target llama-server llama-bench \ + && cmake --build build --config Release -j"$(nproc)" --target llama-server llama-bench llama-batched-bench \ && mkdir -p /out/opt/llama/bin /out/opt/llama/lib \ - && cp build/bin/llama-server build/bin/llama-bench /out/opt/llama/bin/ \ + && cp build/bin/llama-server build/bin/llama-bench build/bin/llama-batched-bench /out/opt/llama/bin/ \ && find build -name '*.so*' -exec cp -P {} /out/opt/llama/lib/ \; FROM docker.io/ubuntu:26.04 AS llama-swap-dl diff --git a/docs/design/optimizer.md b/docs/design/optimizer.md new file mode 100644 index 0000000..413a4bd --- /dev/null +++ b/docs/design/optimizer.md @@ -0,0 +1,104 @@ +# Inference optimizer design + +This document describes the inference optimizer. It finds the llama.cpp settings that run a model fastest on the host's own hardware, by measurement. An upstream LLM can suggest more settings to try, but only a measurement decides. [The design overview](overview.md) holds the rest of the design. + +## Goals + +- Find faster settings for one model on this host, with numbers that the owner can check. +- Never apply settings that were not measured, and never apply settings that do not load at the model's real context size. +- Keep the owner in control: the optimizer recommends, and the owner applies with one action. +- Send no prompts, no user data and no model files off the box. The optional advisor gets hardware facts, model facts and benchmark numbers only. + +## What already exists + +- `internal/models/fit.go` chooses the context size and the KV cache types from the free VRAM. +- `POST /models/{id}/benchmark` runs `llama-bench` once in each backend image through the host agent (`os/rootfs/usr/libexec/sibyl/agent`, `bench`), and recommends the backend with the fastest generation. +- `models.Meta` reads the layer count, the expert count and the expert tensor sizes from the GGUF file. + +The optimizer replaces the fixed benchmark with a search over more settings, and it keeps the same path to the host. + +## Profiles + +The owner picks a profile for each run. + +| Profile | Score | Tool | +|---|---|---| +| `single` | Generation tokens per second at a context depth, then prompt tokens per second | `llama-bench` | +| `throughput` | Total tokens per second over parallel requests, with a floor on the speed of each request | `llama-batched-bench` | + +The context depth of the `single` profile is the number of tokens that are already in the KV cache when the test runs. It is 8,192 tokens, or half the model's context if that is smaller. An empty cache makes generation look faster than a real chat. The floor of the `throughput` profile is half of the single-request generation speed, so a high total with slow answers does not win. + +## Settings that the search covers + +| Setting | llama.cpp flag | Model setting | Note | +|---|---|---|---| +| Batch and micro-batch | `-b`, `-ub` | `batch`, `ubatch` (new) | Large effect on prompt speed. A larger micro-batch needs a larger compute buffer. | +| KV cache type | `-ctk`, `-ctv` | `kv_cache` | `q8_0` and `q4_0` free VRAM for context, and lower quality a little. | +| MoE experts in system RAM | `-ncmoe`, `-ot` | `moe.cpu_layers`, `args` | Only for a model whose experts do not fit in VRAM. | +| Split mode | `-sm` | `split_mode` (new) | `layer` or `row`, only with more than one GPU. | +| Tensor split | `-ts` | `tensor_split` | Only with more than one GPU. | +| Parallel slots | not in `llama-bench` | `parallel` | The `throughput` profile only. | +| Backend | the image | the hardware configuration | The backend applies to every model, so the optimizer reports it apart, and applying it restarts llama-swap. | + +Flash attention stays on, and all layers stay on the GPUs, as llama-swap runs them today. Threads matter only for layers on the CPU, so the search leaves them alone. + +## The search + +The search is a fixed sequence of stages. Each stage runs one tool once. `llama-bench` takes a comma list for each flag and tests every combination, so one run covers one stage. + +1. Baseline: the model's current settings. +2. Backend: each backend that can run on the selected GPUs. With more than one GPU, split mode `layer` and `row`. +3. KV cache: `f16` and `q8_0`. A cache type that does not fit the model's context in VRAM (`FitContext`) is left out. +4. Batch: micro-batch 256, 512, 1024 and 2048, with batch 2048 and 4096, where the batch is at least the micro-batch. +5. MoE: for a model whose experts do not fit in VRAM, the smallest `cpu_layers` value that fits, and two values above it. +6. Advisor: the candidates that the advisor proposes, if the owner turned it on. +7. Parallel: for the `throughput` profile, 1, 2, 4 and 8 parallel sequences at the model's context. +8. Verify: the host agent starts `llama-server` with the best settings at the model's real context size, waits for `/health`, and stops it. + +Each stage keeps the best result so far and passes it to the next stage. A result wins only when it beats the current best by more than three standard deviations of the two runs, or by 3 percent, whichever is larger. `llama-bench` reports the standard deviation (`stddev_ts`). Noise therefore does not change a setting. + +`llama-bench` allocates a context only for the test, not for the model's full context. A micro-batch that fits in the test can still fail at the real context. The verify stage catches that case. If the verify fails, the optimizer drops the last changed setting and verifies again. + +## The path to the host + +The System API runs in a container and has no access to Podman. The optimizer uses the host agent, as the benchmark does. + +- A new agent request, `tune.`, runs one tool from `$RUN/tune/.*` files: the tool (`bench`, `batched` or `server`), the backend, the model path, the GPUs and the arguments. +- The agent checks every argument against an allowlist of flags. Each flag has a pattern for its value. An argument that is not on the list fails the request. The model path, the pool and the GPUs get the same checks as the benchmark. +- The tool runs in the backend image with no network, all capabilities dropped and `no-new-privileges`, as the benchmark does. +- The images build `llama-batched-bench` as well as `llama-server` and `llama-bench`. + +The search itself runs in the System API (`internal/optimize`). It writes one request for each stage, and reads the result files. + +## The advisor + +The advisor is off by default. When the owner turns it on, the optimizer asks one provider model that the router already has. + +- A new router route, `POST /sibyl/advise`, takes a provider and a model. Only the System API key can call it, as with `/sibyl/classify`. The provider keys stay in the router. +- The optimizer sends the GPU names and VRAM, the system RAM, the backend, the model architecture, the parameter count, the quantization, the layer and expert counts, the context size, and every measured result. +- The advisor answers with JSON: up to six candidates, each one a set of the settings in the table above. The optimizer checks each candidate with the same allowlist as the agent, and drops what fails. +- The advisor is most useful for `-ot` patterns on a new MoE architecture, and for the order of the stages on hardware that the fixed search does not know. + +## API + +| Route | Purpose | +|---|---| +| `POST /models/{id}/optimize` | Start a run: `{"profile": "single" or "throughput", "advisor": {"provider": "...", "model": "..."}}`. The advisor is optional. | +| `GET /models/{id}/optimize` | The state: the stage, every measured result, and the recommendation when the run is done. | +| `DELETE /models/{id}/optimize` | Cancel the run. | +| `POST /models/{id}/optimize/apply` | Write the recommended model settings, and reload llama-swap. | + +One run goes at a time on the host, because each run unloads the models and uses every selected GPU. + +## Limits + +- A run unloads the running models, so inference stops for its length: about 5 to 15 minutes for one model. +- `llama-bench` measures the model alone. It does not measure speculative decoding, so a model with a draft model keeps its speculative settings, and the optimizer measures without them. +- A recommendation is for the hardware and the llama.cpp version of the run. An update of either one makes the result old, and the console says so. + +## Phases + +1. Model settings `batch`, `ubatch` and `split_mode`. The `tune` agent request and its allowlist. `llama-batched-bench` in the images. +2. `internal/optimize`, the API routes, and the verify stage. +3. The TUI and the console. +4. The advisor and `/sibyl/advise`. diff --git a/docs/index.md b/docs/index.md index d400114..8ca3832 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,7 @@ These pages are for contributors. They explain how Sibyl works and why. | [Overview](design/overview.md) | The decisions, the host image, the services, storage, security and the phase plan | | [Router](design/router.md) | The model router, the classifier, calibration and their measurements | | [Web console](design/web-console.md) | The web console, its sign-in and its security | +| [Inference optimizer](design/optimizer.md) | The measured search for faster llama.cpp settings, and the optional LLM advisor | | [Diagnostics](design/diagnostics.md) | The watchdog, metrics, incidents, opt-in upload and the fleet service plan | | [Development](development.md) | Building, testing, CI, releases and testing on hardware | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index de0477c..569b5c7 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -61,6 +61,9 @@ qwen3-32b-q4_k_m: | `reasoning.value` | string | `on` or `off` for `enable_thinking` and `thinking`. For `reasoning_effort`, 1 to 32 letters, digits, `_` and `-`. | `--chat-template-kwargs` | The default reasoning. See [Reasoning settings](#reasoning-settings). | | `parallel` | integer | 0 to 64 | `--parallel` | The number of requests that the model answers at once. The requests share the context. 0 leaves llama-server's default. | | `tensor_split` | list of numbers | 0 or more each, at most one value per selected GPU | `--tensor-split` | The share of the layers on each GPU of a spanning model. Empty lets Sibyl split by the memory that each GPU has left. | +| `batch` | integer | 0, or 32 to 65536 | `--batch-size` | The logical batch size. 0 leaves llama-server's default. | +| `ubatch` | integer | 0, or 32 to 65536, and not larger than `batch` | `--ubatch-size` | The physical batch size. A larger value makes the prompt faster and needs a larger compute buffer on the GPU. 0 leaves llama-server's default. | +| `split_mode` | string | `layer` or `row` | `--split-mode` | How a spanning model splits across the GPUs. Empty is `layer`. It has no effect on one GPU. | | `kv_cache.type_k` | string | `f32`, `f16`, `bf16`, `q8_0`, `q4_0`, `q4_1`, `iq4_nl`, `q5_0`, `q5_1` | `--cache-type-k` | The K cache type. | | `kv_cache.type_v` | string | same as `type_k` | `--cache-type-v` | The V cache type. | | `speculative.types` | list of strings | `none`, `draft-simple`, `draft-eagle3`, `draft-mtp`, `draft-dflash`, `draft-dspark`, `ngram-simple`, `ngram-map-k`, `ngram-map-k4v`, `ngram-mod`, `ngram-cache` | `--spec-type` | The speculative decoding types, tried in order. | diff --git a/docs/reference/system-api.md b/docs/reference/system-api.md index eca199a..9e2f9f6 100644 --- a/docs/reference/system-api.md +++ b/docs/reference/system-api.md @@ -159,7 +159,7 @@ Each model in `GET /models` has these fields: | `drafter` | `true` for a DFlash or EAGLE-3 drafter. llama-swap, the router and the Ollama list do not serve it, and it has no `fit`. | | `drafters` | The number of drafters on Hugging Face, from the last search. Missing until a search finishes. `GET /models` starts the searches in the background. | -The settings body of `PUT /models/{id}` has these fields: `layout`, `ctx_size`, `embedding`, `hidden`, `sampling`, `speculative`, `kv_cache`, `moe`, `fit`, `preset`, `reasoning`, `parallel`, `tensor_split` and `args`. [Configuration](configuration.md) describes each field and its range. The route returns `404` for a model that is not installed. It returns `422` in these cases: +The settings body of `PUT /models/{id}` has these fields: `layout`, `ctx_size`, `embedding`, `hidden`, `sampling`, `speculative`, `kv_cache`, `moe`, `fit`, `preset`, `reasoning`, `parallel`, `tensor_split`, `batch`, `ubatch`, `split_mode` and `args`. [Configuration](configuration.md) describes each field and its range. The route returns `404` for a model that is not installed. It returns `422` in these cases: - A value is out of range. - `moe` is set, but the model file has no experts. diff --git a/internal/console/models.go b/internal/console/models.go index 3822de6..0c26066 100644 --- a/internal/console/models.go +++ b/internal/console/models.go @@ -194,6 +194,7 @@ func formFromPost(r *http.Request) modelform.Form { v := func(name string) string { return strings.TrimSpace(r.PostFormValue(name)) } return modelform.Form{ Layout: v("layout"), CtxSize: v("ctxsize"), Parallel: v("parallel"), TensorSplit: v("tensorsplit"), + Batch: v("batch"), UBatch: v("ubatch"), SplitMode: v("splitmode"), Embedding: r.PostFormValue("embedding") == "on", Hidden: r.PostFormValue("hidden") == "on", Temperature: v("temperature"), TopK: v("topk"), TopP: v("topp"), MinP: v("minp"), RepeatPenalty: v("repeatpenalty"), PresencePenalty: v("presencepenalty"), diff --git a/internal/console/templates/model.html b/internal/console/templates/model.html index 6ac9e55..a59e258 100644 --- a/internal/console/templates/model.html +++ b/internal/console/templates/model.html @@ -21,6 +21,12 @@

{{.Model.ID}}

Parallel slots share the context. The tensor split is the share of the layers on each GPU, such as 3,2.

+
+ + + +
+

A larger micro-batch makes the prompt faster and uses more GPU memory. The split mode applies only to a model that spans GPUs.

diff --git a/internal/hostctl/agent_test.go b/internal/hostctl/agent_test.go new file mode 100644 index 0000000..393ba9a --- /dev/null +++ b/internal/hostctl/agent_test.go @@ -0,0 +1,194 @@ +package hostctl + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// checkTuneArgs runs the host agent's allowlist on one argument list. +func checkTuneArgs(t *testing.T, tool string, args ...string) error { + t.Helper() + f := filepath.Join(t.TempDir(), "args") + body := strings.Join(args, "\n") + if len(args) > 0 { + body += "\n" + } + if err := os.WriteFile(f, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return exec.Command("bash", "../../os/rootfs/usr/libexec/sibyl/agent", "--check-tune-args", tool, f).Run() +} + +func TestAgentTuneAllowlist(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("no bash") + } + good := []struct { + tool string + args []string + }{ + {"bench", []string{"-p", "512", "-n", "128", "-d", "8192", "-r", "2"}}, + {"bench", []string{"-b", "2048,4096", "-ub", "256,512,1024,2048"}}, + {"bench", []string{"-ctk", "f16,q8_0", "-ctv", "f16,q8_0", "-sm", "layer,row", "-ts", "3/2,1/1"}}, + {"bench", []string{"-ncmoe", "0,8,16", "-ot", `blk\.(1[0-9])\.ffn_.*_exps\.=CPU;blk\.2.\.ffn_up_exps\.=CPU`}}, + {"batched", []string{"-c", "32768", "-npp", "512", "-ntg", "128", "-npl", "1,2,4,8", "-ctk", "q8_0"}}, + {"server", []string{"-c", "65536", "-b", "4096", "-ub", "1024", "-np", "4", "-sm", "row", "-ts", "3/2"}}, + {"server", nil}, + } + for _, c := range good { + if err := checkTuneArgs(t, c.tool, c.args...); err != nil { + t.Errorf("%s %v refused: %v", c.tool, c.args, err) + } + } + bad := []struct { + tool string + args []string + }{ + {"bench", []string{"-m", "/etc/shadow"}}, // the agent sets the model + {"bench", []string{"--model", "x.gguf"}}, + {"bench", []string{"-p"}}, // a flag with no value + {"bench", []string{"-p", "512", "-n"}}, + {"bench", []string{"-p", "-n"}}, // a flag as a value + {"bench", []string{"-p", "512;rm -rf /"}}, + {"bench", []string{"-p", "1234567"}}, // too large + {"bench", []string{"-ctk", "f32"}}, + {"bench", []string{"-sm", "tensor"}}, + {"bench", []string{"-ot", "-rpc=CPU"}}, // a pattern that starts with a dash + {"bench", []string{"-ot", "blk.*=ROCm0"}}, // only system RAM + {"bench", []string{"-rpc", "10.0.0.1:50052"}}, + {"bench", []string{"-o", "md"}}, + {"bench", []string{"--hf-repo", "evil/model"}}, + {"batched", []string{"-npl", "1,2", "-c", "1,2"}}, // one value for -c + {"batched", []string{"--host", "0.0.0.0"}}, + {"server", []string{"--host", "0.0.0.0"}}, + {"server", []string{"--port", "80"}}, + {"server", []string{"-np", "4,8"}}, + {"server", []string{"--api-key", "x"}}, + {"server", []string{"-c", "4096\n--host"}}, + {"other", []string{"-p", "512"}}, + } + for _, c := range bad { + if err := checkTuneArgs(t, c.tool, c.args...); err == nil { + t.Errorf("%s %q passed", c.tool, c.args) + } + } +} + +func TestTuneRequest(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "requests"), 0o750); err != nil { + t.Fatal(err) + } + c := New(dir) + if err := c.Request(Tune, "qwen3.8-27b-q8_0"); err != nil { + t.Fatal(err) + } + if err := c.Request(Tune, "../x"); err == nil { + t.Error("a bad model ID passed") + } + names, _ := os.ReadDir(filepath.Join(dir, "requests")) + if len(names) != 1 || !strings.HasSuffix(names[0].Name(), ".tune.qwen3.8-27b-q8_0") { + t.Errorf("requests %v", names) + } +} + +// fakeHost lays out the agent's directories, with a podman and a systemctl +// that log their arguments. +type fakeHost struct { + run, models, share, bin, log string +} + +func newFakeHost(t *testing.T) *fakeHost { + t.Helper() + root := t.TempDir() + h := &fakeHost{run: filepath.Join(root, "run"), models: filepath.Join(root, "models"), share: filepath.Join(root, "share"), + bin: filepath.Join(root, "bin"), log: filepath.Join(root, "podman.log")} + for _, d := range []string{filepath.Join(h.run, "requests"), filepath.Join(h.run, "tune"), h.models, h.share, h.bin} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(h.models, "m.gguf"): "GGUF", + filepath.Join(h.share, "image-tag"): "sha-test", + filepath.Join(h.share, "gpu-variant"): "amd", + filepath.Join(h.bin, "systemctl"): "#!/bin/sh\necho active\n", + // podman logs each call. llama-bench prints one JSONL row, and a + // started llama-server answers its health check. + filepath.Join(h.bin, "podman"): "#!/bin/sh\necho \"$*\" >> \"" + h.log + "\"\n" + + "case \"$*\" in\n" + + " *llama-bench*) echo '{\"n_prompt\":512,\"avg_ts\":1000.5}' ;;\n" + + " *llama-batched-bench*) echo '{\"pl\":4,\"speed\":80.5}' ;;\n" + + " run\\ -d*) echo ctr ;;\n" + + " inspect*) echo true ;;\n" + + "esac\n", + } + for p, body := range files { + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + return h +} + +// tune writes a tune request and runs the agent once. +func (h *fakeHost) tune(t *testing.T, tool string, args ...string) (status, out, calls string) { + t.Helper() + files := map[string]string{"path": "m.gguf", "gpus": "0", "pool": "", "backend": "hip", "tool": tool, + "args": strings.Join(args, "\n") + "\n"} + for ext, body := range files { + if err := os.WriteFile(filepath.Join(h.run, "tune", "m."+ext), []byte(body), 0o640); err != nil { + t.Fatal(err) + } + } + _ = os.Remove(h.log) + if err := os.WriteFile(filepath.Join(h.run, "requests", "1.tune.m"), nil, 0o640); err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", "../../os/rootfs/usr/libexec/sibyl/agent") + cmd.Env = append(os.Environ(), "PATH="+h.bin+":"+os.Getenv("PATH"), "SIBYL_AGENT_RUN="+h.run, + "SIBYL_AGENT_MODELS="+h.models, "SIBYL_AGENT_POOLS="+filepath.Join(h.run, "nopools"), "SIBYL_AGENT_SHARE="+h.share) + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("agent: %v\n%s", err, b) + } + read := func(p string) string { b, _ := os.ReadFile(p); return string(b) } + return read(filepath.Join(h.run, "tune", "m.status")), read(filepath.Join(h.run, "tune", "m.out")), read(h.log) +} + +func TestAgentTuneRuns(t *testing.T) { + if _, err := exec.LookPath("realpath"); err != nil || exec.Command("realpath", "-e", "/").Run() != nil { + t.Skip("no GNU realpath") + } + h := newFakeHost(t) + status, out, calls := h.tune(t, "bench", "-p", "512", "-n", "128", "-d", "8192", "-ub", "512,1024") + if !strings.HasPrefix(status, "done ") || !strings.Contains(out, `"avg_ts":1000.5`) { + t.Errorf("bench: status %q out %q", status, out) + } + for _, want := range []string{"--network none", "--cap-drop all", "--security-opt no-new-privileges", + "ghcr.io/diffsec/sibyl-llama-swap:sha-test-hip", "-m /models/m.gguf -ngl 999 -fa on -o jsonl -p 512 -n 128 -d 8192 -ub 512,1024", + "HIP_VISIBLE_DEVICES=0", "--device /dev/kfd"} { + if !strings.Contains(calls, want) { + t.Errorf("bench: no %q in\n%s", want, calls) + } + } + + status, out, calls = h.tune(t, "batched", "-c", "32768", "-npl", "1,2,4") + if !strings.HasPrefix(status, "done ") || !strings.Contains(out, `"speed":80.5`) || + !strings.Contains(calls, "--output-format jsonl -c 32768 -npl 1,2,4") { + t.Errorf("batched: status %q out %q calls %s", status, out, calls) + } + + status, out, calls = h.tune(t, "server", "-c", "65536", "-ub", "1024") + if !strings.HasPrefix(status, "done ") || !strings.Contains(out, `"loaded":true`) || + !strings.Contains(calls, "--host 127.0.0.1 --port 8080 -c 65536 -ub 1024") || !strings.Contains(calls, "rm -f sibyl-tune-m") { + t.Errorf("server: status %q out %q calls %s", status, out, calls) + } + + status, _, calls = h.tune(t, "bench", "-m", "/etc/shadow") + if !strings.HasPrefix(status, "failed: the arguments are not allowed") || strings.Contains(calls, "run ") { + t.Errorf("bad args: status %q calls %s", status, calls) + } +} diff --git a/internal/hostctl/hostctl.go b/internal/hostctl/hostctl.go index 40e9349..7fac1e1 100644 --- a/internal/hostctl/hostctl.go +++ b/internal/hostctl/hostctl.go @@ -36,6 +36,9 @@ const ( Reload Action = "reload" // Bench runs llama-bench with both backend images on one model. Bench Action = "bench" + // Tune runs one llama.cpp tool for the optimizer on one model, from the + // files in /tune. The agent checks every argument. + Tune Action = "tune" ) // benchTarget matches the model IDs that the host agent accepts. @@ -74,9 +77,9 @@ func (c *Client) Request(a Action, unit string) error { if !slices.Contains(Units, unit) { return fmt.Errorf("unit %q is not one of %v", unit, Units) } - case Bench: + case Bench, Tune: if !benchTarget.MatchString(unit) { - return fmt.Errorf("model ID %q is not valid for a benchmark", unit) + return fmt.Errorf("model ID %q is not valid for a %s request", unit, a) } default: return fmt.Errorf("unknown action %q", a) diff --git a/internal/llamaswap/generate.go b/internal/llamaswap/generate.go index d2aa943..d7b37cd 100644 --- a/internal/llamaswap/generate.go +++ b/internal/llamaswap/generate.go @@ -193,7 +193,11 @@ func command(m models.Model, s models.Settings, p placement, selected int, devPr for i := range devs { devs[i] = devPrefix + strconv.Itoa(i) } - args = append(args, "--split-mode", "layer", "--device", strings.Join(devs, ",")) + mode := s.SplitMode + if mode == "" { + mode = "layer" + } + args = append(args, "--split-mode", mode, "--device", strings.Join(devs, ",")) if len(s.TensorSplit) > 0 { parts := make([]string, len(s.TensorSplit)) for i, v := range s.TensorSplit { @@ -207,6 +211,12 @@ func command(m models.Model, s models.Settings, p placement, selected int, devPr if s.Parallel > 0 { args = append(args, "--parallel", strconv.Itoa(s.Parallel)) } + if s.Batch > 0 { + args = append(args, "--batch-size", strconv.Itoa(s.Batch)) + } + if s.UBatch > 0 { + args = append(args, "--ubatch-size", strconv.Itoa(s.UBatch)) + } args = append(args, s.KVCache.Flags()...) args = append(args, s.MoE.Flags()...) if !s.Embedding { diff --git a/internal/llamaswap/generate_test.go b/internal/llamaswap/generate_test.go index 8ab43b1..1e62832 100644 --- a/internal/llamaswap/generate_test.go +++ b/internal/llamaswap/generate_test.go @@ -235,6 +235,25 @@ func TestGenerateParallelAndSplit(t *testing.T) { } } +func TestGenerateBatchAndSplitMode(t *testing.T) { + ms := []models.Model{{ID: "big", Path: "big.gguf"}, {ID: "small", Path: "small.gguf"}} + cfg, issues := Generate(twoGPUs(hardware.LayoutSpan), ms, map[string]models.Settings{ + "big": {Batch: 4096, UBatch: 1024, SplitMode: "row"}, + "small": {Layout: "gpu:0", SplitMode: "row"}, + }) + if len(issues) != 0 { + t.Fatalf("issues = %+v", issues) + } + if cmd := cfg.Models["big"].Cmd; !strings.Contains(cmd, "--split-mode row") || !strings.Contains(cmd, "--batch-size 4096") || + !strings.Contains(cmd, "--ubatch-size 1024") { + t.Errorf("big cmd %s", cmd) + } + // One GPU does not split, whatever the split mode says. + if cmd := cfg.Models["small"].Cmd; !strings.Contains(cmd, "--split-mode none") || strings.Contains(cmd, "batch-size") { + t.Errorf("small cmd %s", cmd) + } +} + func TestGenerateMoE(t *testing.T) { cfg, issues := Generate(twoGPUs(hardware.LayoutSpan), installed[:2], map[string]models.Settings{ "big-70b": {MoE: models.MoE{CPULayers: models.AllLayers, CacheMiB: 8192}}, diff --git a/internal/modelform/modelform.go b/internal/modelform/modelform.go index 647c924..c13ab81 100644 --- a/internal/modelform/modelform.go +++ b/internal/modelform/modelform.go @@ -20,6 +20,9 @@ type Form struct { CtxSize string Parallel string TensorSplit string // comma separated + Batch string + UBatch string + SplitMode string // empty (layer), layer or row Embedding bool Hidden bool Temperature string @@ -84,6 +87,7 @@ func FromSettings(s models.Settings) Form { MoELayers: FmtMoELayers(s.MoE.CPULayers), Fit: string(s.Fit), Preset: s.Preset, + SplitMode: s.SplitMode, Args: strings.Join(s.Args, " "), } @@ -99,6 +103,12 @@ func FromSettings(s models.Settings) Form { if s.MoE.CacheMiB > 0 { f.MoECacheMiB = strconv.Itoa(s.MoE.CacheMiB) } + if s.Batch > 0 { + f.Batch = strconv.Itoa(s.Batch) + } + if s.UBatch > 0 { + f.UBatch = strconv.Itoa(s.UBatch) + } split := make([]string, len(s.TensorSplit)) for i, v := range s.TensorSplit { split[i] = strconv.FormatFloat(v, 'f', -1, 64) @@ -168,6 +178,7 @@ func (f Form) Settings() (models.Settings, error) { KVCache: models.KVCache{TypeK: f.KVTypeK, TypeV: f.KVTypeV}, Fit: models.FitMode(f.Fit), Preset: f.Preset, + SplitMode: f.SplitMode, } if v := strings.TrimSpace(f.Reasoning); v != "" { name, value, ok := strings.Cut(v, "=") @@ -191,6 +202,16 @@ func (f Form) Settings() (models.Settings, error) { } else if n != nil { s.Parallel = *n } + if n, e := ParseInt("batch size", f.Batch); e != nil { + return s, e + } else if n != nil { + s.Batch = *n + } + if n, e := ParseInt("micro-batch size", f.UBatch); e != nil { + return s, e + } else if n != nil { + s.UBatch = *n + } if s.MoE.CPULayers, err = ParseMoELayers(f.MoELayers); err != nil { return s, err } diff --git a/internal/models/models.go b/internal/models/models.go index 18c3757..b3804f5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -200,6 +200,15 @@ type Settings struct { // model (--tensor-split). Empty lets Sibyl split by the memory that // each GPU has left. TensorSplit []float64 `yaml:"tensor_split,omitempty" json:"tensor_split,omitempty"` + // Batch is the logical batch size (--batch-size), and UBatch the + // physical batch size (--ubatch-size). A larger UBatch speeds up the + // prompt and needs a larger compute buffer. 0 leaves llama-server's + // default. + Batch int `yaml:"batch,omitempty" json:"batch,omitempty"` + UBatch int `yaml:"ubatch,omitempty" json:"ubatch,omitempty"` + // SplitMode is how a spanning model splits across the GPUs: layer (the + // default) or row. It has no effect on one GPU. + SplitMode string `yaml:"split_mode,omitempty" json:"split_mode,omitempty"` // Args are extra llama-server flags for anything the typed fields do not cover. Args []string `yaml:"args,omitempty" json:"args,omitempty"` } @@ -207,6 +216,15 @@ type Settings struct { // MaxParallel is the most parallel slots that a model can have. const MaxParallel = 64 +// Limits of the batch sizes. +const ( + MinBatch = 32 + MaxBatch = 65536 +) + +// SplitModes are the split modes that a spanning model can use. +var SplitModes = []string{"layer", "row"} + var ( safeArg = regexp.MustCompile(`^[A-Za-z0-9_.,:=/+-]+$`) reservedArg = map[string]bool{ @@ -249,6 +267,20 @@ func (s Settings) Validate(selected int) error { return errors.New("tensor_split: values must be 0 or more") } } + for _, b := range []struct { + name string + v int + }{{"batch", s.Batch}, {"ubatch", s.UBatch}} { + if b.v != 0 && (b.v < MinBatch || b.v > MaxBatch) { + return fmt.Errorf("%s: must be %d to %d", b.name, MinBatch, MaxBatch) + } + } + if s.Batch > 0 && s.UBatch > s.Batch { + return fmt.Errorf("ubatch %d: must not be larger than batch %d", s.UBatch, s.Batch) + } + if s.SplitMode != "" && !slices.Contains(SplitModes, s.SplitMode) { + return fmt.Errorf("split_mode %q: use one of %s", s.SplitMode, strings.Join(SplitModes, ", ")) + } if err := s.Sampling.Validate(); err != nil { return err } @@ -295,9 +327,41 @@ func LoadSettings(path string) (map[string]Settings, error) { if err := yaml.Unmarshal(data, &out); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } + for id, s := range out { + out[id] = s.moveBatchArgs() + } return out, nil } +// batchArgs are the raw args that became the typed Batch and UBatch. +var batchArgs = map[string]func(*Settings) *int{ + "-b": func(s *Settings) *int { return &s.Batch }, "--batch-size": func(s *Settings) *int { return &s.Batch }, + "-ub": func(s *Settings) *int { return &s.UBatch }, "--ubatch-size": func(s *Settings) *int { return &s.UBatch }, +} + +// moveBatchArgs moves batch flags from Args to the typed fields. Before the +// typed fields, the batch sizes could only be set as raw args. +func (s Settings) moveBatchArgs() Settings { + var args []string + for i := 0; i < len(s.Args); i++ { + flag, val, eq := strings.Cut(s.Args[i], "=") + field, ok := batchArgs[flag] + if !ok { + args = append(args, s.Args[i]) + continue + } + if !eq && i+1 < len(s.Args) { + i++ + val = s.Args[i] + } + if n, err := strconv.Atoi(val); err == nil && *field(&s) == 0 { + *field(&s) = n + } + } + s.Args = args + return s +} + // SaveSettings writes models.yaml atomically. func SaveSettings(path string, s map[string]Settings) error { data, err := yaml.Marshal(s) diff --git a/internal/models/models_test.go b/internal/models/models_test.go index df257ce..0caa64a 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -91,7 +91,12 @@ func TestSettingsValidate(t *testing.T) { {"pin in range", Settings{Layout: "gpu:1"}, false}, {"pin out of range", Settings{Layout: "gpu:2"}, true}, {"bad layout", Settings{Layout: "both"}, true}, - {"safe args", Settings{Args: []string{"--batch-size=2048", "--ubatch-size=512"}}, false}, + {"safe args", Settings{Args: []string{"--threads=8", "--mlock"}}, false}, + {"batch in args", Settings{Args: []string{"--batch-size=2048"}}, true}, + {"batch", Settings{Batch: 4096, UBatch: 1024, SplitMode: "row"}, false}, + {"batch too small", Settings{Batch: 16}, true}, + {"ubatch over batch", Settings{Batch: 512, UBatch: 1024}, true}, + {"bad split mode", Settings{SplitMode: "tensor"}, true}, {"shell chars", Settings{Args: []string{"--temp;rm"}}, true}, {"reserved flag", Settings{Args: []string{"--port=1"}}, true}, {"reserved short flag", Settings{Args: []string{"-m"}}, true}, @@ -125,3 +130,21 @@ func TestSettingsRoundTrip(t *testing.T) { t.Errorf("round trip = %+v", out) } } + +func TestLoadSettingsMovesBatchArgs(t *testing.T) { + p := filepath.Join(t.TempDir(), "models.yaml") + body := "a:\n args: [--batch-size=4096, -ub, \"1024\", --mlock]\nb:\n batch: 2048\n args: [-b, \"512\"]\n" + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got, err := LoadSettings(p) + if err != nil { + t.Fatal(err) + } + if a := got["a"]; a.Batch != 4096 || a.UBatch != 1024 || len(a.Args) != 1 || a.Args[0] != "--mlock" { + t.Errorf("a %+v", a) + } + if b := got["b"]; b.Batch != 2048 || len(b.Args) != 0 { + t.Errorf("b keeps its typed batch: %+v", b) + } +} diff --git a/internal/models/tuning.go b/internal/models/tuning.go index 5ada8c4..2786e84 100644 --- a/internal/models/tuning.go +++ b/internal/models/tuning.go @@ -124,6 +124,7 @@ var typedFlags = []string{ "-c", "--ctx-size", "--embedding", "--embeddings", "-np", "--parallel", "-ts", "--tensor-split", "-cmoe", "--cpu-moe", "-ncmoe", "--n-cpu-moe", "--moe-cache-mib", + "-b", "--batch-size", "-ub", "--ubatch-size", } func checkFloat(name string, v *float64, lo, hi float64) error { diff --git a/internal/tui/formsections.go b/internal/tui/formsections.go index 3cf187e..c201ba7 100644 --- a/internal/tui/formsections.go +++ b/internal/tui/formsections.go @@ -24,7 +24,7 @@ type settingsSection struct { } var settingsSections = []settingsSection{ - {"Model", []string{"Layout", "Fit", "Parallel", "CtxSize", "TensorSplit", "KVTypeK", "KVTypeV", "Embedding", "Hidden"}}, + {"Model", []string{"Layout", "Fit", "Parallel", "CtxSize", "TensorSplit", "Batch", "UBatch", "SplitMode", "KVTypeK", "KVTypeV", "Embedding", "Hidden"}}, {"Mixture of Experts", []string{"MoELayers", "MoECacheMiB"}}, {"Sampling defaults", []string{"Preset", "Temperature", "TopK", "TopP", "MinP", "RepeatPenalty", "PresencePenalty"}}, {"Reasoning", []string{"Reasoning"}}, diff --git a/internal/tui/models.go b/internal/tui/models.go index 9137795..d72140a 100644 --- a/internal/tui/models.go +++ b/internal/tui/models.go @@ -200,6 +200,12 @@ func (v *modelsView) editFormWith(m client.Model, fields settingsForm) tea.Cmd { DescriptionFunc(func() string { return contextNote(m, f, hw, gpus) }, f), huh.NewInput().Title("Tensor split").Placeholder(splitHint(m)).Value(&f.TensorSplit). Description("The share of the layers on each GPU, such as 3,2. Empty splits by the memory that each GPU has left."), + optionalNumber("Batch size", "", true, &f.Batch).Placeholder("llama.cpp default"), + optionalNumber("Micro-batch size", "", true, &f.UBatch).Placeholder("llama.cpp default"). + Description("A larger micro-batch makes the prompt faster and uses more GPU memory."), + huh.NewSelect[string]().Title("Split mode").Inline(true).Options( + huh.NewOption("Layer", ""), huh.NewOption("Row", "row"), + ).Value(&f.SplitMode).Description("Only for a model that spans GPUs."), huh.NewSelect[string]().Title("KV cache K type").Inline(true).Options(kv...).Value(&f.KVTypeK), huh.NewSelect[string]().Title("KV cache V type").Inline(true).Options(kv...).Value(&f.KVTypeV), huh.NewConfirm().Title("Embedding model").Value(&f.Embedding), diff --git a/os/rootfs/usr/libexec/sibyl/agent b/os/rootfs/usr/libexec/sibyl/agent index 4559e6a..bd302c2 100755 --- a/os/rootfs/usr/libexec/sibyl/agent +++ b/os/rootfs/usr/libexec/sibyl/agent @@ -10,14 +10,19 @@ # restart. restart one Sibyl unit # reload.all systemctl --user daemon-reload # bench. run llama-bench with each backend image of this variant +# tune. run one llama.cpp tool for the optimizer (see tune) set -euo pipefail -RUN=/var/lib/sibyl/run +# The tests set these. On the host, the systemd unit runs the agent with no +# such variables, and no container can set them. +RUN="${SIBYL_AGENT_RUN:-/var/lib/sibyl/run}" +SHARE="${SIBYL_AGENT_SHARE:-/usr/share/sibyl}" UNITS=(llama-swap router open-webui caddy arcane system-api) REQUEST_RE='^[0-9]{1,20}\.(restart|reload)\.(all|llama-swap|router|open-webui|caddy|arcane|system-api)$' BENCH_RE='^[0-9]{1,20}\.bench\.([a-z0-9][a-z0-9._-]{0,127})$' -MODELS=/var/lib/sibyl/models -POOLS=/var/lib/sibyl-pools +TUNE_RE='^[0-9]{1,20}\.tune\.([a-z0-9][a-z0-9._-]{0,127})$' +MODELS="${SIBYL_AGENT_MODELS:-/var/lib/sibyl/models}" +POOLS="${SIBYL_AGENT_POOLS:-/var/lib/sibyl-pools}" write_status() { local tmp sep="" unit active sub since @@ -65,7 +70,7 @@ bench() { rel="${model#"$(realpath "$root")"/}" split=none [[ "$gpus" == *,* ]] && split=layer - tag="$(cat /usr/share/sibyl/image-tag)" + tag="$(cat "$SHARE/image-tag")" echo "running $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$dir/$id.status" # Free the GPUs: llama-swap starts the model again on the next request. @@ -74,7 +79,7 @@ bench() { # The backends and GPU devices depend on the image's GPU variant, as in # the System API's drop-in (internal/llamaswap DropIn). local variant backends - variant="$(cat /usr/share/sibyl/gpu-variant)" + variant="$(cat "$SHARE/gpu-variant")" case "$variant" in nvidia) backends="cuda vulkan" ;; intel) backends="vulkan" ;; @@ -101,7 +106,7 @@ bench() { echo "sibyl-agent: benchmarking $id with $backend" if ! podman run --rm --network none \ "${devices[@]}" --group-add keep-groups \ - --userns keep-id:uid=10001,gid=10001 --cap-drop all --security-opt no-new-privileges \ + --userns "keep-id:uid=10001,gid=10001" --cap-drop all --security-opt no-new-privileges \ -v "$root:/models:$mount" -v llama-swap-cache:/app/.cache:U \ -e "$visible=$gpus" --entrypoint /opt/llama/bin/llama-bench "$image" \ -m "/models/$rel" -ngl 999 -fa on -sm "$split" -p 512 -n 128 -r 2 -o json \ @@ -112,9 +117,159 @@ bench() { echo "done $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$dir/$id.status" } +# The optimizer's arguments. Every flag that a tool takes is here, with a +# pattern for its value. llama-bench takes a comma list for each flag and +# tests every combination. The other tools take one value. +NUM='[0-9]{1,6}' +NUMS="$NUM(,$NUM){0,15}" +KV='(f16|bf16|q8_0|q4_0|q4_1|q5_0|q5_1|iq4_nl)' +SM='(none|layer|row)' +TS='[0-9]{1,3}(\.[0-9]{1,3})?(/[0-9]{1,3}(\.[0-9]{1,3})?){0,15}' +# An override keeps tensors that match a pattern in system RAM. The pattern +# must not start with a dash, so it cannot pass for a flag. +OT='[][A-Za-z0-9_.*+?|()^$\][][A-Za-z0-9_.*+?|()^$\-]{0,199}=CPU' + +# tune_value_re prints the pattern for a flag of a tool, or nothing. +tune_value_re() { + local tool="$1" flag="$2" + case "$tool:$flag" in + bench:-p|bench:-n|bench:-d|bench:-b|bench:-ub|bench:-ncmoe|bench:-r) echo "^$NUMS\$" ;; + bench:-ctk|bench:-ctv) echo "^$KV(,$KV){0,7}\$" ;; + bench:-sm) echo "^$SM(,$SM){0,2}\$" ;; + bench:-ts) echo "^$TS(,$TS){0,7}\$" ;; + bench:-ot) echo "^$OT(;$OT){0,7}(,$OT(;$OT){0,7}){0,7}\$" ;; + batched:-npp|batched:-ntg|batched:-npl) echo "^$NUMS\$" ;; + batched:-c|batched:-b|batched:-ub|batched:-ncmoe|server:-c|server:-b|server:-ub|server:-ncmoe|server:-np) + echo "^$NUM\$" ;; + batched:-ctk|batched:-ctv|server:-ctk|server:-ctv) echo "^$KV\$" ;; + batched:-sm|server:-sm) echo "^$SM\$" ;; + batched:-ts|server:-ts) echo "^$TS\$" ;; + batched:-ot|server:-ot) echo "^$OT(;$OT){0,7}\$" ;; + esac +} + +# tune_check_args checks an argument file: one argument per line, a flag +# then its value. It prints the arguments, one per line, or fails. +tune_check_args() { + local tool="$1" file="$2" flag value re n=0 + local -a lines + mapfile -t lines < "$file" + if (( ${#lines[@]} % 2 != 0 || ${#lines[@]} > 64 )); then + echo "sibyl-agent: tune arguments must be flag and value pairs" >&2 + return 1 + fi + while (( n < ${#lines[@]} )); do + flag="${lines[n]}" value="${lines[n+1]}" + re="$(tune_value_re "$tool" "$flag")" + if [[ -z "$re" || ! "$value" =~ $re ]]; then + echo "sibyl-agent: tune argument '$flag' is not allowed for $tool" >&2 + return 1 + fi + printf '%s\n%s\n' "$flag" "$value" + n=$((n + 2)) + done +} + +tune_fail() { echo "failed: $2" > "$RUN/tune/$1.status"; } + +# tune runs one tool for the optimizer. The API wrote these files in +# $RUN/tune: .path, .pool and .gpus as for bench, .backend, .tool +# (bench, batched or server) and .args. The output goes to .out and +# the tool's log to .log. +tune() { + local id="$1" dir="$RUN/tune" rel gpus pool root mount model backend tool tag image visible variant ctr checked ok=0 i + local -a devices args=() + rel="$(cat "$dir/$id.path" 2>/dev/null || true)" + gpus="$(cat "$dir/$id.gpus" 2>/dev/null || true)" + pool="$(cat "$dir/$id.pool" 2>/dev/null || true)" + backend="$(cat "$dir/$id.backend" 2>/dev/null || true)" + tool="$(cat "$dir/$id.tool" 2>/dev/null || true)" + root="$MODELS" mount=ro,z + if [[ -n "$pool" ]]; then + [[ "$pool" =~ ^pool-[0-9]{1,2}$ ]] || { tune_fail "$id" "invalid tune request"; return; } + root="$POOLS/$pool" mount=ro + fi + model="$(realpath -e -- "$root/$rel" 2>/dev/null || true)" + if [[ -z "$rel" || "$rel" != *.gguf || "$model" != "$(realpath "$root")"/* || ! "$gpus" =~ ^[0-9]+(,[0-9]+)*$ ]]; then + tune_fail "$id" "invalid tune request" + return + fi + rel="${model#"$(realpath "$root")"/}" + variant="$(cat "$SHARE/gpu-variant")" + case "$variant:$backend" in + nvidia:cuda|nvidia:vulkan|intel:vulkan|amd:hip|amd:vulkan) ;; + *) tune_fail "$id" "backend $backend is not in this image"; return ;; + esac + case "$tool" in + bench|batched|server) ;; + *) tune_fail "$id" "unknown tool"; return ;; + esac + if ! checked="$(tune_check_args "$tool" "$dir/$id.args")"; then + tune_fail "$id" "the arguments are not allowed" + return + fi + [[ -n "$checked" ]] && mapfile -t args <<< "$checked" + tag="$(cat "$SHARE/image-tag")" + image="ghcr.io/diffsec/sibyl-llama-swap:${tag}-${backend}" + case "$backend" in + hip) visible=HIP_VISIBLE_DEVICES ;; + cuda) visible=CUDA_VISIBLE_DEVICES ;; + *) visible=GGML_VK_VISIBLE_DEVICES ;; + esac + if [[ "$variant" == nvidia ]]; then + devices=(--device nvidia.com/gpu=all) + elif [[ "$backend" == hip ]]; then + devices=(--device /dev/kfd --device /dev/dri) + else + devices=(--device /dev/dri) + fi + local -a run=(--network none "${devices[@]}" --group-add keep-groups + --userns "keep-id:uid=10001,gid=10001" --cap-drop all --security-opt no-new-privileges + -v "$root:/models:$mount" -v llama-swap-cache:/app/.cache:U -e "$visible=$gpus") + echo "running $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$dir/$id.status" + rm -f "$dir/$id.out" "$dir/$id.log" + podman exec llama-swap curl -fsS http://127.0.0.1:8080/unload >/dev/null 2>&1 || true + echo "sibyl-agent: tune $id: $tool with $backend" + case "$tool" in + bench) + podman run --rm "${run[@]}" --entrypoint /opt/llama/bin/llama-bench "$image" \ + -m "/models/$rel" -ngl 999 -fa on -o jsonl "${args[@]}" > "$dir/$id.out" 2> "$dir/$id.log" && ok=1 + ;; + batched) + podman run --rm "${run[@]}" --entrypoint /opt/llama/bin/llama-batched-bench "$image" \ + -m "/models/$rel" -ngl 999 -fa on --output-format jsonl "${args[@]}" > "$dir/$id.out" 2> "$dir/$id.log" && ok=1 + ;; + server) + # Start llama-server with the settings, and wait until it has + # loaded the model and allocated the whole context. + ctr="sibyl-tune-$id" + podman rm -f "$ctr" >/dev/null 2>&1 || true + if podman run -d --name "$ctr" "${run[@]}" --entrypoint /opt/llama/bin/llama-server "$image" \ + -m "/models/$rel" -ngl 999 -fa on --host 127.0.0.1 --port 8080 "${args[@]}" >/dev/null 2> "$dir/$id.log"; then + for ((i = 0; i < 600; i++)); do + if podman exec "$ctr" curl -fsS http://127.0.0.1:8080/health >/dev/null 2>&1; then + ok=1 + break + fi + [[ "$(podman inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null)" == true ]] || break + sleep 1 + done + printf '{"loaded":%s,"seconds":%d}\n' "$( ((ok)) && echo true || echo false)" "$i" > "$dir/$id.out" + podman logs "$ctr" >> "$dir/$id.log" 2>&1 || true + podman rm -f "$ctr" >/dev/null 2>&1 || true + fi + ;; + esac + if (( ok )); then + echo "done $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$dir/$id.status" + else + tune_fail "$id" "$(tail -n 1 "$dir/$id.log" 2>/dev/null | tr -cd '[:print:]' | cut -c1-300)" + fi +} + handle_requests() { local f name action unit reload=0 - local -a restarts=() benches=() + local -a restarts=() benches=() tunes=() shopt -s nullglob for f in "$RUN"/requests/*; do name="$(basename "$f")" @@ -123,6 +278,10 @@ handle_requests() { benches+=("${BASH_REMATCH[1]}") continue fi + if [[ "$name" =~ $TUNE_RE ]]; then + tunes+=("${BASH_REMATCH[1]}") + continue + fi if [[ ! "$name" =~ $REQUEST_RE ]]; then echo "sibyl-agent: rejected request '$name'" >&2 continue @@ -150,9 +309,15 @@ handle_requests() { for unit in "${benches[@]}"; do bench "$unit" done + for unit in "${tunes[@]}"; do + tune "$unit" + done } -if [[ "${1:-}" != "--status" ]]; then - handle_requests -fi +case "${1:-}" in + --status) ;; + # --check-tune-args TOOL FILE checks optimizer arguments, for the tests. + --check-tune-args) tune_check_args "$2" "$3"; exit ;; + *) handle_requests ;; +esac write_status From a0ebcab80e83fbd0d97e27e7c1d73ed0161f014a Mon Sep 17 00:00:00 2001 From: Ben Gittins <5601410+ihavespoons@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:28:01 +1000 Subject: [PATCH 2/2] Bump the fedora-bootc base image digest Co-Authored-By: Claude Opus 5.5 --- os/Containerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/os/Containerfile b/os/Containerfile index 1421453..9dda0fb 100644 --- a/os/Containerfile +++ b/os/Containerfile @@ -26,7 +26,7 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/sibyl-hostd ./cmd/ FROM scratch AS variants COPY os/variants/ / -FROM quay.io/fedora/fedora-bootc:44@sha256:3f4b21855f45581d8c82cd44931c2a06e8a13e93d871f0c70fc387fad7e89e01 +FROM quay.io/fedora/fedora-bootc:44@sha256:6bda175e89575aa3dfb6b0b4b8342978cb7de8d6109042f0338560eb3fe82c15 ARG GPU_VARIANT=amd ARG SIBYL_VERSION=dev