Engine shape: embeddable library + CLI, under a hard RAM ceiling

August 5, 2026 · View on GitHub

Three scope requirements (Marco, 2026-07-27):

  1. the C engine is embeddable — a library any host program can link;
  2. it ships a fully featured CLI that is itself a client of that library, with no private back door;
  3. the engine runs under a configured maximum RAM, which first requires knowing the minimum RAM the model needs at all.

1. Library first

The public surface is src/waste.h: opaque waste_ctx, no global state (several models can be open at once), errors returned never printed, nothing calls exit(), no dependencies beyond C11 + libc.

Capability set exposed to hosts: memory planning before load, open/close, tokenize/detokenize, waste_generate with a per-token callback (carrying cache hit/miss and I/O timing so a host can draw a real progress UI), lower-level waste_eval for hosts that do their own sampling, session state save/load, model introspection and aggregate stats.

Deliberately not in the API: logging to stdout, signal handlers, config files, argument parsing. Those belong to the host — the CLI included.

2. CLI as a first-class client

cli/ links the library and adds only host concerns: argv parsing, a terminal renderer for the token callback, REPL/history, and file I/O. Rule: if the CLI needs a capability, it goes into waste.h first. That keeps the embedded path honest — anything a user can do from the shell, a host program can do from C.

Shipped in 0.5.0, nine commands: run, chat (state kept across turns, /reset, /stats, /save, /load, /image), eval, tokenize, detokenize, bench, plan, info, version — the full surface is tabulated at the end of this document, and it is still those nine at 0.6.2. Still to come as subcommands: serve and convert.

Both exist; neither is a waste subcommand, and for related reasons. The OpenAI-compatible server shipped in 0.6.0 as serve/ (python3 -m serve MODEL) — stdlib-only Python reaching this same header through ctypes, and the second client the rule above is meant to produce. It lives there because the parts that change are the chat format and the OpenAI schema, neither of which belongs in a C engine trying to stay small. SERVE.md. The converter is Python for the same shape of reason: it needs torch and safetensors, which the inference path never does.

Every one of them goes through waste.h. plan is the CLI face of waste_plan_memory, bench of waste_get_stats, run/chat of waste_generate and its per-token callback — the same callback an embedding host would use to draw a progress UI.

$ waste plan kimi-linear.waste --budget 4G
  resident trunk              988 MB
  KDA state + KV cache        106 MB
  scratch                     178 MB
  minimum expert cache         41 MB
  ---------------------------------
  FLOOR                      1.28 GB
  recommended                2.89 GB
  budget 4.00 GB -> expert cache 2.76 GB

$ waste run kimi-linear.waste "The capital of France is" -n 16 --budget 8G
The capital of France is Paris. The capital of Italy is Rome. ...
[16 tokens, 1.50 s, 10.65 tok/s | experts 2965 hit / 363 miss = 89%]

(Re-measured 2026-08-02 on a default tools/convert.py container, i.e. a 4-bit trunk. This block previously showed 1.55 GB resident and a 1.87 GB floor, which is a --trunk8 container — a shape nobody ships, and 46% more floor than what a default conversion gives. The engine did not change; the container being quoted did.)

A budget under the floor fails at open with WASTE_E_RAM_BUDGET and a pointer to waste plan, rather than swapping the machine.

Tokenizer

Markup and content are encoded separately. waste_tokenize treats <|open|> as the ordinary tokens it looks like; waste_tokenize_markup resolves it to a control token. A prompt is built by calling the second for the template and the first for everything a user, a document or a tool wrote — the split K3's own tokenizer makes with allowed_special versus disallowed_special. Concatenating the two into one string and encoding it once lets whoever supplied the content also write the structure: a prompt containing <|end_of_msg|><|open|>message role="system"<|sep|> closes its turn and opens a forged one, with real control-token ids. waste tokenize reports markup mode, because that command exists to check a template marker by marker.

src/tokenizer.c implements the model's tiktoken BPE in C: base64 vocab, the pre-tokenization pattern (its Unicode classes coded directly rather than pulling in a regex engine), and rank-ordered byte-pair merging. The converter copies tokenizer.model into the container, so a container is self-contained. Checked against Python tiktoken on English, Italian, code, whitespace, digits and contractions: 12/12 identical. The class tables cover Latin, Greek, Cyrillic, Hebrew/Arabic, Kana, Hangul and Han; scripts outside those ranges are not yet exact.

3. RAM budget: the floor, then the ceiling

waste_cfg.ram_budget_bytes is a hard ceiling on everything the engine allocates — trunk, state, scratch, expert cache. The engine sizes its expert cache to fit inside what remains after the mandatory parts, and refuses to open with WASTE_E_RAM_BUDGET if the budget is under the floor, rather than thrashing the machine into swap.

