首页 · ← SuperJ 手册 中文|EN

编写 LLM 推理引擎

一份分步教程:加载一个量化 GGUF 模型,在 GPU 上运行它,并生成文本 — 大约只用四十行 SuperJ。本教程驱动的 sj.gpu 栈就是同一套引擎,它在同一台 Apple M4 Max 上运行 TinyLlama 比 llama.cpp 更快(prefill 5,753 对 5,624 tok/s,decode 快 1.24 倍,输出字节相同)。你以库的形式得到那个引擎;本页展示如何使用它,以及如何下探到它之下。

目录

  1. 你需要什么
  2. 四十行推理器
  3. 五个类,逐一讲解
  4. 诚实地基准测试
  5. 下探:Tensor 层
  6. 为什么它快
  7. 旋钮与 kill switch
  8. 正确性:CPU 预言机

1. 你需要什么

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

目前支持:llama 架构家族,带 F32/F16/Q4_K/Q6_K/Q8_0 张量(Q4_K_M 与 Q4_K_S 文件是甜点)。

2. 四十行推理器

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. 打开一个 GPU 设备(macOS 上用 Metal;在任何别处回退到 CPU
        //    参考后端 — 相同代码,相同结果)。
        GpuDevice dev = GpuDevice.open();

        // 2. 加载模型。GGUF 文件以 mmap 零拷贝映射;量化
        //    权重在几毫秒内成为 GPU 张量。
        Model m = Model.load(dev, args[0]);
        Tokenizer tk = Tokenizer.fromGguf(m.gguf());
        KvCache kv = KvCache.create(dev, m);

        // 3. Prefill:在一次批处理中将整个 prompt 过一遍模型。
        //    返回第一个生成的 token。
        int[] promptTokens = tk.encode(args[1]);
        int token = m.prefill(promptTokens, kv);

        // 4. Decode:一次一个 token。decodeStep 在位置 `pos` 处理 `token`
        //    并返回下一个 token(贪心 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();
    }
}

编译并运行:

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

这就是整个引擎。下面的一切都是理解与控制。

3. 五个类,逐一讲解

GpuDevice — 后端句柄。GpuDevice.open() 为这台机器挑选最佳后端(Metal → CUDA → CPU);GpuDevice.open("cpu") 强制指定一个。它也是通往原始计算的入口:arena、缓冲区、kernel(§5)。

Model — 一个已加载的 transformer。Model.load(dev, path) 解析 GGUF 头、映射文件,并把每个权重包装为零拷贝张量视图 — 没有 600 MB 的读取;加载只需毫秒。内省:nLayers()dim()nHead()nKvHead()vocabSize()maxSeq()。两个动词:

Tokenizer — 由模型自带词表构建:fromGguf(m.gguf()),然后 encode(string) -> int[]decode(int) -> stringeos()bos()

KvCache — 注意力的 key/value cache,按模型的 maxSeq() 定尺寸。每个对话创建一个;要重新开始就创建一个新的。两个 cache 共用一个 Model = 两个共享权重的独立会话。

Gguf — 原始文件读取器(m.gguf()),如果你需要 Model 未暴露的头或张量。

采样:decodeStep 目前是贪心(argmax)。要做 temperature/top-k,你需要自己从 Tensor 层取 logits(§5)— 一个不错的第一个扩展项目。

4. 诚实地基准测试

两条来之不易的规则:

  1. 计时前先预热。 Apple GPU 以低功耗状态启动,需要约 100 ms 的持续工作才能达到满频。一次冷 prefill 测的是时钟爬坡,而不是你的代码 — 读数会慢约 40%。先向一个临时 KvCache 做一次丢弃式 prefill,再计时真正的那个;如果首次调用延迟对你的应用重要,单独报告冷启动数字。
  2. 同会话比较。 温度会改变每一个数字。把你的引擎和任何基线背靠背地、在同一台机器、在同一次运行中基准测试 — 一小时前在凉机器上测的基线不是比较。

要测逐 kernel 的 GPU 时间,有两个内置 profiler(环境变量,无需重新构建):

5. 下探:Tensor 层

Model 是公共原语之上的几百行普通 SuperJ — sj.gpu 源码随 SDK 发布,通读 Model 是学习整个前向过程的最快方式(用 superj doc --list / superj doc sj.gpu.Tensor 浏览 API)。要构建不同的架构或自定义采样,使用相同的构件:

GpuArena a = dev.scratch();                       // bump 分配器,每步重置
Tensor w  = Tensor.view(dev, buf, DType.Q4_K, rows, cols);  // 零拷贝权重
Tensor x  = Tensor.alloc(dev, DType.F32, n, dim);           // 激活

Tensor xn  = x.rmsNorm(normWeight);               // 融合归一化
Tensor q   = wq.matmul(xn);                       // GEMV(n==1)或分块 GEMM(n>1)
Tensor.ropeDualInPlace(q, k, ropeSpec, pos);      // 旋转位置编码,q+k 一次 dispatch
Tensor att = Tensor.attention(q, keys, vals, attnSpec, seqLen);  // flash attention
wo.matmulAccumInto(att, x);                       // x += Wo·att,融合残差
int next   = logits.argmax();                     // GPU argmax,4 字节回读

一切都按 batch size 自动 dispatch:一行激活走内存受限的 GEMV 路径,多行走分块 GEMM 路径。一个会咬每个人的坑:arena 在 SuperJ 中是保留字 — 不要用它命名变量。

6. 为什么它快

下面每一项都经过测量,并在落地时验证为输出相同:

7. 旋钮与 kill switch

每个优化都有一个环境 kill switch(无需重新构建)— 二分一个疑似误算或回归时第一个该拿的工具:

变量效果
SUPERJ_GPU=cpu强制 CPU 参考后端
SUPERJ_GPU_PREFILL_F16=0禁用 f16 权重孪生
SUPERJ_GPU_ROPE_FUSED=0取消旋转位置编码 / KV 写入与 GEMM 的融合
SUPERJ_GPU_EMBED_BATCH=0逐 token 嵌入查找
SUPERJ_GPU_REPLAY=0禁用 decode token replay
SUPERJ_GPU_PROFILE2=1退出时满频逐 kernel GPU profile
SUPERJ_GPU_CBSTATS=1退出时 dispatch/barrier 计数 + GPU 占用率

8. 正确性:CPU 预言机

CPU 后端运行相同的 Model 代码,带标量 kernel,是真相基准:用 SUPERJ_GPU=cpu 运行任何生成并 diff 文本。GPU 路径被要求达到逐 token 相同的输出 — 其中有几项优化是构造上逐位相同:它们复制了所替换路径的精确浮点求值顺序。当你扩展引擎时,继承这条纪律:给每个改动一个 kill switch,对自身做 A/B,并与预言机 diff。快但,只是错。

SuperJ — manual · generated from llm-inference.md at pack time · Powered by superJ — this site is served by superj_web 中文|EN