Home · ← SuperJ Manual EN|中文

Writing an LLM Inference Engine

A step-by-step tutorial: load a quantized GGUF model, run it on the GPU, and generate text — in about forty lines of SuperJ. The sj.gpu stack this tutorial drives is the same engine that runs TinyLlama faster than llama.cpp on the same Apple M4 Max (prefill 5,753 vs 5,624 tok/s, decode 1.24× faster, output byte-identical). You get that engine as a library; this page shows you how to use it, and then how to go beneath it.

Contents

  1. What you need
  2. The forty-line inferencer
  3. The five classes, explained
  4. Benchmarking it honestly
  5. Going lower: the Tensor layer
  6. Why it's fast
  7. Knobs and kill switches
  8. Correctness: the CPU oracle

1. What you need

curl -L -o tinyllama-q4k.gguf \
  https://huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf

Supported today: the llama architecture family, with F32/F16/Q4_K/Q6_K/Q8_0 tensors (Q4_K_M and Q4_K_S files are the sweet spot).

2. The forty-line inferencer

import sj.gpu.GpuDevice;
import sj.gpu.Model;
import sj.gpu.KvCache;
import sj.gpu.Tokenizer;

public class tinychat {
    public static void main(String[] args) {
        if (args.length < 2) {
            System.out.println("usage: tinychat <model.gguf> <prompt> [maxTokens]");
            System.exit(1);
        }
        int maxTokens = args.length > 2 ? Integer.parseInt(args[2]) : 200;

        // 1. Open a GPU device (Metal on macOS; falls back to the CPU
        //    reference backend anywhere else — same code, same results).
        GpuDevice dev = GpuDevice.open();

        // 2. Load the model. The GGUF file is mmap'd zero-copy; quantized
        //    weights become GPU tensors in a few milliseconds.
        Model m = Model.load(dev, args[0]);
        Tokenizer tk = Tokenizer.fromGguf(m.gguf());
        KvCache kv = KvCache.create(dev, m);

        // 3. Prefill: run the whole prompt through the model in one batched
        //    pass. Returns the first generated token.
        int[] promptTokens = tk.encode(args[1]);
        int token = m.prefill(promptTokens, kv);

        // 4. Decode: one token at a time. decodeStep processes `token` at
        //    position `pos` and returns the next token (greedy argmax).
        for (int pos = promptTokens.length; pos < maxTokens; pos++) {
            token = m.decodeStep(token, pos, kv);
            if (token == tk.eos()) break;
            System.out.print(tk.decode(token));
        }
        System.out.println();
    }
}

Compile and run:

superj compile tinychat.sj --sdk-path "$SJ_HOME/sdk" --link --output tinychat
./tinychat tinyllama-q4k.gguf "The capital of France is" 100

That's the whole engine. Everything below is understanding and control.

3. The five classes, explained

GpuDevice — the backend handle. GpuDevice.open() picks the best backend for the machine (Metal → CUDA → CPU); GpuDevice.open("cpu") forces one. Also the door to raw compute: arenas, buffers, kernels (§5).

Model — a loaded transformer. Model.load(dev, path) parses the GGUF header, maps the file, and wraps every weight as a zero-copy tensor view — no 600 MB read; loading takes milliseconds. Introspection: nLayers(), dim(), nHead(), nKvHead(), vocabSize(), maxSeq(). The two verbs:

Tokenizer — built from the model's own vocab: fromGguf(m.gguf()), then encode(string) -> int[], decode(int) -> string, eos(), bos().

KvCache — the attention key/value cache, sized from the model's maxSeq(). Create one per conversation; create a fresh one to start over. Two caches over one Model = two independent sessions sharing the weights.

Gguf — the raw file reader (m.gguf()), if you need headers or tensors the Model doesn't surface.

Sampling: decodeStep is greedy (argmax) today. For temperature/top-k you would take logits from the Tensor layer yourself (§5) — a good first extension project.

4. Benchmarking it honestly

Two hard-won rules:

  1. Warm up before timing. Apple GPUs boot in a low power state and need ~100 ms of sustained work to reach full clock. A single cold prefill measures the clock ramp, not your code — it reads ~40% slow. Run one throwaway prefill into a scratch KvCache first, then time the real one; report the cold number separately as first-call latency if it matters to your application.
  2. Compare same-session. Thermals move every number. Benchmark your engine and any baseline back-to-back, on the same machine, in the same run — a baseline measured an hour ago on a cool machine is not a comparison.

For per-kernel GPU time there are two built-in profilers (environment variables, no rebuild):

5. Going lower: the Tensor layer

Model is a few hundred lines of plain SuperJ over public primitives — the sj.gpu sources ship with the SDK, and reading Model end-to-end is the fastest way to learn the whole forward pass (browse the API with superj doc --list / superj doc sj.gpu.Tensor). To build a different architecture, or custom sampling, use the same pieces:

GpuArena a = dev.scratch();                       // bump allocator, reset per step
Tensor w  = Tensor.view(dev, buf, DType.Q4_K, rows, cols);  // zero-copy weight
Tensor x  = Tensor.alloc(dev, DType.F32, n, dim);           // activations

Tensor xn  = x.rmsNorm(normWeight);               // fused normalize
Tensor q   = wq.matmul(xn);                       // GEMV (n==1) or tiled GEMM (n>1)
Tensor.ropeDualInPlace(q, k, ropeSpec, pos);      // rotary embedding, q+k in one dispatch
Tensor att = Tensor.attention(q, keys, vals, attnSpec, seqLen);  // flash attention
wo.matmulAccumInto(att, x);                       // x += Wo·att, fused residual
int next   = logits.argmax();                     // GPU argmax, 4-byte readback

Everything dispatches by batch size automatically: one activation row takes the memory-bound GEMV path, many rows take the tiled GEMM path. A gotcha that bites everyone once: arena is a reserved word in SuperJ — don't name a variable that.

6. Why it's fast

Every item below is measured and was verified output-identical when it landed:

7. Knobs and kill switches

Every optimization has an environment kill switch (no rebuild) — the first tool to reach for when bisecting a suspected miscomputation or regression:

VariableEffect
SUPERJ_GPU=cpuforce the CPU reference backend
SUPERJ_GPU_PREFILL_F16=0disable the f16 weight twins
SUPERJ_GPU_ROPE_FUSED=0unfuse rotary embedding / KV-write from the GEMMs
SUPERJ_GPU_EMBED_BATCH=0per-token embedding lookups
SUPERJ_GPU_REPLAY=0disable decode token replay
SUPERJ_GPU_PROFILE2=1full-clock per-kernel GPU profile at exit
SUPERJ_GPU_CBSTATS=1dispatch/barrier counts + GPU occupancy at exit

8. Correctness: the CPU oracle

The CPU backend runs the same Model code with scalar kernels and is the ground truth: run any generation with SUPERJ_GPU=cpu and diff the text. The GPU path is held to token-identical output — and several of its optimizations are bit-identical by construction: they replicate the exact floating-point evaluation order of the path they replace. When you extend the engine, inherit the discipline: give every change a kill switch, A/B it against itself, and diff against the oracle. Fast and wrong is just wrong.

SuperJ — manual · generated from llm-inference.md at pack time EN|中文