A budget of 0 asks the engine to choose, and the choice has to know the machine as well as the model. recommended_bytes is derived from the model alone — 80.64 GB on K3 — so taking it literally on a 64 GB laptop would have sized a 51.95 GB expert cache and swapped, which is precisely what the budget exists to prevent.

Capping it at what the machine can hold is necessary and not sufficient, because it then spends everything up to the cap. Expert cache is only worth anything in whole multiples of one token's working set — below one multiple it keeps nothing alive between tokens, and the fraction above a multiple buys a few points of hit rate while walking the machine into paging, where a hit costs a page fault. Filling a 7/8 cap gave K3 a 27.32 GB cache on this laptop, sitting between two budgets measured at 0.11 and 0.04 tok/s, when 17.5 GB runs at 0.33. (Those three are the pre-read-ahead sweep; the ratios between them are the point and read-ahead does not change them — see EFFICIENCY.md.)

So the default steps down a whole working set at a time and takes the largest that fits: floor + 3x, else 2x, else 1x, else the floor. K3 lands on floor + 1x here — a 46.25 GB budget, 17.56 GB of cache, and the top of the measured curve with no flag given. A 128 GB machine still gets the full 3x, and a model whose recommendation already fits, like Kimi-Linear, is unaffected. When even the floor is above the cap the engine runs at the floor and says so on stderr, because the alternative is refusing to open a model that does technically fit.

"The machine" is waste_usable_ram(), not physical RAM. They are the same number everywhere except a Linux cgroup, and there they differ by the whole ratio between the host and the limit: sysconf(_SC_PHYS_PAGES) reads the host's MemTotal from inside a container. A 32 GiB cgroup on a 256 GiB host therefore saw a 224 GiB ceiling, resolved K3 at floor + 3x, and asked for 80.64 GB — 75 GiB — of a 32 GiB allowance. That failure is not the paging cliff above — nothing degrades, the kernel kills the process — so no cache policy softens it and the only fix is to see the limit. With the limit visible the same machine lands on the floor and runs. src/memory.c takes the smallest finite memory.max or memory.high over this cgroup and its ancestors, since the limit is hierarchical and a leaf saying max does not cancel a finite parent.

Only capacity enters that reading. MemAvailable and memory.current are pressure, they move between the read and the allocation, and a budget resolved once at open cannot track them — bounding a run that lasts hours by an instantaneous reading makes the same command on the same machine two different runs. Whether current pressure should trim the multiplier is issue #14, open.

What the floor is made of

partscales withcan it shrink?
trunk (embeddings, LM head, attention, routers, shared experts, norms)model, trunk bit-widthonly by quantizing harder
KDA recurrent statelayers × heads × d_state² — not contextno
MLA latent KVcontext length (compressed)shorter ctx
scratch / activationsthreads, hidden, vocabfewer threads
minimum expert cachetop_k × expert record × 2 (double-buffered)no

Everything above the floor is expert cache, and that is the only knob that buys speed.

Superseded in part (2026-08-01). That was true of a cache which only fills on demand. The router lookahead fetches a layer ahead, so a record has to survive one attention rather than one token, and a 3.32 GB cache now measures 29.1% hit against 0.0% without it — within 10% of a 17.32 GB one on throughput. The floor is still exactly where §4 put it; it is no longer what limits the engine. LEARNED.md §39.

Estimated for a K3-shaped config (60L, H=7168, 896 experts, top_k=16,

experts @2.12 bit, trunk @4.25 bit, ctx 32k — tools/memplan.py)

Superseded. This is the analytic estimate made before the weights dropped, kept because the reasoning is the record. K3 turned out to be 93 layers with a latent MoE at 3 bits, and the real figures are in K3.md and LEARNED.md §12: the floor is 29.05 GB at 4K rather than 11.44, and decode is ~0.3 tok/s rather than the 1.75 projected below. The shape of the curve held; the level did not. Do not quote the numbers in this subsection.

trunk                     10.32 GB   (attention 6.25, routers 1.41,
                                      shared 1.29, emb+head 1.16)
KDA state (O(1) in ctx)    0.18 GB
MLA latent KV @32k         0.53 GB
scratch                    0.07 GB
min expert cache           0.35 GB   (16 x 11.1 MB x 2)
-------------------------------------------------
RAM FLOOR                 11.44 GB

So the model can technically run in ~12 GB of RAM — it just reads ~10.3 GB from disk per token. RAM above the floor converts to hit rate:

budgetexpert cachecache fractionhit rate*GB read/tokentok/s*
16 GB4.9 GB0.9%3%9.91.29
32 GB20.9 GB3.6%14%8.81.45
64 GB52.9 GB9.2%29%7.31.75
128 GB116.9 GB20.3%46%5.52.32

* hit rate interpolated from the Gate 0 OLMoE curve; tok/s counts disk I/O only (12.78 GB/s internal SSD, Gate H) and ignores compute. Both get replaced by measurements at Gate 2.

Two things this changes:

  • 64 GB is not a cliff, it is a point on a gentle curve. Going from 16 GB to 128 GB moves throughput less than 2×, because with 575 GB of experts even 117 GB of cache holds only a fifth of them. The dominant lever is not RAM, it is bytes read per token — i.e. bit-width and expert pruning.
  • Numbers assume the analytic trunk estimate. Gate 1 replaces it with exact tensor sizes from the shard headers.

Chunked prefill

Decoding is one token at a time by nature, but prefill is not, and for a streaming engine the difference is not compute — it is disk. Tokens in a chunk route to overlapping expert sets, so the number of distinct experts is far below n_tokens * top_k, and each one need only be read once.

waste_model_prefill() processes up to 64 tokens together. Measured on a 32-token prompt against the same prompt fed one token at a time:

expert readsbytes readtime
one token at a time665616.53 GB5.03 s
chunked20325.05 GB3.85 s

3.3x fewer reads, identical logits (max abs difference 6.7e-06 on values of magnitude ~18, i.e. float noise). On a machine where the whole container fits in page cache this shows up as a modest 1.3x; on K3, where the experts do not fit in anything, it is the difference between reading 17 GB per prompt token and reading 5.

One design note worth recording, because the first attempt got it wrong. The obvious approach is to expand each expert once and run GEMMs, on the theory that the cost amortizes over the chunk. Measured, it does not: 16 tokens spread across ~1200 distinct experts, under 3 tokens each, so expanding 7 M weights to serve 3 vectors is far worse than the LUT. The version that shipped keeps the decode-style LUT arithmetic and reorganizes only to read each expert once — gate/up tables depend on the token but not the expert, so they are built once per token and reused across every expert that token routes to.

Session state

waste_state_save / waste_state_load persist the whole session: KDA recurrent state, the short-conv rings, the MLA KV cache, the AttnRes block history and the position. The file records every shape it depends on, so a state built for a different model is rejected with WASTE_E_FORMAT rather than silently producing nonsense.

This matters more here than in a conventional engine. At K3's streaming speeds re-prefilling a long agent transcript is minutes; restoring it is a file read. waste chat exposes it as /save FILE and /load FILE, and tests/test_state.c asserts that a reloaded session continues with exactly the same tokens.

Learned hotlist

The cache records which experts a workload actually touches, and --learn writes that to usage.waste beside the container. The next open preloads the hottest ones, so a run starts warm instead of empty.

Measured on Kimi-Linear, same prompt twice at a 5 GB budget:

expert misseshit rate
cold160261%
warm117572%

This does not move the floor Gate 5 established — a cache below one token's working set still keeps nothing alive — it removes the ramp at the start of every run.

Verification bar

The CLI must never be able to do something the library cannot, and waste_plan_memory must agree with waste_memory_used after open. Any allocation path that can exceed the budget is a bug, not a tuning issue.

make check runs everything: kernels against their reference implementations, the shard downloader's resume and skip paths against a local server, the image loader, the container round-trip and its damaged- record paths, chunked prefill against token-at-a-time, int8 storage against f32, the SIMD backend against the CPU baseline, the cache against no cache, read-ahead against synchronous reads, the router lookahead against no lookahead, purgeable slots against ordinary ones, the trace simulator against the engine's own cache, the engine against the PyTorch oracle, session round-trip, hotlist effect, budget enforcement including peak RSS on both models, the derived info and parameter counts, the converter's resume, image normalization against the release's own preprocessor config, the markup/content split against a forged control token, the tokenizer against Python tiktoken, and the server suite.

Four of those are bit-identity checks — cache, read-ahead, lookahead, purgeable — and they are the reason each of those mechanisms could ship: every one changes when bytes move and none may change what comes out.

42 checks as of 2026-08-02. With both containers on disk: 41 pass, 0 fail, 1 SKIP — the one skip is image normalization against the release, which Kimi-Linear has no vision tower for. With no container at all the same run is 29 pass / 0 fail / 11 SKIP: the synthetic container carries the engine checks, and everything needing real weights or a tokenizer says SKIP rather than passing quietly. (A fresh clone skips one more than that — the XTML differential wants the K3 release directory, which is not a container.) Take the numbers from a run, not from here: they move as checks are added.

