kimi-k3-in-c

FareedKhan-devApache-2.0活跃维护

用纯 C99 从零实现的 Kimi K3 推理引擎,单 CPU、8.24 GB 内存即可跑 2.78 万亿参数模型,不依赖 BLAS、框架或 GPU。

A 2.78-trillion-parameter Kimi K3 running inference on a single CPU in 8.24 GB of RAM. Portable C99: no BLAS, no framework, no GPU.

v1.1.0发布于 2026-10-02 03:35 UTC11,626star +405 / 7 天1,806fork同步于 2026-11-10 23:29 UTC

怎么上手

由 README 提炼,执行前请确认
git clone https://github.com/FareedKhan-dev/kimi-k3-in-c.git
cd kimi-k3-in-c
make -j
make test
硬件要求按 README 说明

无需 GPU,CPU 即可运行;8GB 内存可跑(峰值 RSS 8.24GB),内存越多越快,128GB 以上可全量驻留内存;README 未说明具体 CPU 型号要求,但依赖 OpenMP 多核。

纯 CPU 可运行
下载:源码 zip·GitHub Releases(安装包)本站不镜像安装包

项目说明

由 README 自动翻译提炼

用纯 C99 在单 CPU、8GB 内存上跑 2.78 万亿参数 Kimi K3 推理,无需 GPU 和框架

用纯 C99 从零实现的 Kimi K3 推理引擎,单 CPU、8.24 GB 内存即可跑 2.78 万亿参数模型,不依赖 BLAS、框架或 GPU。

核心能力

  • 用流式读取把 1.56TB 权重从磁盘逐层喂入内存
  • 按内存大小选 preset,128GB 以上可全量驻留内存
  • 提供 pack-trunk 把 93 层稠密层重排成单文件按偏移读取
  • 内置 make test 用 13 层同构小模型对齐 PyTorch 参考
  • 支持增量解码与 KV cache、KDA 状态复用

容易踩的坑

需额外下载 1.56TB checkpoint,下载不完整不会报错但会生成错误 token;运行前需先用 pack-trunk.sh 生成约 109GB 的 trunk 文件并放在最快的磁盘上;首次 token 需从磁盘加载全部 pinned 层,耗时远高于稳态速度。

内容由模型根据 README 生成,可能不完整或不准确,以官方文档为准。看原文 README

最近发版

3 个版本v1.1.0
  1. v1.1.0最新正式版2026-10-02 · 2026-10-02 03:35 UTC

    Chat, native Windows builds, an 8 GB class mode for the complete model, NEON kernels for ARM, and a round of hardening on everything that parses checkpoint files. Output is unchanged wherever it was already defined: every oracle gate still matches the PyTorch reference exactly.

    One compatibility note: --save-state files are now version 2, with a checksum. A version 1 file is refused rather than read; regenerate it with --gen 0 --incremental --save-state.

    New

    • --chat: the official K3 XTML chat format, with JSONL history, --no-think, --thinking-effort, and opt-in sampling through --temperature, --top-p, --top-k and --seed. Greedy by default. Later turns reuse the previous turn's state instead of re-prefilling the transcript, behind a bit-exact gate.
    • --preset ultra: the complete 93-layer model in an 8 GB class memory budget, verified with four full runs on a Jetson Orin Nano Super.
    • Native Windows builds through MSYS2 MinGW-w64, with every gate passing, and NEON kernels on arm64.
    • Ctrl-C stops at a safe point, still writes the state file, the results and the reports, and exits 5. A second Ctrl-C kills.
    • --stop-id, --gen 0 to warm a prefix once and resume it many times, --threads, and --trunk-ring.
    • download-model.sh <dest> --layers N fetches only the shards a layer prefix needs, about 7 GB for N=1, for trying the pipeline on a disk too small for the checkpoint.

    Faster, output unchanged

    • The MXFP4 expert matmul is about 1.6x faster, with another 1.56x from a flat-row AVX2 path.
    • Trunk layers are read in parallel chunks and pinned largest-first; expert reads are split into 1 MiB chunks.
    • On Linux the thread count now defaults to physical cores, which measured about 1.3x faster per token on a 16-core SMT machine.

    Fixed

    • --preset auto could not start on machines with roughly 100 GB or more available, and printed a negative shortfall.
    • macOS: reads of 2 GiB or more failed, and available memory read as 0, which switched off both memory checks.
    • Hardening across the safetensors reader, trunk.json and the JSON parser: integer overflow, out-of-bounds reads, overlapping or gapped tensor spans, and truncated documents. Checked against the released checkpoint's own files, including all 96 shard headers.
    • A batched prefill path summed uninitialized memory when an expert failed to load.
    • A partial download now names the missing file, using the checkpoint's real file names.
    • State files are checksummed and saved by atomic rename, so a failed save no longer destroys the previous one.

    Verification

    make test, ctest, both sanitizer jobs and the strict-warnings build are green on Linux, macOS and Windows, and every oracle gate matches the PyTorch reference exactly.

    Thanks to everyone whose work is in this release: @Avicennasis, @FermiHart, @cablepull, @Deobot2, @BlakeEvans22, @santhoshsathish94, @Irfanwani, @AuricTW, @arafatsolok, @genesisrevelationinc-debug, @TROY665, @openchat-ai, @ysgao, @ShaalanMarwan, @cwwjacobs, @biokraft and @Barba2k2.

    Full log in CHANGELOG.md. Diff: v1.0.0...v1.1.0.

  2. v1.0.02026-08-07 · 2026-08-07 16:39 UTC
    查看更新说明

    Verified end to end on the full released 1.56 TB checkpoint, and made substantially faster, with byte-identical output preserved at every step. The first-run experience, broken on a clean clone, now works.

    Faster (output unchanged)

    • Fused matmul kernels (fp32, bf16, MXFP4): about 8x less per-token compute, scalar and AVX2 paths bitwise identical.
    • --preset auto: sizes the trunk and expert-cache budgets from the machine's own free RAM, trunk-first.
    • Chunk-union prefill: fetches each unique routed expert once per chunk, about half the expert bytes on a prompt, generated token bit-identical.
    • Conversation resume (--save-state / --load-state): a second turn resumes instead of re-reading the prompt, 3.9x faster.
    • KDA recurrence parallelised over heads; --spec N speculative decode, exact by construction.

    Correctness and first run

    • All shell scripts are committed executable; the first documented command works on a clean clone.
    • download-model.sh pins an immutable revision with checksum verification and refuses without free space.
    • k3-doctor.sh no longer fails a machine that can build and test the engine.
    • The config-refusal fixtures are gated in make test, ctest, and CI.
    • Two silent-corruption paths (the MLA KV overflow and the single-slot trunk reader) now abort or are prevented.

    Verification

    Token ids byte-identical to the reference from 8 GB to 128 GB+, oracle gates untouched, make test and ctest green.

    Full log in CHANGELOG.md; measured speed in docs/data/speed-2026-08.md.

  3. v0.1.02026-08-02 · 2026-08-02 04:33 UTC

    这个版本没有写更新说明。

