simple-llm / kasahare
kasahare (which means "speak fast" in Twi) is the PyPI distribution name for simple-llm.
A small, self-contained C++17 inference engine for LLaMA-style transformer
language models. No external dependencies beyond the C++ standard library
and a recent g++ / clang++. Designed to be read end-to-end in an
afternoon, then hacked on.
What's in the box
| file | purpose |
|---|---|
include/simplellm/simd.h |
AVX2 + FMA kernels (matmul, RMSNorm, SwiGLU, vec ops) |
include/simplellm/tensor.h |
Lightweight matrix view + matmul / linear kernels |
include/simplellm/config.h |
Static model description |
include/simplellm/tokenizer.h |
Byte-level BPE tokenizer (no HF tokenizers dep) |
include/simplellm/sampler.h |
Greedy / temperature / top-k / top-p sampler |
include/simplellm/model_data.h |
On-disk model format reader / writer |
include/simplellm/model.h |
Public surface: Model::load, forward, generate |
src/model.cpp |
LLaMA block: RMSNorm, GQA attention, RoPE, SwiGLU |
src/main.cpp |
sl-llm CLI |
tools/create_tiny_model.cpp |
Synthesises a tiny model on disk for smoke testing |
tools/bench.cpp |
Micro-benchmark: make bench |
tests/test_basic.cpp |
End-to-end smoke test: make test |
Architecture
For each block (LLaMA-2 style):
x_n = x_{n-1} # residual
x_n = attn_norm(x_n)
q, k, v = Wq x_n, Wk x_n, Wv x_n # no biases
q, k = RoPE(q, k, pos)
attn = softmax(q . K^T / sqrt(d_head)) . V
x_n = Wo(attn) + x_n # residual
x_n = ffn_norm(x_n)
x_n = W2( silu(W1 x_n) * (W3 x_n) ) + x_n # SwiGLU, residual
Optimisations:
matmulandlinearare tiled over the inner dim with 8-wide AVX2- FMA. Compiles to a single fused
vfmadd231pschain.
- FMA. Compiles to a single fused
rmsnorm,vec_add,vec_mul,silu_mulare SIMD-ised.- A pre-allocated KV cache (
KvSlotper head per layer) letsforward()be O(1) allocation cost. - Tied input / output embeddings (LLaMA style) — one copy of the embedding matrix is read once per token.
- Decoder-time attention materialises only the per-step logits; we re-score every cached key on each new token. For long contexts a Flash-Attention style kernel would beat this by ~2x, but the current implementation is dramatically simpler.
The block uses GQA (grouped query attention) so it accepts a config
where n_kv_heads < n_heads (or equal, for MHA, or 1, for MQA).
Build
make # builds build/bin/{sl-llm, create_tiny_model, bench}
make test # builds and runs the smoke test
make bench # runs the benchmark
make clean
Requirements: g++ ≥ 9 (we use g++ 12.2), AVX2 + FMA support. Override
the SIMD flags with make CFLAGS="-O3 -march=native" to autodetect, or
make CFLAGS="-O3 -msse4.2" to disable AVX2.
Quick start
# 1) Generate a tiny model + tokenizer (~110 KB total)
./build/bin/create_tiny_model --out build
# 2) Run it
./build/bin/sl-llm \
--model build/tiny.sllm \
--tokenizer build/tiny.tok.txt \
--prompt "hi" \
--max-new 16 \
--temperature 0.8
# 3) Show timing
./build/bin/sl-llm \
--model build/tiny.sllm \
--tokenizer build/tiny.tok.txt \
--prompt "" \
--max-new 64 --temperature 0 --timing
(The model produced by create_tiny_model has random weights; the output
is deterministic per --seed but not meaningful text. Real text needs
real weights — see "Plugging in a real model" below.)
CLI flags
--model <file.sllm> model file
--tokenizer <file.txt> tokenizer file
--prompt <text> input text
--max-new <n> number of tokens to generate (default 32)
--temperature <f> sampling temperature, 0 = greedy (default 0.8)
--top-k <n> top-k filtering (default 40)
--top-p <f> top-p filtering (default 0.95)
--seed <n> PRNG seed (0 = random)
--show-config print model config and exit
--no-print-prompt don't echo the prompt before the generated tail
--timing print per-step timing to stderr
--stream flush stdout after each generated token
--help this message
On-disk model format (.sllm)
+---------------------------------------------------+
| magic (u32 LE, 0x534C4C4D == "SLLM") |
| version (u32 LE, currently 1) |
| blen (u32 LE) |
| config blob (blen bytes, key=value text) |
| weights blob (u32 × N floats, little-endian) |
+---------------------------------------------------+
The config blob is a human-readable key=value text. Weights are stored in a single contiguous float32 array in this order:
token_emb (vocab_size * dim)
for each block:
attn_norm (dim)
wq (dim * dim)
wk (kv_dim * dim)
wv (kv_dim * dim)
wo (dim * dim)
ffn_norm (dim)
w1 (hidden * dim) # SwiGLU gate
w3 (hidden * dim) # SwiGLU up
w2 (dim * hidden) # SwiGLU down
output_norm (dim)
tied_embeddings reuses token_emb for the output projection (LLaMA
style). The file is mmap-friendly, so you can load a model lazily in
a follow-up patch.
Tokenizer format
A line-oriented text file:
# simple-llm tokenizer
vocab_size <N>
bos <id>
eos <id>
token <id> "<csv-escaped-payload>"
merge <rank> "<csv-escaped-left>" "<csv-escaped-right>"
Payloads are byte strings. The engine now leverages a robust CSV-escape parser to reliably handle ", \, control chars, embedded spaces between tokens, and arbitrary bytes via \xHH.
The BPE merge rank is explicitly declared via the integer <rank> and matched according to the BPE token priorities. A tokenizer with zero merges falls back to a longest-match scan over the vocab, which is enough for byte-level pre-tokenization.
Performance
The numbers below are from make bench on a single CPU core (g++ 12.2,
AVX2 + FMA, FP32). They are not competitive with llama.cpp — we
don't have quantisation, batching, GPU, threading, or FlashAttention —
but they show the hot path is reasonable.
| config | tokens/s |
|---|---|
| dim=256, layers=4, heads=4, hidden=512, seq=128 | ~380 |
| dim=384, layers=6, heads=6, hidden=1024, seq=256 | ~60 |
| dim=512, layers=8, heads=8, hidden=1536, seq=256 | ~24 |
The decode loop is O(seq_len) per token, so a 2x longer context halves
the tok/s. Most of the time is spent in the linear() calls for Q/K/V
projection and FFN; an OpenMP / threadpool pass over those would be the
biggest single win.
Python binding
The engine is also a pip install-able Python package available on PyPI as kasahare:
pip install kasahare
Build it as editable for development, or build a wheel for distribution:
# Editable install (rebuilds on C++ changes — the binding .so lives in
# python/simplellm/_core*.so and is patched in by setuptools).
pip install -e .
# Wheel (good for shipping to a cluster with no compiler).
pip install build
make py-wheel # produces dist/simplellm-0.1.0-*.whl
pip install dist/simplellm-0.1.0-*.whl
Run the Python tests:
make py-test
Then in Python:
import numpy as np
import simplellm
m = simplellm.Model.load("tiny.sllm", "tiny.tok.txt")
ids = m.tokenizer.encode("hello world", add_bos=True)
log = m.forward(ids[0], reset_kv=True) # -> np.ndarray (vocab_size,)
out = m.generate(ids, max_new=32,
options=simplellm.SampleOptions(temperature=0.7, top_k=40))
print(m.tokenizer.decode(out))
# Streaming:
def on_token(tid): print(m.tokenizer.decode_token(tid), end="", flush=True)
m.reset_kv()
m.generate(ids, max_new=16, options=simplellm.SampleOptions(temperature=0.8), stream=on_token)
# In-memory models (no temp files, no paths):
m2 = simplellm.Model.from_bytes(open("tiny.sllm","rb").read(),
open("tiny.tok.txt","rb").read())
# Tracing — get per-layer and per-token statistics (zero overhead normally)
trace = m.forward_traced(ids[0], reset_kv=True)
print("Max logit:", trace["logit_max"], "Entropy:", trace["logit_entropy"])
print("RMS values, layer 0:", trace["layers"][0]["attn_norm_rms"])
The binding is zero-copy on the C++ → Python transition for the
logits array: forward() returns a fresh np.ndarray of shape
(vocab_size,) that you can pass straight to np.argmax,
torch.softmax, etc.
projection and FFN; an OpenMP / threadpool pass over those would be the
biggest single win.
Plugging in a real model
tools/create_tiny_model.cpp shows the on-disk layout. To use a
trained model, the steps are:
- Train or download a LLaMA-2 / Mistral / Qwen2 model.
- Convert its HF / safetensors / GGUF weights to a single float32 buffer in the order listed above.
- Write the config blob (a few
key=valuelines) with the matching dimensions. - Run
sl-llm --model ... --tokenizer ....
We do not ship a converter for a specific upstream format; the layout
is small enough that numpy.tofile() + a few header bytes does the
trick. See tools/create_tiny_model.cpp for a working example of
writing a model from scratch.
Limitations (intentional)
- FP32 only. No FP16, BF16, INT8 or INT4. Adding INT4 would 8x memory and ~3-4x decode throughput.
- Single-threaded. No SIMD beyond AVX2/FMA. No GPU.
- Decoder-only. No training.
- No Flash-Attention — attention is O(seq_len) per token.
- No KV cache compression (quantised cache, sliding window).
- No beam search. Sampling only (greedy / top-k / top-p / temperature).
- The native BPE tokenizer explicitly supports raw byte sequence parsing. Complex BPE merges containing quotes, spaces, and control characters are reconstructed flawlessly via the CSV-escaped token file format, without needing any HuggingFace Python dependencies.
- The BPE builder inside the Python library accepts an O(1)-bounded Word-Frequency
algo="fast"argument to collapse token training iteration times from hours to seconds. - The on-disk format is not portable across endiannesses; this is fine for x86 / ARM servers but not for big-endian targets.
Where to go from here
- Add a
mmap-based weight loader so very large models don't need to live in RAM twice. - Add INT8 / INT4 weight loading (block-quantised, like GGUF Q4_K).
- Add OpenMP parallelism to
matmul(per-row acrossM). - Replace the attention loop with a single fused kernel.
- Add a
StreamAPI that yields tokens through a callback (handy for chat UIs). - Add a HuggingFace
tokenizer.jsonreader so trained tokenizers work out of the box.
License
Do whatever you want with it. No warranty — see "Limitations" above.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
File details
Details for the file kasahare-0.4.0.tar.gz.
File metadata
- Download URL: kasahare-0.4.0.tar.gz
- Upload date:
- Size: 40.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ac15c00eb2657afd7578c04f118f738c5c860cf73a948b881832a724a093267
|
|
| MD5 |
d0edeb2058bdac90a29787c5237ae64f
|
|
| BLAKE2b-256 |
c017a403e5b849167935ad24f3c1f5c758eaba94b67fabe750712b1bbb1d5d96
|