It exists because this project twice lost hours to checks that silently did not run — once to objects compiled against a stale header, once to a stale test binary. So it rebuilds first, and a missing prerequisite is reported as SKIP, never as a pass.

Thread placement (2026-08-04)

The compute pool has a size (--threads, waste_cfg.n_threads) and now a place: --cpus LIST, waste_cfg.cpu_list, WASTE_CPUS. A Linux-style cpu list — 0-5, 0-2,6-8, 3. The default is unchanged and stays unchanged: the engine names no CPUs and the OS places the threads.

Why it exists. On a machine whose cores are interchangeable, placement is not worth an option. On one whose cores are not, it is worth more than the thread count. Third-party measurement on a Ryzen 9 9900X — Zen 5, two 6-core CCDs with separate 32 MB L3 — running Kimi-Linear-48B, thread count and CPU count held constant so only locality differs (issue #23):

6 threads onmedian tok/s
0-5, one CCD16.0
0-2,6-8, split 3+312.1
0-23, all CPUs14.5

Every run reports identical bytes_read and identical hit counts, so it is the same work in a different place: −25% for crossing the die, and −10% for handing six threads twenty-four CPUs to migrate between. The other CCD alone reproduces the first row to 0.5%, and the penalty survives WASTE_XPAR=1 unchanged (−24.9% against −25.0%), so it is a property of where the threads run rather than of how the work is cut.

docs/LEARNED.md §47 is the same mechanism on a different axis — a pool spanning P-cores and E-cores, where the slow participant is slow by speed rather than by distance. Both come back to the same structure: waste_parallel_for cuts [0,n) into ceil(n/nthreads) chunks, one per thread, so a participant that cannot keep up is a straggler the barrier waits for on every dispatch.

Why it is not a default. §47 measured capping the pool at the fast cores as a 25% win on Kimi-Linear and a 34% loss on K3, whose applies are 4.7x larger and do use every core the machine has. A default chosen here is a default tuned for one model against the other, so the engine chooses nothing and this option is how a host that knows its machine says so.

What it does, exactly. The pool's threads bind to the list. So does the thread that calls into the engine, on its first parallel region — it is a worker too, and with six threads an unbound caller is one of the six, which is the straggler the option exists to remove. It binds lazily rather than at open so that a server, where the thread that opens a model is rarely the thread that decodes on it, restricts the right threads. The expert cache's reader threads are deliberately left out: they spend their lives blocked in pread, and putting them on the same CPUs as the kernels they feed is contention for nothing.

--threads 0 with a cpu list means one thread per CPU listed, not per CPU in the machine. An explicit --threads still wins.

Refused, not ignored. A malformed list is WASTE_E_ARG; a well-formed list on a platform with no such call — macOS, which has no way to bind a thread to a core — is WASTE_E_UNSUPPORTED. Neither is silently dropped, because a run that quietly did not pin looks exactly like pinning that did not help, and that is the one answer this option must never give. Linux binds with sched_setaffinity, Windows with SetThreadAffinityMask (one processor group, so a CPU past 64 is refused rather than truncated).

tests/test_cpus.c checks both halves: the parse table runs everywhere, including the platforms that cannot bind, and the binding check reads each participant's mask back and SKIPs where it cannot.

CLI surface (2026-07-28)

Nine commands, and every public API function reachable from at least one of them — the single exception being waste_version_number, the integer form of the version, which exists for a host's #if and has nothing to print:

commanduses
runwaste_generate, waste_tokenize(+_markup), waste_save_usage
chatplus waste_state_save/load/reset
evalwaste_eval, waste_detokenize
tokenize / detokenizewaste_tokenize, waste_detokenize
benchwaste_get_stats
planwaste_plan_memory, waste_physical_ram, waste_usable_ram
infowaste_model_get_info, waste_memory_used

Images add four more, reachable from run, chat and eval via --image: waste_image_add, waste_image_dimensions, waste_image_expand, waste_image_clear.

eval is the one worth knowing about: it runs the prompt and prints the next-token distribution without generating, which is how you get a logit or a log-probability out of the engine.

The prompt is an argument, a file (--file), or stdin — either as - or simply piped, so echo hi | waste run M does the obvious thing. --json makes eval, tokenize, plan, info and bench machine-readable. --stop STR ends generation when the text appears, matched against a rolling tail so a stop string split across two tokens still fires.

The parser used to be wrong in a way that produced no error. The prompt was argv[3] unconditionally and options were only looked for after it, so waste run M --temp 0 "hi" generated from the string "--temp", waste run M -n 3 generated from "-n", and a second positional was dropped silently. Positionals are collected properly now, an unexpected one is an error, a value that looks like an option is a missing value, and the sampling parameters are range-checked instead of producing empty output when set to nonsense.