用户点评

登录用户的真实使用记录,可按版本和硬件筛选
共 0 条

    还没有人点评。第一个说出真实体验的人最有价值。

    README 原文

    未翻译

    Same short prompt at every size, and the output is byte-identical from the smallest machine to the largest; only the clock changes. One machine, 124 cores, fast NVMe drive: the first three rows still read the model from disk each step, so a slower drive is slower there, while the 128 GB+ row keeps everything in memory and no longer waits on the disk. On that same machine v1.0.0 made the math per token about 8× lighter, a follow-up question in a chat 3.9× faster, and long prompts about half as costly. (A token is roughly a short word-piece; the two runnable demos below are the original captures on a slower drive, so their clock reads a little higher.) Full data in docs/data/.

    The macOS, Windows and NEON ports, chat mode, the ultra preset, the faster MXFP4 kernels and a long list of parser fixes came from the people below. Who did what.

    $ ./bin/k3 ~/k3model --trunk ~/k3trunk --preset laptop \
               --tok ~/k3model --prompt "The capital of France is" --gen 8 --incremental
    
    --- generated text ---
     Paris.",
    +            "The Eiffel
    ----------------------
    8 tokens in 261.5 s, 32.69 s/token average
    PEAK RSS for the whole run: 8.24 GB
    

    Slow, and answering correctly, in 8.24 GB, from a checkpoint of 1.56 TB. This particular batch command deliberately asks for a raw continuation. The official Kimi K3 checkpoint is also chat-capable; use the XTML REPL below when you want answers and multi-turn history. Give the same batch request more memory and the answer does not change, only the clock:

    $ ./bin/k3 ~/k3model --trunk ~/k3trunk --preset server \
               --tok ~/k3model --prompt "def fibonacci(n):" --gen 28 --incremental
    
    --- generated text ---
        if n <= 1:
            return n
        else:
            return fibonacci(n-1) + fibonacci
    ----------------------
    28 tokens in 299.3 s, 10.69 s/token average
    PEAK RSS for the whole run: 127.92 GB
    

    Every figure in this document comes from the measurement output in docs/data/.

    A small resident working set on top, the model itself on NVMe underneath, and a few labelled pipes between them

    The dense trunk stays in memory to whatever depth you choose and streams the rest; the 1.45 TB of routed experts are never resident, and are multiplied straight out of their packed 4-bit form. The consequence is that the same model runs in 8 GB and in 224 GB and produces byte-identical output at every budget between.

    Four decisions about where bytes live take it from a cluster to a laptop, and the answer at the bottom is the same as the answer at the top:

    Four steps from a server cluster down to an ordinary laptop, with the same output at both ends

    Part II builds every box in both diagrams from scratch, one component at a time.


    Contents

    Part I: Getting started

    Part II: How it works

    Part III: Validation

    Part IV: Measurements

    Part V: Reference


    Part I: Getting started

    Requirements

    The gate is storage: the checkpoint is 1.56 TB. Everything else is ordinary.

    OSLinux, x86-64 (reference); macOS/arm64 and Windows/x86-64 also build and pass every gateuses O_DIRECT, posix_memalign, getrusage -- ported for Windows via MSYS2's MinGW-w64 (see src/io/k3_portable_io.h)
    CPUAVX2 + FMA on x86-64, NEON on arm64AVX-512 unnecessary. make portable targets generic AVX2 on x86-64
    RAM8 GB and upevery preset works; more memory is faster, never different
    Storage~1.7 TB free1.56 TB checkpoint + 109 GB packed trunk, ideally on fast local disk
    ToolchainGCC ≥ 9 or Clang ≥ 10GNU make, or CMake
    Python3.9+for the download, pack and analysis tools; not for make test

    The tokenizer and config reader are portable C99 and build anywhere. Without a checkpoint you can still do everything in Quick start.

    Quick start

    Clone, build and run the entire test suite. No checkpoint, no network, no Python. The whole thing takes about a minute.

    git clone https://github.com/FareedKhan-dev/kimi-k3-in-c.git
    cd kimi-k3-in-c
    
    make -j            # seconds. Seven C files, a compiler and OpenMP
    make test          # under a minute
    

    It ends like this, or it failed:

    GATE 1  teacher forcing : 32/32 positions match tf_pred
            generated span  : 20/20  <- must be exact
    GATE 2  greedy decode   : 20/20 generated tokens match full_ids
    GATE 3  incremental    : 20/20 generated tokens match full_ids  <- KV cache + carried KDA state
    
    VERDICT: ENGINE MATCHES THE REFERENCE EXACTLY
    
    ALL WEIGHTLESS TESTS PASSED
    

    That is the whole engine: every kernel, the streaming cache, the safetensors reader, the config reader, the tokenizer, and an end-to-end oracle over a 13-layer model built with the same tensor graph as the released one, checked against a PyTorch reference from fixtures committed to the repository.

    One published measurement also replays on the spot, from a trace recorded during a full 93-layer run (this one needs Python 3.9+ and numpy):

    python3 tools/sim_cache.py tests/fixtures/expert_trace.bin
    

    100,096 expert requests, reprinting the capacity table in expert-cache-capacity.txt.

    Full setup

    Six steps from an empty directory to generated text. Only step 4 is slow.

    ./scripts/k3-doctor.sh can be run at any point. It checks the toolchain, sizes your RAM to a preset, measures your storage, and prints the exact command to run next.

    Step 0. clone

    git clone https://github.com/FareedKhan-dev/kimi-k3-in-c.git
    cd kimi-k3-in-c
    

    About 45 MB, most of it the diagrams and the test fixtures.

    Step 1. check the machine

    ./scripts/k3-doctor.sh
    

    Takes about a minute, because it measures your disk the way the engine reads it. It exits non-zero if the machine cannot run the model at all.

    Step 2. build

    make -j
    

    Seconds. The only dependencies are a C99 compiler, libm and OpenMP. CMake works too:

    cmake -B build && cmake --build build -j && ctest --test-dir build
    

    Step 3. verify before downloading anything

    make test
    

    This is worth doing before committing to a 1.56 TB download: it proves the engine matches its reference on a model with the same tensor graph, and it needs nothing but the repository.

    Step 4. fetch the checkpoint

    1.56 TB, so hours rather than minutes. Get a token from huggingface.co/settings/tokens:

    export HF_TOKEN=hf_your_token_here          # read from the environment, never echoed
    ./scripts/download-model.sh ~/k3model       # resumable, re-run to continue
    

    The script finishes by verifying the shard count, the exact byte total, and then every individual per-shard size against the published figures:

    verifying…
      shards : 96 (expect 96)
      bytes  : 1560936091448 (expect 1560936091448)
      shards : all 96 match their published sizes individually
      RESULT : byte-exact match
    

    A partial download does not fail loudly; it produces wrong tokens. Treat a FAIL here as a stop. Checking per shard also turns "re-download 1.56 terabytes" into "re-download this one 17 gigabyte file", and it catches the one case a total cannot: two shards wrong in opposite directions by the same amount.

    Step 5. pack the trunk

    ./scripts/pack-trunk.sh ~/k3model ~/k3trunk
    

    About four minutes, once. It rewrites the 93 dense layers into one 109 GB file where layer L lives at a known offset and can be read in a single call. This is what turns the memory requirement into a dial. Put the output on the fastest disk you have.

    Step 6. run

    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset workstation \
             --tok ~/k3model --prompt "The capital of France is" --gen 8 --incremental
    

    The tokenizer ships with the checkpoint, which is why --tok points at the model directory.

    Where everything ends up

    kimi-k3-in-c/    ~45 MB   source, docs, images, and bin/k3
    ~/k3model/      1.56 TB   96 shards · config.json · tiktoken.model · tokenizer_config.json
    ~/k3trunk/       109 GB   trunk.bin · trunk.json, on the fastest disk you have
    

    The first token of any run loads every pinned layer from disk, about 108 GB at the server preset, so it takes far longer than the steady rate. That cost is paid once per run, not once per token.

    Usage

    Synopsis

    k3 <model_dir> [prompt] [memory] [generation] [diagnostics]
    

    <model_dir> is the directory holding the .safetensors shards. It is required for any run, but --help, --version and --list-presets work without it:

    ./bin/k3 --help
    ./bin/k3 --version
    ./bin/k3 --list-presets
    

    Prompt options

    Outside --chat, exactly one of these is required. Passing none, or more than one, is a usage error (exit 2).

    flagargument
    --promptTEXTtokenize TEXT and run it. Requires --tok.
    --prompt-filePATHtokenize the file's bytes. Requires --tok. Preferred for anything non-ASCII: the shell re-encodes argv, whereas a file is read verbatim
    --ids1,2,3token ids directly. No tokenizer is loaded at all, so this works on a machine with no tokenizer files. The reproducible channel the tests use
    # text in
    ./bin/k3 ~/k3model --tok ~/k3model --prompt "The capital of France is" ...
    
    # text in, from a file. Use this for CJK, emoji, accents
    printf 'La capitale de la France est' > /tmp/p.txt
    ./bin/k3 ~/k3model --tok ~/k3model --prompt-file /tmp/p.txt ...
    
    # ids in, ids out, no tokenizer needed
    ./bin/k3 ~/k3model --ids 1008,10484,318,15383,387 ...
    

    Memory options

    flagargumentdefault
    --presetNAMEnonelaptop · desktop · workstation · server · max. Sets both budgets below
    --trunkDIRoffthe packed trunk directory from step 5. This is what enables streaming. Without it the trunk loads fully resident, around 113.5 GB
    --trunk-gbX16budget for pinned layers plus the streaming ring
    --cache-gbX64budget for the routed-expert LRU cache
    --trunk-ringN2streaming ring slots: one layer being computed on, the rest reads in flight. Each extra slot costs one slot of RAM, and the budget still wins if it does not fit
    --threadsNphysical coresOpenMP threads. On Linux the default is the physical core count rather than OpenMP's one thread per logical CPU, which is slower here on SMT parts. OMP_NUM_THREADS, if set, is respected
    --ultra-low-memorynoneoffstream exact embedding rows and lm_head chunks; full recompute also reuses one recurrent-state slot. Requires --trunk

    The ultra preset selects --ultra-low-memory with a 2.5 GB trunk ring and a 0.31 GB expert cache. It is a proof-of-life path for 8 GB-class machines, not an interactive-speed preset; model precision, Top-K routing and all 93 layers are unchanged.

    --preset and the two -gb flags set the same two numbers, so a preset is just a shorthand. Order matters if you mix them: a later flag wins, so --preset server --cache-gb 40 gives you the server trunk budget with a 40 GB cache.

    --preset without --trunk does nothing useful. Every preset assumes the trunk is streamed. Omit --trunk and the engine loads all 113.5 GB resident regardless of the budget you asked for.

    Generation options

    flagargumentdefault
    --genN8tokens to generate. Ceiling 4096; prompts may be up to 32768 tokens. --gen 0 --incremental --save-state PATH runs only the prefill and saves its exact state, to warm a prefix once and resume it many times
    --stop-idNoffstop as soon as the model emits id N; repeatable, up to 8. The released checkpoint declares two end ids that disagree (163586 in config.json, 163585 in tokenizer_config.json) and emits both, so pass both
    --incrementalnoneoffcarry the KV cache and the recurrent state between tokens instead of re-running the whole prefix
    --tokDIRnonedirectory holding tiktoken.model and tokenizer_config.json

    Pass --incremental for any generation of length. Without it every step re-runs the entire prefix, which is O(T²); with it, step 0 pays for the prompt and every later step costs the same fixed amount. Both paths are gated on producing identical tokens, so this is a pure speed choice.

    Text chat (official Kimi K3 XTML)

    K3 is chat-capable. --chat uses the official XTML segments and tokenizer control tokens; it does not fall back to ChatML or a handwritten generic prompt. Version one is text-only: there are no tools, images, server, or context compaction.

    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset desktop --tok ~/k3model \
      --chat --system "You are a helpful assistant." \
      --history my-session.jsonl --incremental
    

    The REPL accepts ordinary text plus /help, /reset, and /exit. It prints the complete canonical record, including <think>…</think> and <response>…</response>. When --history is supplied, it writes a portable, human-readable JSONL file such as:

    {"role":"system","content":"You are a helpful assistant."}
    {"role":"user","content":"Explain cache locality."}
    {"role":"assistant","content":"…","reasoning_content":"…"}
    

    Treat that file as sensitive: it can contain every user message and the complete assistant reasoning record, which K3 requires to continue a conversation faithfully. On restart the engine validates, re-renders, and re-prefills the transcript; it deliberately does not serialize opaque KV, MLA, or KDA state. Within one session under --incremental the state a turn built is kept, and the next turn prefills only its new tail when the rendered transcript begins with exactly the ids that state was fed (the REPL prints how many positions were reused); /reset or any other divergence starts over. A supplied --system must exactly match an existing initial system record. Literal control-marker text in user messages is encoded as ordinary text, never as an XTML control token.

    Chat defaults to --gen 4096 and, like batch generation, to greedy decoding. Passing any of --temperature, --top-p or --seed turns sampling on (temperature 1.0, top-p 0.95 unless given); --greedy forces argmax even then. Sampling uses PCG32; --seed N deterministically derives one stream per assistant turn, so replaying the same transcript with the same seed is reproducible. The 32K prompt / 4096 generation limits are existing engine limits, not new chat limits; chat fails clearly and retains the history when either context or safe KV allocation is reached.

    Thinking is on by default with thinking_effort=max, which is exactly what the checkpoint's own tokenizer does when apply_chat_template is given nothing; --thinking-effort low or high changes only that setting. --no-think is the encoder's thinking=False: no thinking-effort system message, prior assistant turns rendered without a think channel, and a generation prompt that opens the response channel directly, so the model answers at once and the REPL prints no <think> block. On a streamed trunk this is the largest speed lever there is: in a real-checkpoint run, 119 of the 150 tokens of a five-word answer were the think block. Reasoning already stored in the transcript is left in place and is rendered again the next time the conversation runs with thinking on. Both flags are byte- and id-exact against the official tokenizer in tests/unit/test_chat.c.

    Chat uses the same CPU-only streamed trunk and routed-expert cache as batch mode. --preset, --trunk-gb, and --cache-gb keep exactly their existing meanings: no experts are preloaded and the trunk remains disk-streamed unless those existing memory flags ask otherwise.

    Diagnostic options

    flagargument
    --configPATHmodel config; defaults to <model_dir>/config.json
    --layersNbind only the first N layers, for partial shard sets
    --outFILEJSON results (default k3_run.json)
    --dump-logitsPATHfloat32 logits for the first step, for elementwise comparison
    --dump-cache-traceDIRwrites expert_hist.json and expert_trace.bin, which tools/sim_cache.py replays

    Exit codes

    Scripts can rely on these.

    0success
    1a tensor failed to bind, or a forward pass failed
    2usage error, or a config that could not be read with confidence; the engine declines to guess
    3the run finished but the --out file could not be written, so the results a harness reads are missing
    4the run finished, but at least one routed expert failed to load, so the emitted ids are unsound. Distinct from 1 because the process otherwise succeeded, and it is the code that catches silent numerical corruption
    5stopped early by Ctrl-C. The step in flight finished, and the state file, --out and the reports were all written, but the token list is shorter than asked for. A second Ctrl-C kills the process instead

    Environment variables

    variableused by
    HF_TOKENdownload-model.shHuggingFace token, read from the environment and never echoed
    OMP_NUM_THREADSthe enginethread count. When neither this nor --threads is given, Linux defaults to the physical core count
    K3_TOK_FILEStokenizer tools and CIdirectory holding tiktoken.model, when it is not in a default location
    K3_MODEL_DIRtools/budget.pycheckpoint directory, when not given as an argument

    Worked examples

    # Full-model, one-token proof of life on an 8 GB-class ARM64 machine.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset ultra \
             --tok ~/k3model --prompt "The capital of France is" --gen 1
    
    # Smallest possible run, the 8 GB floor.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset laptop \
             --tok ~/k3model --prompt "Hello! My name is" --gen 16 --incremental
    
    # Fastest per gigabyte. Pins 90 of 93 trunk layers.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset server \
             --tok ~/k3model --prompt "def fibonacci(n):" --gen 28 --incremental
    
    # Hand-tuned split instead of a preset: everything to the trunk.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --trunk-gb 110 --cache-gb 13 \
             --tok ~/k3model --prompt-file prompt.txt --gen 32 --incremental
    
    # Reproducible: ids in, ids out, no tokenizer, JSON results.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset desktop \
             --ids 1008,10484,318,15383,387 --gen 8 --incremental --out run.json
    
    # Capture a cache trace, then replay it offline at any capacity.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset workstation \
             --ids 1008,10484,318,15383,387 --gen 8 --incremental \
             --dump-cache-trace /tmp/trace
    python3 tools/sim_cache.py /tmp/trace/expert_trace.bin
    
    # Elementwise logit comparison against the PyTorch reference.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --preset server \
             --ids 3,4,5,6,7 --gen 1 --dump-logits /tmp/c_logits.bin
    python3 tools/cmp_logits.py /tmp/c_logits.bin ref_logits.json
    
    # Partial shard set: bind only the first 8 layers.
    ./bin/k3 ~/k3model --trunk ~/k3trunk --layers 8 \
             --ids 1,2,3 --gen 1
    
    # Under a hard memory ceiling, which is how the ladder was measured.
    systemd-run --scope --user -q -p MemoryMax=8G -p MemorySwapMax=0 \
      ./bin/k3 ~/k3model --trunk ~/k3trunk --trunk-gb 2.5 --cache-gb 0.5 \
               --ids 1008,10484,318,15383,387 --gen 8 --incremental
    

    Choosing a preset

    $ ./bin/k3 --list-presets
    presets (trunk / expert-cache, in GB):
      ultra          2.50 / 0.31    ~3 GB planned: streamed model tables, one state slot. Slow.
      laptop         3.00 / 1.00    8.2 GB peak RSS. The ordinary-path floor.
      desktop       16.00 / 10.00   31.9 GB peak RSS.
      workstation   60.00 / 30.00   95.5 GB peak RSS; the expert cache starts to matter here.
      server       110.00 / 13.00   ~128 GB peak RSS; 90 of 93 trunk layers pinned. Fastest.
      max          110.00 / 109.00  ~224 GB peak RSS; trunk pinned and a large expert cache.
    
    All presets stream the trunk, so they need --trunk <packed_dir>.
    Run scripts/k3-doctor.sh to see which one this machine fits.
    

    What each preset actually costs in memory

    The boundaries come from the measured ladder, and the doctor keys on MemAvailable rather than MemTotal:

    if   [ "$AVAIL_GB" -ge 192 ]; then PRESET=server;      EXPECT="~6 s/token"
    elif [ "$AVAIL_GB" -ge  96 ]; then PRESET=workstation; EXPECT="~6-20 s/token"
    elif [ "$AVAIL_GB" -ge  32 ]; then PRESET=desktop;     EXPECT="~24 s/token"
    elif [ "$AVAIL_GB" -ge  10 ]; then PRESET=laptop;      EXPECT="~27 s/token"
    else PRESET=""; fi
    

    Two things worth knowing before you pick:

    • max is not faster than server in these measurements. The extra 96 GB buys nothing outside the noise floor.
    • A preset needs a little more free memory than its peak RSS. The engine refuses any plan above 95% of available memory, to leave room for everything outside the plan, so server at about 128 GB needs roughly 135 GB available, not 128.
    • Give the trunk memory before the expert cache. At a fixed 128 GB budget that was worth 1.69×. Allocation beats capacity has the data.

    Reading the run report

    The engine prints a memory plan, then a line per generated token, then a summary. Abridged from a workstation run:

    cache [final step]
      requests     : 1472  hits 1472 (100.00%)  misses 0  evictions 729
                     TRUE resident hit rate 50.48%
    I/O share of wall clock: 71.1%  (trunk 62.4 s + experts 34.2 s of 135.8 s)
    trunk [final]
      pinned 48/93 layers, ring 1 slots
      read 368.65 GB in 62.40 s (5908 MB/s)
    PEAK RSS for the whole run: 94.74 GB   <- quote this, not the plan
    

    Three numbers carry the meaning:

    • TRUE resident hit rate: experts served from RAM. The raw hits counter also counts experts the prefetcher pulled off disk moments earlier, so it reads 100% at every cache size; the resident figure is printed beneath it.
    • I/O share of wall clock: whole-run disk time against total, measured between 41% and 61% across the ladder.
    • PEAK RSS: from getrusage, after the run. This is the memory figure; the up-front plan runs slightly above it.

    Common questions

    Memory sits near 113 GB even at a small preset. --trunk was omitted. Without a packed trunk directory the whole trunk is loaded resident; every preset assumes streaming.

    A non-ASCII prompt tokenizes oddly. The shell re-encodes argv, so the engine receives different bytes than you typed. Put the prompt in a file and use --prompt-file, which is read verbatim.

    --prompt/--prompt-file need --tok DIR. The tokenizer ships with the checkpoint, so add --tok ~/k3model. The engine exits rather than guessing where the vocabulary lives. To skip the tokenizer entirely, pass token ids with --ids.

    Throughput is well below the table. Almost always storage. python3 tools/devbw.py <file-on-that-disk> measures the disk the way the engine reads it, using large random O_DIRECT reads at queue depth 1 and 16, which dd does not. Network volumes run several times slower than local NVMe; keep ~/k3trunk local.

    The run refused to start over the KV cache. Context costs about 2.37 MB per position regardless of budget, and the engine computes that up front rather than discovering it an hour in. Shorten the request, or drop --incremental, which carries no KV cache at all.

    Is the whole 1.56 TB needed? For generation, yes. For development, no: make test needs nothing at all, and --layers N runs against partial shard sets. scripts/download-model.sh <dest> --layers N fetches only the shards those layers need, about 7 GB for N=1 and 125 GB for N=8. That is for exercising the pipeline on a small disk; a layer prefix is not the model and does not produce its output.

    Why no BLAS? Because every matmul here has to give the same bits on every machine. The test suite checks the engine against a PyTorch reference exactly, not to a tolerance, and that only works if the order in which each dot product is summed is fixed and known. A BLAS library chooses its own blocking and reduction order, which can differ between versions, between vendors (OpenBLAS, MKL, Accelerate), and between thread counts on the same machine. Each of those answers is numerically correct and none of them matches the others bit for bit. The kernels in src/core/k3_ops.c exist to pin that order: the AVX2 and NEON paths reproduce the scalar reduction exactly, and test_ops checks they do. It also keeps the build free of a dependency that installs differently on each platform, for kernels that are not the bottleneck anyway. A run is limited by how fast the trunk and the experts come off disk, not by arithmetic.

    macOS, Windows, WSL? Linux is the reference platform. macOS/arm64 builds with plain make (see the Makefile's platform block). Windows builds natively too, via MSYS2's MinGW-w64 GCC (pacman -S mingw-w64-x86_64-gcc, then open the "MSYS2 MinGW x64" shell specifically -- make, make test, and make test-all all pass every gate unmodified, including the full-model oracle and tokenizer parity against real Kimi K3 weights. Four Linux-only calls needed porting -- O_DIRECT, pread, posix_memalign, and getrusage -- documented in src/io/k3_portable_io.h. One real bug surfaced during the port and is worth knowing if you extend this code on Windows: _aligned_malloc, which backs the posix_memalign shim, must be freed with _aligned_free, not plain free; POSIX's posix_memalign carries no such restriction, so this is easy to get wrong silently -- it compiles, and Windows terminates the process with STATUS_HEAP_CORRUPTION only once the corrupted allocator metadata is actually used. make asan/make ubsan switch to Clang on Windows (pacman -S mingw-w64-clang-x86_64-clang mingw-w64-clang-x86_64-compiler-rt): MinGW-w64's GCC package ships no sanitizer runtime at all, confirmed directly rather than assumed. WSL works too, unmodified, since it is just Linux -- the tokenizer and config reader are portable C99 either way and build anywhere, in CI included.


    Part II: How it works

    The problem: a model that does not fit

    Kimi K3 has 2.78 trillion parameters and is 1.56 terabytes as shipped. No consumer machine can hold it, and waiting for better hardware does not help, because the wall is not speed, it is capacity.

    But it is a mixture of experts, so only 16 of its 896 experts per layer fire for any given token and the rest sit asleep on disk. Keep the always-on part in memory, stream the sleeping experts, and it fits in 8.24 gigabytes on one CPU with no GPU.

    The naive requirement is the one every parameter count implies.

    The naive requirement: every parameter resident at bf16

    So 5.56 terabytes is the number to beat.

    One token wakes 16 experts and leaves 880 asleep

    Kimi K3 has 93 layers. Layer 0 is a plain dense feed-forward layer, so the other 92 layers route, and each of those picks the top 16 experts out of 896.

    Only 16 of 896 experts fire per layer, so most of the model sleeps

    About 104 billion parameters are active for any given token, out of 2.78 trillion, which is 3.7 percent. The other 96.3 percent still has to exist somewhere reachable, but it does not have to be in RAM.

    Counting the actual bytes on disk rather than guessing:

    === shard census: what the 1.56 TB actually is ===
    shards            : 96
    total bytes       : 1560936091448  (1.56 TB)
    
    --- routed experts (the part that is streamed, never resident) ---
      experts total     : 82,432   (896 routed x 92 MoE layers)
      bytes per expert  : 17,547,264  exactly
                          = 33,030,144 params x 0.53125 bytes
                          = 0.5 bytes/nibble + 1/32 byte for the shared E8M0 scale
      routed expert set : 82,432 x 17,547,264 = 1.447 TB
    

    There are 82,432 routed experts, each occupying exactly 17,547,264 bytes. Together they are 1.447 terabytes, which is 93 percent of the entire checkpoint. Everything else (attention projections, routers, norms, embeddings) is the remaining 7 percent.

    Where the 1.56 TB lives: 93% of it is experts that never load

    That census is the whole strategy in one picture. If those 1.447 terabytes can be reachable but never resident, the memory problem shrinks by more than an order of magnitude before a single kernel is written.

    The always-active set: 113.49 GB at bf16, everything else is streamable

    What is left is 56,743,648,000 parameters, or 113.49 gigabytes at bfloat16. Of that, 108.81 GB is the per-layer dense trunk and 4.70 GB is the embedding table plus the output head.

    The four reductions

    • 5,560 GB: every parameter at bfloat16, where we start.
    • 1,560 GB: the checkpoint as shipped, because the experts already arrive at half a byte per weight.
    • 113.49 GB: what has to be resident once routing means the experts never load.
    • 8.24 GB: what is measured, once the trunk is streamed instead of held.

    Four reductions, and the output is identical at both ends

    End to end that is a 675× reduction from the bfloat16 model and 189× from the shipped checkpoint. Nothing is approximated and no weight is dropped: the output at the bottom of that ladder is byte for byte the output at the top. The chart at the top of this document is that ledger drawn to scale.

    The machine, and what it assumes

    Every measurement here comes from one workstation: a two-socket AMD EPYC 7763 with 124 cores and no SMT, 228 GB of RAM, and 3.2 TB of NVMe. It also has four NVIDIA L40 GPUs, which sat completely idle for the entire campaign, because this engine has no GPU path.

    --- ISA (note: AVX2 present, AVX-512 ABSENT) ---
    avx avx2 fma sse4_2
    
    --- memory ---
    Mem:           228Gi       5.1Gi       207Gi       3.1Mi        18Gi       223Gi
    MemTotal:       239308464 kB
    MemAvailable:   233961008 kB
    Hugepagesize:       2048 kB
    

    There is no AVX-512. The engine needs AVX2 and FMA and nothing more, the instruction set on any desktop CPU from the last decade.

    The storage numbers matter more than the CPU numbers, and one runs against expectation.

    --- storage bandwidth, measured ---
    O_DIRECT cold : 3.2 GB/s     (dd bs=4M iflag=direct after drop_caches)
    buffered warm : 2.3 GB/s
    engine, trunk : 5373-6064 MB/s sustained during runs
    NOTE O_DIRECT is FASTER than buffered here. That is the opposite of the usual
    expectation, and it is why the engine opens the trunk O_DIRECT.
    

    Reading with O_DIRECT, bypassing the page cache entirely, is faster here than reading through it. That single measurement decided the whole I/O design.

    One binary, four kinds of machine, and one identical answer

    One piece of hygiene, because a loaded machine is easy to measure badly:

    --- measurement hygiene ---
    unattended-upgrades: STOPPED and DISABLED before measurement (was using ~63% of a
      core during the smoke run).
    apt-daily.timer and apt-daily-upgrade.timer: DISABLED
    

    A background package updater eating most of a core moves a timing by more than most optimisations do, so it goes off before anything is measured.

    How much memory does the engine actually need? Multiplying config values by hand gives the wrong answer in an instructive way, which is why tools/budget.py exists:

    # Streamable only if ROUTED. The 2 SHARED experts sit in the same namespace and
    # are NOT streamable, which is where hand arithmetic goes wrong.
    def classify(name: str) -> str:
        if ".block_sparse_moe.experts." in name:
            return "routed_expert"          # streamable: only 16 of 896 per token
        if ".block_sparse_moe.shared_expert" in name:
            return "shared_expert"          # RESIDENT: runs on every token
        if ".self_attn." in name:
            return "attention"              # resident
        if "embed_tokens" in name or "lm_head" in name:
            return "embedding"              # resident
        return "other"                      # norms, router gates, biases: resident
    

    The two shared experts run on every token, so they belong in the resident set even though their tensor names sit next to the routed experts. Getting that wrong makes the floor look smaller than it is, which is the worst direction to be wrong in.


    The codebase

    Six C files compiled into one binary. No BLAS, no PyTorch, no ONNX runtime, no GPU library. The only dependencies are libm and OpenMP.

    include/k3/
      k3.h              # the public header: config, weights, every kernel prototype
      k3_cfg.h          # config reader, header-only, refuses to substitute defaults
    src/
      core/k3_ops.c     # every numeric kernel: RMSNorm, KDA, MLA, MoE, MXFP4 matmul
      io/k3_st.c        # safetensors reader, hand-written JSON scan, O_DIRECT reads
      io/k3_load.c      # locating one expert's bytes inside a shard
      io/k3_trunk.c     # streaming the dense trunk, pinned prefix plus a ring slot
      cache/k3_cache.c  # the routed-expert LRU cache and its batch prefetch
      model/k3_bind.c   # binding checkpoint tensor names to kernel arguments
      tokenizer/k3_tok.h# byte-level BPE loaded from tiktoken.model
      cli/k3_run.c      # the k3 binary: memory plan, decode loop, reporting
    tools/              # python: pack the trunk, replay the cache, verify against torch
    benchmarks/         # the cgroup memory ladder and the split sweep
    tests/              # fixtures, the tiny oracle, the 93-layer conformance run
    
    CFLAGS = -O3 -std=gnu99 -Wall -Wextra -Wpointer-arith -Wshadow -Wvla \
             -march=native -fopenmp -ffp-contract=off
    LDFLAGS = -lm -fopenmp
    

    The flag that looks unusual is -ffp-contract=off. By default a compiler may fuse a multiply and an add into a single FMA, which changes the rounding. That is normally good. Here it is a problem, because the scalar path, the OpenMP path and the AVX2 path must produce bit-identical results, so that a performance change can never quietly become an accuracy change.

    One C file plus small headers becomes a tiny static binary

    1. build, warnings are failures
      -> clean build, no diagnostics
      test_ops          97784 bytes
      k3_model          89392 bytes
      k3_run           179736 bytes
    

    The whole inference engine is 179,736 bytes, a 176 kilobyte binary whose job is to run a 1.56 terabyte model.

    A 176 KB binary that runs a 1.56 TB model

    One check crosses machines. The tokenizer was run on the same input file under Windows and under Linux:

    === cross-platform tokenizer determinism ===
      Linux   gcc 13.3.0  x86_64
      Windows gcc 16.1.0  x86_64
      input   src/k3.h (24,499 bytes) -> 6,862 ids
      result  IDENTICAL id streams
    
      (a naive md5 of stdout DIFFERS by one byte: Windows text-mode stdout writes the
       trailing newline as CRLF. That is the pipe, not the tokenizer.)
    

    Two compilers on two operating systems produce the same 6,862 token ids from the same 24,499 bytes. The md5 sums differ by exactly one byte, and the reason is the line ending the shell added, not anything the tokenizer did.

    Three invariants

    The public header opens with three invariants that must hold. Each one is a place where a plausible-looking implementation produces a model that runs, emits fluent text, and is wrong, with no crash and no NaN to warn you.

    1. A_log is indexed per head, not per channel. The checkpoint ships head_dim floats but only the first num_heads are meaningful; the rest are padding.
    2. MLA uses NoPE, yet the 64 rope dimensions still exist and are still cached. Only the rotation is absent; dropping the slots changes the head width.
    3. The MoE routing bias steers selection only. The combining weights come from the unbiased sigmoid scores.

    Each is ticked off below as its component arrives, and each is gated by a fixture chosen so that getting it wrong changes the output: A_log by a linspace that a per-channel misindex scrambles, NoPE by asserting the softmax scale is over the full head width, and the routing bias by a fixture whose bias reorders the top-k on five of its six rows.

    This list used to have five entries. The other two, that the UT-transform inverse is (I + Akk)^-1 and that Aqk keeps its diagonal while Akk does not, describe the chunked parallel form of the delta rule. This engine does not use it. k3_kda_step runs the naive sequential recurrence one position at a time, and so does the PyTorch reference it is checked against, so neither matrix is ever formed. They were claims about an algorithm rather than about this code, nothing implemented them, and no test could have caught getting them wrong. They now live in docs/ARCHITECTURE.md with the rest of the algorithm description. Restoring a chunked KDA path means restoring them, with the fixtures that gate them.

    1. Reading a 1.56 TB checkpoint from its headers

    The checkpoint is 96 safetensors files. The format is deliberately simple, which is what makes it possible to treat 1.56 terabytes as an index rather than as data.

    safetensors: one length, one header, then raw bytes at known offsets

    Every file starts with an 8-byte little-endian length, then that many bytes of JSON describing every tensor, then the raw tensor bytes back to back. Nothing is compressed and nothing is interleaved.

    Index the shard, read the exact bytes on demand, then drop the pages

    No JSON library is used. The header can be tens of megabytes and only four fields per tensor are wanted, so the reader scans it directly.

    /* Walk the header once, copy nothing we do not need. `p` sits just past the
     * opening quote of the tensor name. */
    static const char *st_scan_entry(const char *p, const char *end, K3Tensor *t)
    {
        const char *q = memchr(p, '"', (size_t)(end - p));
        if (!q || (size_t)(q - p) >= sizeof t->name) return NULL;
        memcpy(t->name, p, (size_t)(q - p));
        t->name[q - p] = '\0';
    
        const char *d = st_find_key(q, end, "dtype");
        if (!d) return NULL;
        t->dtype = st_dtype_code(d);
    
        const char *s = st_find_key(q, end, "shape");
        if (!s) return NULL;
        t->rank = 0;
        t->nelem = 1;
        for (const char *c = s; c < end && *c != ']'; c++) {
            if (*c >= '0' && *c <= '9') {
                long v = strtol(c, (char **)&c, 10);
                if (t->rank >= K3_ST_MAXRANK) return NULL;
                t->shape[t->rank++] = v;
                t->nelem *= (size_t)v;
            }
        }
    
        /* offsets are RELATIVE to the start of the data section */
        const char *o = st_find_key(q, end, "data_offsets");
        if (!o) return NULL;
        t->off  = (size_t)strtoull(o, (char **)&o, 10);
        while (o < end && (*o < '0' || *o > '9')) o++;
        t->nbytes = (size_t)strtoull(o, (char **)&o, 10) - t->off;
        return o;
    }
    

    Every tensor goes into a hash table keyed by a hash of its name. The choice of hash is not arbitrary.

    /* Names are long and share deep prefixes
     * ("language_model.model.layers.N.block_sparse_moe.experts.M...."), so the hash must
     * mix every byte; a prefix-only or length-only hash would pile every expert of a
     * layer into one bucket. */
    static uint64_t fnv1a(const char *s)
    {
        uint64_t h = 1469598103934665603ull;
        while (*s) { h ^= (unsigned char)*s++; h *= 1099511628211ull; }
        return h;
    }
    

    Half a million tensor names that all begin with the same forty characters is a genuinely hostile input for a hash function. FNV-1a mixes on every byte, so the expert index at the end of the name still moves the result.

    Reading a tensor afterwards has one wrinkle: O_DIRECT requires the offset and the length to be multiples of the block size, and a tensor starts wherever the previous one ended.

    int64_t k3_st_read_aligned(const K3St *s, int shard, int64_t off, int64_t nbytes,
                               void *buf, int64_t bufcap, int64_t *payload_off)
    {
        /* widen outward to the enclosing aligned window */
        const int64_t lo  = off & ~(int64_t)(K3_ST_ALIGN - 1);
        const int64_t hi  = (off + nbytes + K3_ST_ALIGN - 1) & ~(int64_t)(K3_ST_ALIGN - 1);
        const int64_t len = hi - lo;
        const int64_t pad = off - lo;
        if (len > bufcap) return 0;
        if (payload_off) *payload_off = pad;
    
        int64_t got = 0;
        while (got < len) {
            ssize_t r = pread(dfd, (char *)buf + got, (size_t)(len - got), (off_t)(lo + got));
            if (r <= 0) break;      /* the last window may run past EOF */
            got += r;
        }
        return got >= pad + nbytes ? nbytes : (got > pad ? got - pad : 0);
    }
    

    Note the break rather than a failure on a short read: the final aligned window of a shard extends past the end of the file, which is expected, so the return value checks that the payload was covered rather than that the whole window was.

    indexed 497220 tensors from 96 shards in 0.27 s
    

    Half a million tensors indexed in about a quarter of a second. This is what makes everything afterwards possible: the engine never reads a shard it does not need, so the 1.56 terabytes on disk is a catalogue, not a working set.

    A parser that agrees with itself proves nothing, so the index is dumped and re-parsed independently in Python, comparing dtype, shape, offsets, and the widened float bit patterns.

    # Bit patterns, not tolerances: widening bf16 to f32 is lossless.
    c_bits = np.asarray(c_values[name], dtype=np.float32).view(np.uint32)
    p_bits = ref.astype(np.float32).view(np.uint32)
    if not np.array_equal(c_bits, p_bits):
        bad = int(np.count_nonzero(c_bits != p_bits))
        fail(f"{name}: {bad} of {c_bits.size} float32 bit patterns differ")
    
    === shard verification ===
    shards: 96
    bytes:  1560936091448
    expected: 1560936091448
    RESULT: EXACT MATCH
    

    96 shards, 1,560,936,091,448 bytes, verified one file at a time

    2. The config reader that refuses to guess

    The model's dimensions come from the checkpoint's own config.json, and this is the first place invariant four can silently bite.

    One-based layer indices, and 92 and 93 are both MLA by design

    Kimi K3 alternates two attention mechanisms. Most layers use one, every fourth uses the other, and the last two are both the second kind so the final layer always does global attention. The config lists those layers explicitly, and the list is one-based.

    --- every value below is READ from the checkpoint, not assumed ---
    config: config.json (nested shape) | hidden=7168 layers=93 vocab=163840
            | 24 MLA + 69 KDA | experts 896 top16 shared2 | latent=3584
    
    --- KDA/MLA layer map (ONE-based, from full_attn_layers) ---
    full_attn_layers (24, all MLA): 4,8,12,16,20,24,28,32,36,40,44,48,52,56,60,64,68,72,76,80,84,88,92,93
      note 92 AND 93 are both MLA - the report (2.1) places an extra Gated MLA layer
      at the end of the backbone so the final layer always does global attention.
    kda_layers (69): every other layer.
    

    Every one of those numbers is read from the file; none is compiled in. They land in one struct, which is the entire model on one screen:

    typedef struct {
        int hidden;            /* 7168  */
        int n_layers;          /* 93    */
        int vocab;             /* 163840 */
        float rms_eps;         /* 1e-5  */
    
        /* Kimi Delta Attention. 69 of the 93 layers. */
        int kda_heads;         /* 96    */
        int kda_head_dim;      /* 128, and d_k == d_v */
        int conv_k;            /* 4, depthwise, causal, SiLU fused */
        float gate_lb;         /* -5.0, the decay lower bound */
    
        /* Gated MLA. 24 of the 93 layers. */
        int n_heads;           /* 96    */
        int q_lora;            /* 1536  */
        int kv_lora;           /* 512   */
        int qk_nope;           /* 128   */
        int qk_rope;           /* 64, PRESENT BUT NEVER ROTATED */
        int v_head;            /* 128   */
        int mla_out_gate;      /* 1     */
    
        /* Stable LatentMoE. 92 of the 93 layers. */
        int n_experts;         /* 896   */
        int topk;              /* 16    */
        int n_shared;          /* 2, full width, added UNWEIGHTED */
        int latent;            /* 3584, the routed-expert width */
        int moe_inter;         /* 3072  */
        float routed_scale;    /* 1.0   */
        int moe_renorm;        /* 1     */
        int latent_norm;       /* 1, RMSNorm on the AGGREGATE, not per expert */
    
        /* the single dense layer, layer 0 */
        int first_dense;       /* 1     */
        int dense_inter;       /* 33792 */
    
        int attn_res_block;    /* 12. Boundaries fire when layer_idx % this == 0. */
        float situ_b1;         /* 4.0   */
        float situ_b2;         /* 25.0  */
    
        int  n_full_attn;      /* 24 */
        int *full_attn;        /* ONE-BASED layer indices */
    } K3Cfg;
    

    That struct is the contract between the checkpoint and every kernel. If it is right, the model is Kimi K3. If any field is wrong, the model is something else that still speaks English.

    Refuse rather than guess, because a guessed field gives you a different model

    Consider what a permissive reader would do. The released config nests its fields one level deeper than a fixture does, so a reader that only knows the flat shape finds nothing it recognises. If it then fills in defaults, two things happen: the SiTU betas get 4.0 and 25.0, which are the correct values, so nothing looks wrong, and full_attn_layers comes back empty, so all 93 layers run as KDA and the 24 global-attention layers vanish. The model loads, streams, decodes, and produces grammatical English from an architecture that is not Kimi K3.

    /* An absent field is an ERROR, never a default. Missing names are accumulated so
     * the message lists all of them at once. */
    static int cfg_req_int(jval root, const char *key, int *out,
                           const char **missing, int *nmissing)
    {
        jval v = json_get(root, key);
        if (v.type != JSON_NUM) {                 /* absent OR the wrong type */
            if (*nmissing < K3_CFG_MAXMISS) missing[(*nmissing)++] = key;
            return 0;
        }
        *out = (int)v.num;
        return 1;
    }
    
      [no_layermap]
        k3_cfg: no_layermap.json is missing 1 required field(s):
            full_attn_layers
          refusing to substitute defaults: a config this reader cannot
          fully understand would silently produce a DIFFERENT model.
          ok    correctly rejected no_layermap.json
    
      [bad_layer_index]
        k3_cfg: bad_layer_index.json full_attn_layers[2] = 999 is outside 1..93
            (the list is ONE-based)
          ok    correctly rejected bad_layer_index.json
    

    A config reader is about a hundred and fifty lines of the most boring code in the project, and it is one of exactly two places that can hand you a different model without telling you.

    3. The tokenizer, byte for byte

    The other one is the tokenizer. Kimi K3 uses a byte-level BPE with 163,584 ranks plus 256 special tokens, shipped as a tiktoken.model file.

    Every case goes through a file, never through argv

    The loader reads that file straight into the vendored BPE structures. It rests on three assumptions, each of which produces a tokenizer working perfectly on ASCII and diverging on everything else:

    • The merge keys are bytes, not code points. A key that happens to decode as valid UTF-8 must still be treated as its raw bytes.
    • Ranks come from the file. They are not derived from frequency at load time.
    • The added-token block is appended after the ranks, so an added token's id is 163,584 plus its index, not its position in a merged table.

    The test compares the C tokenizer against the Python tiktoken library case by case, through files rather than command-line arguments:

    oracle   : tiktoken 0.13.0
    method   : token-for-token comparison; every case passed through a FILE, never argv
               (argv is re-encoded to the active code page on Windows and would compare
                different bytes on every non-ASCII case)
    
      PASS  han only                 2 ids
      PASS  japanese                 6 ids
      PASS  korean                   5 ids
      PASS  cyrillic                 4 ids
      PASS  arabic                   7 ids
      PASS  emoji zwj                5 ids
      PASS  code python             11 ids
      PASS  json                    19 ids
    
    tokenizer parity: 45/45 cases match
    

    Then whole files are pushed through and decoded back:

    roundtrip: 48353 bytes -> 14797 ids -> 48353 bytes : PASS   <- k3_ops.c
    roundtrip: 24499 bytes -> 6862 ids -> 24499 bytes : PASS   <- k3.h
    roundtrip: 201775 bytes -> 52671 ids -> 201775 bytes : PASS   <- REPORT.md
    roundtrip: 53444 bytes -> 12145 ids -> 53444 bytes : PASS   <- modeling_kimi_k3.py
    

    Four files in, byte-identical files back out

    Two hundred kilobytes of markdown becomes 52,671 token ids and comes back as exactly the same two hundred kilobytes. Every later claim about identical output rests on the tokenizer being deterministic.

    /* Greedily merge the lowest-rank adjacent pair. Everything here is BYTES. */
    static int tok_encode_piece(const Tok *t, const unsigned char *p, int n, int *out)
    {
        int parts[K3_TOK_MAXPIECE + 1], np = n + 1;
        for (int i = 0; i <= n; i++) parts[i] = i;          /* byte boundaries */
    
        for (;;) {
            int best = -1, bestrank = INT_MAX;
            for (int i = 0; i + 2 < np; i++) {
                const int r = tok_rank(t, p + parts[i], parts[i + 2] - parts[i]);
                if (r >= 0 && r < bestrank) { bestrank = r; best = i; }
            }
            if (best < 0) break;                            /* no mergeable pair left */
            memmove(&parts[best + 1], &parts[best + 2],
                    (size_t)(np - best - 2) * sizeof(int));
            np--;
        }
    
        for (int i = 0; i + 1 < np; i++)
            out[i] = tok_rank(t, p + parts[i], parts[i + 1] - parts[i]);
        r