Plugins / Lab
elelem-ggml
A transformer you can read.
A Qwen2 forward pass built by hand on ggml, with the tokenizer in Ruby. 466 lines of C and about 300 lines of Ruby from prompt to tensor ops.
git clone https://git.mokhan.ca/elelem/ggml.gitLab. An experiment in how inference works. Built to learn from and read, not published to RubyGems.
Experimental: CPU only, greedy decoding only, no tool calls, and only Qwen2-family models decode correctly. Not published to RubyGems.
elelem-ggml runs a language model on ggml,
the tensor library under llama.cpp, without llama.cpp. The transformer
graph is written by hand in one C file. The tokenizer and chat template
are plain Ruby. Use it to learn what happens between your prompt and the
next token. For real work, use elelem-llama.
Install #
The gem is not on RubyGems. Build it from source:
git clone https://git.mokhan.ca/elelem/ggml.git
cd ggml
git submodule update --init --recursive
bundle install
bundle exec rake compileggml is vendored as a git submodule at vendor/ggml. rake compile runs
ext/elelem/ggml/extconf.rb, which builds ggml with CMake, then compiles
ext/elelem/ggml/elelem.c into ext/elelem/ggml/native/libelelem_ggml.so
next to ggml’s shared libraries.
Configure #
Point it at a GGUF file:
export ELELEM_GGUF_MODEL=/path/to/qwen2-model.ggufOr put the file in ~/.agents/models/. If that directory holds more than
one .gguf, symlink the one you want to ~/.agents/models/default.gguf.
These are the same variable and directory elelem-llama uses.
The context size is the model’s context_length, capped at 4096. The
plugin always uses 4 CPU threads. Neither is configurable from the
environment.
Use #
From the repository:
ruby -Ilib exe/elelem-ggml ask "hi"exe/elelem-ggml is the elelem CLI with ggml as the default provider,
so chat (the default command) and ask both work. To use it from the
main elelem command, put lib on Ruby’s load path:
RUBYLIB=/path/to/ggml/lib elelem -p ggmlThe reply appears all at once when generation finishes. There is no token streaming.
How it works #
A reply takes 5 steps. Each one is a short file you can read.
1. Read the GGUF metadata in Ruby. lib/elelem/ggml/gguf.rb parses the
GGUF header and key-value section with IO#read and unpack. It skips the
tensor data. The tokenizer needs 4 keys from it: tokenizer.ggml.tokens,
tokenizer.ggml.merges, tokenizer.ggml.token_type, and
tokenizer.ggml.eos_token_id.
2. Format the prompt as ChatML. lib/elelem/ggml/chat_ml.rb wraps each
message in <|im_start|>role and <|im_end|>, then ends with
<|im_start|>assistant so the model writes the next turn. If there is no
system message, it adds Qwen’s default one. elelem always sends its own
system prompt, so that default only matters when you call the provider
directly.
3. Tokenize with byte-level BPE. ggml has no tokenizer, so
lib/elelem/ggml/tokenizer.rb implements one. Strings like <|im_end|>
that match a control token (token_type 3) map straight to their ID.
Everything else is split with Qwen2’s pre-tokenizer regex, mapped
byte-by-byte to printable characters (the GPT-2 byte encoder), and merged
pair by pair, always taking the lowest-ranked merge, until no merge applies.
The specs pin the exact token IDs against a real Qwen2 vocabulary.
4. Run the transformer in C. el_open in ext/elelem/ggml/elelem.c
loads the GGUF with ggml’s gguf.h, reads the hyperparameters under the
architecture’s prefix (qwen2.block_count, qwen2.embedding_length, and
so on), and allocates the KV cache. Each forward pass builds a fresh ggml
graph. For every layer:
- RMSNorm. Scale the hidden state by its root mean square, then by a learned weight. No mean subtraction, no bias.
- Attention projections. Multiply by the Q, K, and V weights. Add the bias if the file has one.
- RoPE, NEOX style. Rotate Q and K by an angle that depends on token
position, so attention scores encode relative distance. NEOX pairs
dimension
iwithi + d/2, not with its neighbour. - Grouped-query attention (GQA). There are fewer K/V heads than query heads. The test model has 14 query heads and 2 K/V heads. The cache only stores the 2, and ggml’s matrix multiply broadcasts each one across 7 query heads.
- KV cache. The new K and V are copied into the cache at the current
position. Attention then reads every cached position, with a causal mask
of
-INFINITYabove the diagonal and softmax scaled by1/sqrt(head_dim). - Output projection and residual. Project back to the model width and add the layer input.
- SwiGLU feed-forward. RMSNorm, then
down(silu(gate(x)) * up(x)), then another residual add.
After the last layer comes a final RMSNorm and the output projection. If
the file has no output.weight, it reuses the token embedding (tied
weights).
5. Pick tokens and decode. Generation is greedy: C scans the logits for
the largest value. The first pass (prefill) runs the whole prompt at once
but projects only the last position to logits. Each later pass feeds one
token and reuses the cached K and V. It stops at the EOS token or
ELELEM_GGUF_MAX_TOKENS. Ruby turns the IDs back into bytes with the
inverse byte map and returns UTF-8 text.
Ruby talks to C through Fiddle with 4 functions: el_open, el_version,
el_generate, and el_close. Token IDs cross the boundary as packed
int32 arrays.
The KV cache is F32, head_dim × n_head_kv × n_ctx per layer, for K and V.
It lives as long as the provider, but every reply starts again at position
0 and re-reads the whole conversation.
Limitations #
- Only Qwen2-family models decode correctly. The loader accepts other dense architectures (attention bias is optional, the output layer can be tied) and Llama-family files load without crashing, but RoPE ordering depends on the architecture. Qwen2 needs NEOX, Llama needs NORMAL, and no GGUF key says which. The code hardcodes NEOX, so Llama-family output is wrong. A spec with a 2-layer Llama-architecture model keeps the wrong output as a regression canary.
- Greedy decoding only. No temperature, top-p, or seed.
- No tool calls. The tool list elelem passes in is ignored, so the agent cannot read or edit files with this provider.
- CPU only.
n_gpu_layersis accepted and ignored. - No streaming. The reply arrives in one piece.
- If the prompt plus
ELELEM_GGUF_MAX_TOKENSis more than the context size, the call fails withggml: generation failed. elelem’s system prompt and anyAGENTS.mdcount toward the prompt. - Every turn re-processes the whole conversation. Long sessions get slower.
Development #
bin/setup
bin/compile
bin/test
bin/lintbin/setup runs bundle install only; run
git submodule update --init --recursive first. The tokenizer and ChatML
specs use a vocabulary-only Qwen2 GGUF in spec/fixtures/. Specs that need
real weights are skipped unless you set ELELEM_GGML_TEST_MODEL:
ELELEM_GGML_TEST_MODEL=/path/to/qwen2-model.gguf bin/testThose specs expect a model with 24 layers, n_embd 896, 14 heads, 2 K/V
heads, n_ff 4864, and a 151,936-token vocabulary, and a greedy reply to
“hi” that starts with “Hello”.
Environment #
| Variable | Default | Description |
|---|---|---|
ELELEM_GGUF_MODEL | none | Path to a .gguf file. If unset, uses ~/.agents/models/default.gguf, or the only .gguf in ~/.agents/models/. |
ELELEM_GGUF_MAX_TOKENS | 256 | Maximum number of tokens to generate per reply. |