The woodpecker drums a hundred times a second on a huge trunk; we drum 128 experts on a huge disk.
A streaming Mixture-of-Experts (MoE) inference engine for the GPT-OSS models (20B and 120B), written in pure C, that runs models larger than your RAM on ordinary consumer hardware.
Most of a MoE model's weight is in its experts, and only a handful of experts are used for each token. Picchio keeps only the small "dense" part of the model permanently in memory and streams the experts from disk on demand, caching the ones it has recently used. This is what lets a 14 GB model (20B) run comfortably on a 16 GB laptop, and makes the 66 GB model (120B) runnable at all without a datacenter GPU.
Inspired by Colibri (GLM), adapted for the GPT-OSS architecture.
The same streaming core serves two MoE families. The engine reads every dimension
from config.json and flips the family-specific behaviors from the model's
model_type, so adding the second family left the GPT-OSS path byte-for-byte
unchanged.
| Family | Models | Converted size | Chat bridge |
|---|---|---|---|
| GPT-OSS | gpt-oss-20b, gpt-oss-120b |
~14 GB / ~66 GB | chat.py (Harmony) |
| Qwen3-MoE | e.g. Qwen3-30B-A3B |
~20 GB | chat_qwen.py (ChatML) |
The Qwen3 support is config-gated: QK-Norm, plain SwiGLU, softmax-normalized
top-k routing, and full attention (no sinks, no sliding window) are switched on
only for Qwen checkpoints. See Running a Qwen3-MoE model
and PORTING_QWEN3.md.
The converter and complete runtime path are covered by both a synthetic Qwen3-MoE
smoke test and a short end-to-end run on a converted Qwen3-30B-A3B checkpoint.
The remaining validation gap is a token/logit comparison with transformers on
the original full-precision model, not basic loading, generation, or ChatML chat.
It can also split inference across two machines on a LAN. Each node loads only its assigned dense layers and KV state, although both currently still need the converted model files on local disk. Only the small residual-stream vector crosses the network, and the output is byte-identical to a single node. See Distributed inference across two machines.
New to this? Read the sections in order. Every command below is complete: nothing is assumed. Windows commands are shown for PowerShell; Linux/macOS equivalents are given where they differ.
- What you need (hardware & software)
- Install the toolchain
- Build the engine
- Download and convert a model
- Run it: the chat bridge (recommended)
- Run it: an OpenAI-compatible API server
- Running the big model (120B)
- Tuning & environment variables
- Troubleshooting
- Verifying correctness (optional)
- How it works & project layout
- Running a Qwen3-MoE model
- Distributed inference across two machines
- License
| Resource | Minimum | Recommended (20B) | Notes |
|---|---|---|---|
| CPU | x86-64 with AVX2 | 6+ cores with AVX2/FMA | Almost every desktop/laptop CPU since ~2013 has AVX2. Without it the build fails or runs very slowly. |
| RAM | 8 GB | 16 GB | The 20B needs ~3 GB always resident + expert cache. More RAM = more cache = less disk reading = faster. |
| Disk | ~30 GB free | SSD/NVMe, ~30 GB free | The model is read from disk constantly, so an internal SSD matters a lot. A slow USB bridge can more than double the I/O time. |
The 120B model additionally needs ~70 GB of free disk and benefits from as much RAM as you can give it (see section 7).
- A C compiler (GCC or Clang). On Windows this means MSYS2/MinGW.
- Python 3.9+ (for converting the model and for the chat/server bridges).
- An internet connection to download the model once from Hugging Face.
a) Install MSYS2 (provides the GCC compiler).
- Download and run the installer from https://www.msys2.org.
- Accept the default install location
C:\msys64. - Open the "MSYS2 MinGW 64-bit" terminal from the Start menu and install GCC:
pacman -S mingw-w64-x86_64-gcc make
build.batexpects the compiler atC:\msys64\mingw64\bin\gcc.exe(the default). If you installed elsewhere, edit theGCC=line inbuild.bat.
b) Install Python from https://www.python.org/downloads/ (tick "Add Python to PATH" during setup).
sudo apt install build-essential python3 python3-pip # Debian/Ubuntuxcode-select --install # gives you clang + make
brew install python # if you don't already have Python 3From the project folder (C:\picchio or wherever you cloned it):
If you would rather not build from source, download the prebuilt Windows binary from the Releases page:
- Download
picchio.exeand place it in the project folder. Release assets use this exact stable name, so every command below works without renaming it. - It is a static build: no MinGW DLLs, runs from anywhere.
- Releases can lag the source tree. Version 0.6.0 includes
.picchioflat, direct I/O,ASYNC_MOE, INT3, Qwen3-MoE, and the two-node pipeline; compile from source only when you need changes newer than the latest release. - Requires Windows x64 with an AVX2/FMA CPU (2013 or newer). The binary is unsigned, so Windows SmartScreen may warn on first run ("More info" then "Run anyway").
- Verify the download against
SHA256SUMS.txtpublished on the release.
Then skip to section 4 to get a model. To compile it yourself instead (any OS), continue below.
.\build.batThis produces a self-contained picchio.exe (statically linked, it does not
need any MinGW DLLs and runs from anywhere).
Or compile by hand from the MSYS2 MinGW terminal:
gcc -O2 -Wall -fopenmp -mavx2 -mfma -Wno-misleading-indentation \
-Wno-unused-function -static -Wl,--stack,8388608 \
-o picchio.exe picchio.c -lm -lpsapimake-fopenmp: enables multi-core. Without it, all matmuls run on one core and everything is several times slower.-mavx2 -mfma: enables the SIMD kernels. Without them the math falls back to slow scalar code. Your CPU must support AVX2.-static(Windows): bakes the OpenMP/pthread runtime into the exe so you don't needlibgomp-1.dll/libwinpthread-1.dllnext to it.
.\picchio.exe --self-test # Windows
./picchio --self-test # Linux/macOSThis runs the full forward pass on a tiny synthetic model, no model download
needed. You should see ── self-test PASSED ──. If you
do, the engine works.
GPT-OSS ships in a format Picchio can't read directly (MXFP4). You convert it once into Picchio's INT4 format. We'll use the 20B model, which is the recommended choice for 16 GB machines.
pip install torch safetensors numpy huggingface_hubpython convert.py --model openai/gpt-oss-20b --output C:\models\gptoss20b_i4 --download--model: the Hugging Face repo id (openai/gpt-oss-20b).--output: a folder you choose where the converted model will be written. Put it on your fastest internal disk. Use any path you like (e.g.C:\models\gptoss20b_i4or~/gptoss20b_i4).--download: fetch the model from Hugging Face automatically. Omit this if you already downloaded the raw model yourself and pointed--modelat a local folder.
This downloads several GB and writes a converted model of about 14 GB to the output folder. It only needs to be done once.
Smaller experts (
--expert-bits 3). By default experts are INT4 (gs64). Adding--expert-bits 3packs them at INT3 gs64 instead — about 22% fewer expert bytes on disk and in RAM (~26% on the experts, ~16% on the whole model), at a small quality cost. The INT3 matmul is AVX2-vectorized (the bit-plane layout is chosen for SIMD), so the smaller experts can actually run faster than INT4 when I/O-bound (measured ~1.6 vs ~0.9 tok/s on a 20B). The runtime detects the format from the convertedconfig.json; nothing else changes on the command line.No re-download: transcode an existing INT4 model. If you already converted to INT4 and don't want to fetch the original again, requantize the experts in place with
transcode_i4_to_i3.py:python transcode_i4_to_i3.py --input C:\models\gptoss20b_i8h --output C:\models\gptoss20b_i3It dequantizes each INT4 expert and repacks it as INT3 (INT8 head, F32 attention, etc. copied unchanged), writing a marked container — no download. Slightly lower quality than converting from the original (INT4→INT3 compounds a little error), but validated to keep answers correct on a real 20B.
Reclaim space after converting. The raw Hugging Face download is left in a sibling folder named
<output>_raw(e.g.C:\models\gptoss20b_i4_raw). Only the--outputfolder is needed to run Picchio, so once the conversion finishes you can delete<output>_rawto free that extra space.
Hugging Face access: the GPT-OSS models are openly licensed and normally download without an account. If you ever get a
401/gated error, runpip install huggingface_hubandhuggingface-cli loginonce with a free token from https://huggingface.co/settings/tokens.
Picchio needs a small binary tokenizer file next to the model:
python export_vocab.py C:\models\gptoss20b_i4\tokenizer.json C:\models\gptoss20b_i4\picchio_vocab.bin(The two arguments are: the tokenizer.json that came with the model, and the
output path for the binary vocab. export_vocab.py has no dependencies.)
Your model folder is now ready to use.
chat.py is the recommended way to talk to the model. It uses OpenAI's
official "Harmony" library to format the conversation exactly the way GPT-OSS
expects, so the output is correct token-for-token.
pip install -r requirements-chat.txt(That installs openai-harmony, the only extra package needed to chat.)
python chat.py "Write a short greeting in English." --model C:\models\gptoss20b_i4 --pin-gb 4 --ctx 1024--model: the folder you converted in step 4. You must pass this (the built-in default points at a 120B path and won't match your setup).--pin-gb: how many GB of RAM to spend on the expert cache. More = faster (fewer disk reads).4is a good start on a 16 GB machine.--ctx: context window in tokens (how much conversation history fits).1024is fine to start.
Omit the prompt to get a chat loop that keeps the model and its cache in memory between turns:
python chat.py --model C:\models\gptoss20b_i4 --pin-gb 4 --ctx 1024 --max-tokens 200 --temperature 0.7Type your message after the blue YOU prompt. Type /exit or /quit to leave.
The interactive chat also accepts /help, /clear, /stats, and /settings.
Both model families use the same terminal interface, with a compact model
summary, live generation status, and per-response performance metrics.
| Option | What it does |
|---|---|
--temperature 0.7 |
Randomness. Use ~0.7 for normal conversation. The default 0 (greedy) is deterministic but can make the model loop in its "thinking" channel without answering. |
--max-tokens 200 |
Maximum length of the reply. |
--no-reasoning |
Skip the internal "analysis" (chain-of-thought) and answer directly. Faster, but can degrade multi-turn chats on large models (see Troubleshooting) — prefer --reasoning low if answers deteriorate after a few turns. |
--show-analysis |
Deprecated: the reasoning is now always streamed live (dimmed, under a thinking ❯ header) next to the answer. |
--top-p, --top-k, --seed |
Standard sampling controls. |
--async-moe --direct |
Experimental decode pipeline: overlap unbuffered expert reads with CPU expert compute. Tune read concurrency with --io-threads (start from 4). |
--reasoning low|medium|high |
How much the model thinks before answering. |
--json |
Print the structured reply as JSON. |
--dry-run |
Show the exact tokens that would be sent, without loading the model (handy for debugging). |
You can run the engine directly without Python. This uses a built-in
approximate tokenizer (not token-exact; prefer chat.py for real use):
$env:MODEL = "C:\models\gptoss20b_i4"
$env:INPUT = "The capital of Italy is"
$env:MAX = "40"
.\picchio.exeOn Linux/macOS:
MODEL=~/gptoss20b_i4 INPUT="The capital of Italy is" MAX=40 ./picchioserver.py exposes the model over HTTP with the same API shape as OpenAI, so any
OpenAI-compatible client or tool can talk to it. It uses only the Python standard
library plus openai-harmony (already installed in step 5a).
python server.py --model C:\models\gptoss20b_i4 --port 8000 --pin-gb 4 --ctx 1024It prints [server in ascolto su http://127.0.0.1:8000 ...] when ready.
POST /v1/chat/completions: streaming (SSE) and non-streaming.GET /v1/modelsGET /health
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="not-needed")
resp = client.chat.completions.create(
model="gptoss20b",
messages=[{"role": "user", "content": "Say hello in one sentence."}],
max_tokens=64,
temperature=0.7,
)
print(resp.choices[0].message.content)curl http://127.0.0.1:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gptoss20b","messages":[{"role":"user","content":"Hello"}],"max_tokens":64}'Per-request options (in the JSON body): temperature, top_p, top_k,
max_tokens, reasoning_effort ("low"/"medium"/"high"), and
no_reasoning: true.
Note: the model is a single process with one KV-cache, so requests are handled one at a time (serialized). This is meant for personal/local use, not for serving many users concurrently.
The 120B converts to about 66 GB and runs on the same machine as the 20B, only much more slowly, because far more must be streamed from disk. It is a "works, with patience" model, not a daily driver: expect well under 1 token/s (see Measured performance below for real numbers on consumer hardware). For everyday use the 20B is the better choice.
- Disk space: you need about 70 GB free on the output drive.
On Windows, "used space" can be inflated by hidden shadow copies (System
Restore) under
System Volume Information: if a drive looks full but your files don't add up, reclaim it with Disk Cleanup, or from an Administrator prompt:vssadmin delete shadows /for=D: /all. - Dependencies (same as section 4, plus the fast downloader):
pip install torch safetensors numpy huggingface_hub hf_transfer
convert_streaming.py downloads and converts one shard at a time, never
keeping more than one raw shard (~4.6 GB) on disk. hf_transfer makes the
download several times faster (multi-connection: ~5 MB/s vs ~0.7 MB/s in testing):
$env:PYTHONUTF8 = "1" # progress symbols print correctly
$env:HF_HUB_ENABLE_HF_TRANSFER = "1" # multi-connection downloads (much faster)
$env:PICCHIO_OUTPUT = "D:\gptoss120b_i4" # where the converted shards go (~66 GB)
$env:PICCHIO_RAW = "D:\gptoss_tmp" # scratch for the single raw shard
python convert_streaming.pyPICCHIO_OUTPUT,PICCHIO_RAW, andPICCHIO_REPOare read from the environment; point them at a disk with room (defaults are set in the script).- Resumable: already-converted shards are skipped, so if the download drops or you stop it, just run the same command again and it continues.
- Do not use an HF mirror here.
HF_ENDPOINT=hf-mirror.comserves the small config files but fails on the large LFS shards. Download from Hugging Face directly (the default).
When it finishes, the output folder holds model-00000.safetensors through
model-00014.safetensors, plus config.json, tokenizer.json, and
picchio_vocab.bin (the vocab is generated for you). The expert biases are
baked into the shards (F32), so no separate sidecar is needed.
Keep the context and cache modest on 16 GB (the dense part alone is ~5 GB):
$env:PYTHONUTF8 = "1"
python chat.py --model D:\gptoss120b_i4 --no-reasoning --ctx 1024 --pin-gb 6 --max-tokens 200 --temperature 0.7On startup Picchio reads the architecture from config.json, opens all 15
shards, and loads only the ~5 GB dense part into RAM; the experts stay on disk
and are streamed on demand:
The first turn is slow (it streams every expert from disk); later turns reuse the
KV prefix and the learned hot-store, so they speed up. You can see this in a real
three-turn session: the reused counter on each stats line climbs from 0/82 to
169/187 to 291/307 as the KV-cache prefix is carried over between turns.
The numbers below are a deliberate stress test: the whole point of Picchio is
to prove a 117B-parameter MoE model can run at all on a consumer laptop with
limited RAM, streaming the experts from an external SSD. This is the hardest
case on purpose, not a representative one. On an internal NVMe drive, or with more
RAM devoted to the expert cache (--pin-gb), the rates are higher.
Test configuration:
| Model | GPT-OSS-120B, INT4 (gs64) experts, F32 attention (~66 GB) |
| Storage | external SSD (shards split across two drives via --model-aux) |
| Launch | --no-reasoning --ctx 4096 --pin-gb 6 --threads 6 --temperature 0 |
| Expert cache | 6 GB pinned (--pin-gb 6), 4 parallel I/O threads |
Three-turn chat, generating 16 tokens per turn:
| Turn | KV reused | Prefill | Time-to-first-token | Decode rate | Overall rate |
|---|---|---|---|---|---|
| 1 (cold) | 0 / 86 | 86 tok | 218 s | 0.22 tok/s | 0.038 tok/s |
| 2 (warm) | 95 / 110 | 15 tok | 37 s | 0.24 tok/s | 0.16 tok/s |
| 3 (warm) | 126 / 147 | 21 tok | 54 s | 0.29 tok/s | 0.15 tok/s |
Two things to read from this:
- Steady-state decode is stable at ~0.25 tok/s and is the real hardware ceiling: every token routes to 4 of 128 experts per layer, streamed from the SSD. This barely changes turn to turn.
- Perceived (overall) speed depends almost entirely on the prefill. The first turn must process the entire prompt from scratch (86 tokens, 218 s before the first token), so its overall rate collapses to ~0.04 tok/s. From the second turn on, Picchio reuses the KV-cache prefix (95/110, 126/147 positions reused), so only the small delta is re-processed and the overall rate jumps about 4x, to ~0.15 tok/s. Short, continuous turns stay close to the decode ceiling; long new prompts pay the prefill cost up front.
In short: on this hardware the 120B is usable for careful, patient exchanges, not interactive chat. If you want responsiveness, run the 20B.
You can spread the shards across two disks and pass the ones on the second disk
with --model-aux (semicolon-separated). For example, if the last shard lives on C::
python chat.py --model D:\gptoss120b_i4 --model-aux "C:\gptoss120b_extra\model-00014.safetensors" --no-reasoning --ctx 1024 --pin-gb 6--model-aux also carries any other loose files a model may need.
Legacy note (bias sidecar). Containers converted with older code quantized the expert biases by mistake and needed a separate F32 sidecar (
python download_expert_biases.pywritesexpert_biases.safetensors, passed via--model-aux). A fresh conversion with the currentconvert.pyincludes the biases in the shards, so you can ignore this.
Picchio is configured through environment variables (the chat.py/server.py
flags map onto these). The most useful:
| Variable | Default | Meaning |
|---|---|---|
MODEL |
(none) | Path to the converted model folder (or pass it as the first argument). |
PIN_GB |
auto | GB of RAM for the expert cache. The single biggest performance knob. By default it's sized automatically from your physical RAM (all RAM minus a ~6 GB reserve). A bigger cache means fewer disk reads. Setting a value overrides the auto-sizing. |
CTX |
512 | KV-cache size in tokens (max prompt+generation length). |
OMP_NUM_THREADS |
all cores | Number of CPU threads for the matmuls. |
MAX |
128 | Max tokens to generate (bare-metal run only). |
TEMPERATURE |
1.0 | Sampling temperature (0 = greedy). |
TOPP / TOPK |
0.95 / 50 | Nucleus / top-k sampling. |
SEED |
fixed | RNG seed for reproducible sampling. |
IO_THREADS |
4 | Threads used for reading experts from disk in parallel. |
ASYNC_MOE |
0 |
1 = experimental completion-driven pipeline: compute ready CPU experts while the remaining routed experts are still being read. The final reduction keeps canonical top-k order. |
FLAT |
auto | Auto-detects <model>/experts.picchioflat; set a path to override or 0 to disable. Build it with FLAT_MODEL=<model> python flat_pack.py. |
FLAT_VERIFY |
0 |
1 = verify the truncated SHA-256 of every flat expert payload while loading (diagnostic; index SHA-256 is always verified). |
MODEL_AUX |
(none) | Extra model files on other disks (semicolon-separated). |
IDOT |
0 |
1 = integer expert kernel (int8 activation × int4 weight). Uses AVX-VNNI (dpbusd) where the CPU supports it, else AVX2; a small approximation, so off by default. |
DROP |
0 |
1 = drop just-read pages from the OS page cache after each read (Linux), keeping peak RAM at "dense + cache" when streaming a model larger than RAM. |
DIRECT |
0 |
1 = unbuffered expert reads (O_DIRECT / FILE_FLAG_NO_BUFFERING), bypassing the OS page cache. A win on fast internal NVMe where the buffered path is page-cache-bound; little effect on a USB bridge. Opt-in, with a buffered fallback per read. |
Performance notes:
- On the tested 6-core machine with the 20B on internal NVMe, observed decode rates span roughly 0.8-1.7 tok/s, depending on INT3/INT4, cache size, and storage path; treat these as local measurements, not a hardware guarantee.
- Keep the model on an internal SSD. From USB the I/O time roughly doubles.
- More RAM devoted to
PIN_GBis almost always the best speedup: going from a small cache to full residency on the 20B cut disk reads by ~53% in testing.
To build the optional aligned expert store after conversion (about the size of the converted expert tensors, so check free disk space first):
$env:FLAT_MODEL = "C:\models\gptoss20b_i4"
python flat_pack.pyPicchio discovers the resulting experts.picchioflat automatically. Pair it
with DIRECT=1 ASYNC_MOE=1 (or --direct --async-moe in the Python frontends)
to exercise the full aligned decode path. On the tested GPT-OSS-20B, a complete
flat store averaged 1.206 tok/s versus 1.104 tok/s through safetensors with the
same asynchronous pipeline (+9.2% over two runs per path). Against one
synchronous safetensors reference it was about 43% faster. These results do
not predict the gain on a different SSD, cache size, or model.
For the design rationale and measurements, see DESIGN.md.
picchio.exe exits immediately / "libgomp-1.dll not found".
You built without -static. Either rebuild with .\build.bat (which uses
-static), or run from the MSYS2 MinGW terminal / add C:\msys64\mingw64\bin to
your PATH.
"Illegal instruction" crash on startup. Your CPU lacks AVX2, or you built for a different CPU. Rebuild on the machine you run on. AVX2 is required.
The model keeps "thinking" and never gives an answer.
You're in greedy mode. Add --temperature 0.7 (chat) or set TEMPERATURE=0.7.
A multi-turn chat degrades after a few turns (especially the 120B).
This is usually --no-reasoning. GPT-OSS is trained to reason before answering;
forcing the final channel confuses the model as the conversation grows (it
flounders into . . . … or leaks its reasoning). The bigger models are more
sensitive than the 20B. Fix: drop --no-reasoning and let it think, e.g.
--reasoning low (the reasoning is hidden by default but now also streamed live,
dimmed, so you can see what it is doing). Note --rep 1.1 does not rescue
this: the degenerate run alternates different punctuation tokens, which a
per-token repetition penalty cannot catch.
Output is gibberish / degenerates in long replies.
Make sure you converted with the current convert.py (it keeps the embedding and
output head at INT8 as required). Models converted with older code must be
reconverted. You can check a container quickly: embed_tokens/lm_head must be
I8 in the shard header, not U8 (the old INT4-packed layout collapses into a
mix of languages and repetitions on long texts).
Out of memory / very slow.
Lower PIN_GB (e.g. --pin-gb 2) and/or lower --ctx. Streaming still works with
a small cache; it just reads from disk more often.
Conversion download is extremely slow (120B).
See the mirror tip in section 7
(HF_ENDPOINT=https://hf-mirror.com).
Garbled accented characters in terminal output (Windows).
Set PYTHONUTF8=1 before running Python scripts.
If you want to confirm the math matches a reference implementation, there's a
lightweight numeric oracle (needs only numpy and safetensors):
pip install safetensors numpy
python make_test_model.py # writes a tiny synthetic model to ./test_model
python test_forward.py test_model # validates the forward pass against the oracleThe built-in picchio --self-test (section 3) is the quickest sanity check and
needs nothing at all.
The idea in one paragraph: the dense weights (attention, router, embedding, output head) stay resident in RAM. For each token the router picks the top-4 of 128 experts per layer; Picchio loads just those experts, computing them while an LRU cache keeps recently-used experts around and a learned hot-store keeps the most frequently used ones pinned. Because only a few experts are touched per token, total disk traffic is a fraction of the model size.
| Property | GPT-OSS 20B | GPT-OSS 120B | Qwen3 30B-A3B |
|---|---|---|---|
| Total parameters | 21 B | 117 B | 30.5 B |
| Active per token | ~3.6 B | ~5.1 B | ~3.3 B |
| Hidden size | 2880 | 2880 | 2048 |
| Layers (all MoE) | 24 | 36 | 48 |
| Experts / layer | 32 | 128 | 128 |
| Active experts / token | 4 (top-4) | 4 (top-4) | 8 (top-8) |
| Attention | GQA, sliding-window + full, attention sinks, YaRN | same | GQA + QK-Norm, full only |
| Activation | clipped SwiGLU | clipped SwiGLU | plain SwiGLU (SiLU) |
| Converted size | ~14 GB | ~66 GB | ~20 GB |
Quantization (both families): experts are INT4 (group-scaled, 64), the embedding
and output head are INT8, attention is F32. The engine reads every dimension from
config.json and flips the family-specific behaviors from the model's
model_type, so the GPT-OSS path is byte-for-byte unchanged.
picchio.c The engine (single translation unit)
flat.h Aligned `.picchioflat` reader and integrity checks
quant.h Quantized matmul kernels (F32 / INT8 / INT4) with AVX2/NEON
st.h safetensors reader (multi-shard, multi-disk)
json.h config.json parser
tok.h Built-in approximate tokenizer (fallback for bare-metal runs)
Makefile / build.bat Build for Linux/macOS and Windows
convert.py Convert a GPT-OSS (MXFP4/BF16) or Qwen3-MoE (BF16) model to INT4
convert_streaming.py Shard-by-shard download+convert for the GPT-OSS 120B
convert_streaming_qwen.py Shard-by-shard download+convert for a Qwen3-MoE model
export_vocab.py Build the binary tokenizer file
download_expert_biases.py Regenerate the 120B expert-bias sidecar
chat.py Token-exact GPT-OSS chat bridge (Harmony)
chat_qwen.py Qwen3-MoE chat bridge (ChatML via transformers)
picchio_logo.py Shared terminal logo/banner for the chat bridges
server.py OpenAI-compatible HTTP API server
requirements-chat.txt Dependency for chat.py / server.py (openai-harmony)
make_test_model.py Generate a tiny synthetic model for validation
test_forward.py Numeric oracle to validate the forward pass
test_qwen_smoke.py End-to-end synthetic Qwen3-MoE smoke test (optional deps)
net_bench.py Measure LAN latency/throughput (sizing the distributed split)
pipe_node.py Prototype of the 2-stage pipeline with byte-identity check
flat_common.py Shared helpers for the .picchioflat store (model-agnostic)
flat_pack.py Repack converted experts into a flat, block-aligned store
flat_bench.py Byte-verify the flat store and microbench expert I/O
flat_bench_qd.py Async high-queue-depth read benchmark (overlapped + IOCP)
DESIGN.md Design notes, rationale, and measurements
DESIGN_STREAMING_IO.md Storage-bypass I/O roadmap (flat store, O_DIRECT, async QD)
PORTING_QWEN3.md How the Qwen3-MoE port works and what it changes
For a much deeper dive into the numerics, the streaming/caching design, the
service protocol, and the measured results, read DESIGN.md. The
ongoing work on storage-bypass I/O (a flat block-aligned expert store, unbuffered
reads, and async high-queue-depth streaming), with the prototype harness and its
measured numbers, is in DESIGN_STREAMING_IO.md.
Picchio runs Qwen3-MoE checkpoints (for example
Qwen/Qwen3-30B-A3B-Instruct-2507)
with the same streaming engine. The 30B-A3B is a good fit for a 16 GB machine: it
converts to about 20 GB and activates only ~3.3 B parameters per token.
pip install torch safetensors numpy huggingface_hub transformerstransformers is used by the chat bridge to render Qwen's ChatML prompts and to
tokenize. The engine itself still only exchanges raw token IDs.
The converter auto-detects Qwen from config.json (no extra flag). Qwen experts
arrive as separate BF16 gate/up/down matrices; Picchio fuses gate and up and
quantizes everything to INT4, exactly the layout the runtime expects.
If the whole raw model fits on disk (about 61 GB for the 30B in BF16):
python convert.py --model Qwen/Qwen3-30B-A3B-Instruct-2507 --output C:\models\qwen3_30b_i4 --downloadIf disk is tight, convert shard by shard so only the finished INT4 model (~20 GB) ever lands on disk, never the full 61 GB of raw weights:
$env:PYTHONUTF8 = "1"
$env:PICCHIO_OUTPUT = "C:\models\qwen3_30b_i4" # where the converted shards go
$env:PICCHIO_RAW = "C:\models\qwen_tmp" # scratch for one raw shard at a time
python convert_streaming_qwen.pyconvert_streaming_qwen.py downloads one shard, converts it, deletes the raw
shard, and moves on. It is resumable, keeps the Hugging Face cache off your system
drive, and adapts the download backend automatically (it uses Hugging Face's fast
Xet path when available and falls back to a plain, reliable download when Xet is
unavailable).
Qwen uses ChatML, not Harmony, so it has its own bridge, chat_qwen.py:
python chat_qwen.py --model C:\models\qwen3_30b_i4 --no-reasoning --ctx 2048 --pin-gb 8 --temperature 0.7The options mirror chat.py: --no-reasoning disables Qwen's thinking
(enable_thinking=False), --temperature / --top-p / --top-k control
sampling, and --direct --async-moe --io-threads 4 enables the experimental
aligned/overlapped expert path. Omit the prompt for an interactive multi-turn
session with KV-prefix reuse between turns.
Detection is by model_type in config.json. For Qwen the engine turns on
QK-Norm (RMSNorm on Q and K per head before RoPE), plain SwiGLU instead of the
clipped GPT-OSS variant, softmax-normalized top-k routing (norm_topk_prob),
full attention on every layer (no sliding window), no attention sinks, and the
ChatML end-of-turn token as the stop id. Everything is config-gated, so the
GPT-OSS path is unchanged. For the full list and the validation status, see
PORTING_QWEN3.md.
Current validation status: a small all-MoE Qwen3 fixture created with the official
transformers architecture converts, loads, and generates successfully. Its
safetensors path and .picchioflat + DIRECT + ASYNC_MOE path produced the same
greedy token sequence. A converted real 30B-A3B checkpoint also loaded all 25,013
tensors and produced identical greedy IDs through synchronous and asynchronous
safetensors paths (2773 12 16 15 for the short regression input). On the test
machine the asynchronous path took 10.77 s versus 12.75 s, about +18.4% tok/s.
Finally, chat_qwen.py rendered a real 10-token ChatML prompt and returned the
coherent, deliberately truncated reply Ciao! Come…. A full-model numeric oracle
comparison against transformers and a long multi-turn session remain pending.
Picchio can split inference across two machines on the same network. The layers are cut at a boundary: the coordinator (machine A) loads the first layers plus the embedding, while the worker (machine B) loads the rest plus the output head. For each token only the small residual-stream vector (a few KB) crosses the network; each machine keeps its own layers' KV cache locally. The result is byte-identical to running the whole model on one node.
When to use it. This experimental mode divides resident dense/KV memory and layer compute between the machines. It does not currently pool disk capacity: both machines need the converted model files. Picchio already streams experts when weights exceed RAM, and a single machine is usually faster when it has enough resident memory because the split adds a network round-trip. Wired Ethernet is strongly preferred over WiFi.
- Sampling lives on the coordinator, the single authority for temperature, seed and repetition penalty, so the distributed output matches a single node exactly.
- Each node loads only its own layers (
PIPE_CUTsets the boundary), so a 20B whose dense part is ~3.7 GB on one machine becomes ~1.9 GB on each of two. - The prompt is encoded in batched blocks (one network round-trip per block), then tokens are generated one at a time.
Both machines need picchio.exe and the same converted model folder on disk
(each loads only its half into RAM, but both read from the model files).
1. On the WORKER machine (B). Open TCP port 52200 once (Administrator prompt):
New-NetFirewallRule -DisplayName "picchio" -Direction Inbound -Protocol TCP -LocalPort 52200 -Action AllowFind its LAN IP with ipconfig (the "IPv4 Address", e.g. 192.168.1.14), then start
the worker (it stays listening):
$env:PIPE_ROLE="worker"; $env:PIPE_CUT="16"; $env:PIN_GB="2"; $env:CTX="1024"
.\picchio.exe C:\models\gptoss20b_i8hWait for pipe worker (stage B): listening on port 52200.
2. On the COORDINATOR machine (A). Point it at the worker's IP and chat:
$env:PIPE_ROLE="coord"; $env:PIPE_PEER="192.168.1.14:52200"; $env:PIPE_CUT="16"
python chat.py --model C:\models\gptoss20b_i8h --no-reasoning --pin-gb 3 --ctx 1024 --temperature 0.7chat.py inherits the PIPE_* variables from the environment, so it drives the
two nodes transparently: you type, the two machines answer together.
PIPE_CUTmust be the same on both machines. Give the stronger/larger-RAM machine more layers (a higher cut) to balance the pipeline.- To go back to single-machine mode, clear the variables (
Remove-Item Env:PIPE_ROLE, Env:PIPE_PEER, Env:PIPE_CUT) or open a fresh terminal.
| Variable | Meaning |
|---|---|
PIPE_ROLE |
worker (stage B) or coord (stage A). Unset = normal single-node. |
PIPE_CUT |
Layer boundary. Coordinator holds [0, cut), worker holds [cut, n_layers). Must match on both nodes. |
PIPE_PEER |
Coordinator only: the worker's host:port (e.g. 192.168.1.14:52200). |
PIPE_PORT |
Worker only: TCP port to listen on (default 52200). |
./picchio --pipe-self-test runs both stages over a loopback socket on a tiny
synthetic model and verifies the distributed tokens equal a single node's. On a
real model, PIPE_SPLIT_CHECK=<cut> ./picchio <model> checks in one process that
the split forward is byte-identical to the monolithic one.
The design notes and the measurement harnesses (net_bench.py for LAN latency,
pipe_node.py for the pipeline prototype) are described in
DESIGN.md.
MIT. See LICENSE.

