跳到正文
LLM 推理
S22收官之作·4018

用 TypeScript 写 mini-picoLM

把引擎第三次重建一遍:一个 ArrayBuffer、一个 arena、十八个步骤,做出一个不需要服务端、不需要 WASM、零依赖就能在浏览器里生成文本的引擎。JavaScript 里难的是内存,不是数学。

  • ArrayBuffer 与 arena
  • 类型化数组视图
  • 融合量化 matmul
  • 异步生成
  • WebGPU
  • 交付到标签页

为什么重要

问题

S21 证明了这个引擎可以用 Python 重建。Python 是一个「容易做对」的地方:NumPy 把内存藏了起来,正确性也容易验证。但它交付不了,因为没人会为了渲染一个网页去跑一个 Python 进程。

那就用 TypeScript 再建第三遍,并加上一个改变一切的约束:它必须跑在浏览器标签页里。 没有服务端,没有 WASM 工具链,零依赖。一个 ArrayBuffer,十八个步骤。

本章会把每一行代码都印出来——整个引擎,逐个文件,按你实际会写的顺序排列。你可以照着建出来,全程不必打开仓库。章末还有一个终端,运行着完成后的引擎,你可以拿它来对照检查自己的版本。

个步骤,按依赖顺序
18
ArrayBuffer → arena → … → WebGPU
行,全部印在下面
4018
由真正运行的文件生成
个运行时依赖
0
没有 WASM、没有 npm 包、没有服务端

这里的源码不会漂移

下面每一段代码都由 scripts/embed-minipicolm-source.mjssrc/lib/minipicolm/ 生成,而 --check 会在两者不一致时让构建失败。这个仓库已经在 chapters.ts 那些手工维护的行数上吃过一次亏。要印上数千行代码的章节,需要比「我会记得更新」更硬的保证。

核心思路

构建顺序与初始设置

示意图十八个步骤
十八个步骤,一个方向ArrayBuffer步骤 1Arena步骤 2Tensor views步骤 3MatMul步骤 4RMSNorm步骤 5RoPE步骤 6Attention步骤 7KV cache步骤 8SwiGLU步骤 9Block步骤 10BPE步骤 11Sampling步骤 12Generation步骤 13GGUF步骤 14Q8步骤 15Q4步骤 16Grammar步骤 17WebGPU步骤 18内存 · 算子 · 模型 · 运行时 · 格式 · GPU
每一步只依赖它前面的那些。下面没有任何前向引用,整个引擎因此可以一遍读完。
从零开始bash
mkdir -p minipicolm/{memory,tensor,model,runtime,tokenizer,format}
cd minipicolm

# there is nothing to install — that is the point
node --version    # 22.6+ for --experimental-strip-types

有两个编译选项很关键。allowImportingTsExtensions 让相对导入保留 .ts 后缀,这是 Node 的 ESM 解析器所要求的;而它的前提是 noEmit。两者合起来,同一批文件才能既被打包器处理,又能在 node --experimental-strip-types 下零构建直接运行。

tsconfig.json —— 真正重要的两项json
{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "target": "ES2020",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["dom", "esnext"]
  }
}

类型擦除只删不编译

任何带运行时效果的语法都会被直接拒绝:构造函数参数属性、enumnamespace。写成 constructor(private readonly dim: number),无头运行就会以 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 失败。先声明字段再赋值,下面每一个类都是这么写的。

最终你会得到这样一棵树:

完成后的目录树text
src/lib/minipicolm/
├── memory/          1-3    ArrayBuffer → arena → tensor views
│   ├── buffer.ts           the single allocation
│   ├── arena.ts            bump allocator, mark/release
│   └── tensor.ts           shape + a view, nothing more
├── tensor/          4,5,9,18
│   ├── matmul.ts           matmul + the FUSED quantised path
│   ├── rmsnorm.ts          x / rms(x) * w
│   ├── softmax.ts          plain, and the streaming form
│   ├── activation.ts       SwiGLU
│   └── webgpu.ts           a real compute shader, feature-detected
├── model/           6,7,10
│   ├── rope.ts             interleaved rotation + precomputed tables
│   ├── attention.ts        GQA over the cache, online softmax
│   ├── embedding.ts        one dequantised row
│   ├── mlp.ts              SwiGLU feed-forward
│   ├── block.ts            the residual pair
│   └── llama.ts            forward(token, pos)
├── runtime/         8,12,13,17
│   ├── kv-cache.ts         [layer][pos][kv_dim], written once
│   ├── workspace.ts        every scratch buffer, allocated once
│   ├── sampler.ts          temperature / top-k / top-p, seeded
│   ├── grammar.ts          JSON pushdown automaton
│   └── generation.ts       the async loop
├── tokenizer/       11
│   └── bpe.ts              SentencePiece, byte fallback
├── format/          14-16
│   ├── gguf.ts             read and write the container
│   └── quant.ts            Q4_0, Q8_0, Q4_K, Q6_K
├── main.ts                 loadFromGGUF() and createToyModel()
├── shell.ts                the commands behind the terminal
├── selftest.ts             90 assertions
├── run-selftest.mts        the headless engine gate
└── run-shell-test.mts      the headless shell gate

工作原理

第 1–3 步 —— 内存

示意图一个 ArrayBuffer,由 arena 切分
一个 ARRAYBUFFER,由 ARENA 切分ARRAYBUFFER —— 一次分配,永不扩容KV 缓存[layers, seq, kv_dim] · float32工作区x · xb · q · k · v · hb · logitsRoPE 表cos / sin0bump 指针arena.f32(n) → new Float32Array(buffer, offset, n)这里每一个 Float32Array 都是视图,不是拷贝。token 循环中不分配任何东西,于是 GC 在两个 token 之间根本不会运行,token 间延迟也就保持平稳。
KV 缓存、所有 scratch 缓冲区、RoPE 表,全都是第一个 token 之前那一次分配上的视图。

推理引擎的分配模式在启动前就完全已知。你知道模型形状,也知道上下文长度,所以你知道自己将来会用到的每一块缓冲区。这就让最简单的分配器成了最正确的那个。

memory/buffer.ts

那唯一一次分配,外加对齐辅助函数。它会当场把错报出来,而不是把问题推迟到后面某次随机的分配上。

src/lib/minipicolm/memory/buffer.tstypescript
/**
 * mini-picoLM — step 1: the ArrayBuffer
 * =====================================
 *
 * Everything the engine touches lives in exactly one `ArrayBuffer`. Not one per
 * tensor, not one per layer — one, allocated at startup and never grown.
 *
 * The reason is the same one picoLM has in C, and it survives the move to
 * JavaScript intact: an inference engine's allocation pattern is completely
 * known before the first token. You know the model shape, you know the context
 * length, so you know every buffer you will ever need. Allocating them up front
 * turns a stream of GC-visible objects into a single flat region.
 *
 * In the browser that matters more than it does in C. A `Float32Array` created
 * per matmul is garbage the collector has to trace; a view into a pre-allocated
 * buffer is not. The difference shows up as jitter between tokens, which is the
 * one thing a streaming UI cannot hide.
 */

/** Typed-array constructors the arena knows how to hand out. */
export type TypedArrayCtor =
  | Float32Array0
  | Int32Array0
  | Uint32Array0
  | Uint8Array0
  | Int8Array0
  | Uint16Array0;

type Float32Array0 = typeof Float32Array;
type Int32Array0 = typeof Int32Array;
type Uint32Array0 = typeof Uint32Array;
type Uint8Array0 = typeof Uint8Array;
type Int8Array0 = typeof Int8Array;
type Uint16Array0 = typeof Uint16Array;

/** GPUs and SIMD both want 256-byte alignment; CPUs are happy with far less. */
export const ALIGNMENT = 256;

export function alignUp(bytes: number, alignment: number = ALIGNMENT): number {
  return Math.ceil(bytes / alignment) * alignment;
}

export function bytesHuman(n: number): string {
  if (n < 1024) return `${n} B`;
  if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
  if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
  return `${(n / 1024 ** 3).toFixed(2)} GB`;
}

/**
 * Allocate the one buffer.
 *
 * Browsers cap a single ArrayBuffer well below the address space — the limit is
 * around 2 GB on 64-bit Chrome and lower elsewhere — so this fails loudly
 * rather than at some random allocation later.
 */
export function createBackingBuffer(bytes: number): ArrayBuffer {
  if (!Number.isFinite(bytes) || bytes <= 0) {
    throw new RangeError(`backing buffer size must be positive, got ${bytes}`);
  }
  try {
    return new ArrayBuffer(bytes);
  } catch (err) {
    throw new RangeError(
      `could not allocate ${bytesHuman(bytes)} in one ArrayBuffer. ` +
        `Lower the context length or use a smaller model. (${String(err)})`,
    );
  }
}

memory/arena.ts

一个 bump 分配器。分配就是一次加法;没有空闲链表,没有碎片,也有意不提供 free()

src/lib/minipicolm/memory/arena.tstypescript
/**
 * mini-picoLM — step 2: the arena allocator
 * =========================================
 *
 * A bump allocator over the single ArrayBuffer from step 1. Allocation is one
 * addition; there is no free list, no fragmentation, and no per-object header.
 *
 * There is also no `free()`. That sounds like a limitation and is actually the
 * design: everything the engine allocates lives exactly as long as the engine
 * does. Scratch space is reused by overwriting, not by releasing. The only
 * lifetime operation that makes sense is `mark()` / `release()` — drop
 * everything allocated after a point — which is how a request-scoped scratch
 * region works.
 *
 * This is the same argument S06 makes about the KV cache from the other end.
 * There, allocation had to be dynamic because output length is unknown. Here,
 * every buffer's size is known before the first token, so the simplest possible
 * allocator is also the right one.
 */

import { ALIGNMENT, alignUp, bytesHuman, createBackingBuffer } from "./buffer.ts";

export type Allocation = {
  readonly name: string;
  readonly byteOffset: number;
  readonly byteLength: number;
};

export class Arena {
  readonly buffer: ArrayBuffer;
  readonly capacity: number;
  private offset = 0;
  private readonly log: Allocation[] = [];

  constructor(capacityBytes: number) {
    this.capacity = alignUp(capacityBytes);
    this.buffer = createBackingBuffer(this.capacity);
  }

  /** Bytes handed out so far, including alignment padding. */
  get used(): number {
    return this.offset;
  }

  get remaining(): number {
    return this.capacity - this.offset;
  }

  /** Every allocation, in order — what the S22 simulator draws. */
  get allocations(): readonly Allocation[] {
    return this.log;
  }

  /**
   * Carve out `count` elements of `Ctor` and return a view onto them.
   *
   * The view aliases the arena; it is not a copy. Two views can legitimately
   * overlap, which is how the workspace reuses one scratch region for several
   * differently-shaped intermediates.
   */
  alloc<T extends ArrayBufferView>(
    Ctor: {
      new (buffer: ArrayBuffer, byteOffset: number, length: number): T;
      BYTES_PER_ELEMENT: number;
    },
    count: number,
    name = "anon",
  ): T {
    const byteLength = count * Ctor.BYTES_PER_ELEMENT;
    const byteOffset = alignUp(this.offset, ALIGNMENT);
    if (byteOffset + byteLength > this.capacity) {
      throw new RangeError(
        `arena exhausted allocating ${bytesHuman(byteLength)} for "${name}": ` +
          `${bytesHuman(this.remaining)} left of ${bytesHuman(this.capacity)}`,
      );
    }
    this.offset = byteOffset + byteLength;
    this.log.push({ name, byteOffset, byteLength });
    return new Ctor(this.buffer, byteOffset, count);
  }

  f32(count: number, name?: string): Float32Array {
    return this.alloc(Float32Array, count, name);
  }

  i32(count: number, name?: string): Int32Array {
    return this.alloc(Int32Array, count, name);
  }

  u8(count: number, name?: string): Uint8Array {
    return this.alloc(Uint8Array, count, name);
  }

  /** Remember the current watermark. */
  mark(): number {
    return this.offset;
  }

  /**
   * Drop everything allocated since `mark`. The bytes are not cleared — the
   * next allocation simply overwrites them, which is the whole point.
   */
  release(mark: number): void {
    if (mark < 0 || mark > this.offset) {
      throw new RangeError(`bad arena mark ${mark} (offset is ${this.offset})`);
    }
    this.offset = mark;
    while (this.log.length && this.log[this.log.length - 1].byteOffset >= mark) {
      this.log.pop();
    }
  }

  summary(): string {
    return `${bytesHuman(this.used)} / ${bytesHuman(this.capacity)} in ${this.log.length} allocations`;
  }
}

memory/tensor.ts

一个形状加一个视图。自动微分、广播、惰性图,推理一样都不需要,而每一样都会给每次运算带来一次分配。

src/lib/minipicolm/memory/tensor.tstypescript
/**
 * mini-picoLM — step 3: tensor views
 * ==================================
 *
 * A tensor here is a shape plus a `Float32Array` that already points into the
 * arena. It owns nothing and allocates nothing.
 *
 * Note what is deliberately missing: no autograd, no broadcasting, no dtype
 * promotion, no lazy graph. Inference needs none of it, and every one of those
 * features costs an allocation or an indirection per operation. The whole type
 * is twenty lines because the whole job is "interpret these bytes as a matrix".
 */

import type { Arena } from "./arena.ts";

export type Shape = readonly number[];

export class Tensor {
  readonly data: Float32Array;
  readonly shape: Shape;
  readonly name: string;

  constructor(data: Float32Array, shape: Shape, name = "anon") {
    const n = shape.reduce((a, b) => a * b, 1);
    if (data.length !== n) {
      throw new RangeError(
        `tensor "${name}": shape [${shape}] needs ${n} elements, view has ${data.length}`,
      );
    }
    this.data = data;
    this.shape = shape;
    this.name = name;
  }

  static zeros(arena: Arena, shape: Shape, name = "anon"): Tensor {
    const n = shape.reduce((a, b) => a * b, 1);
    return new Tensor(arena.f32(n, name), shape, name);
  }

  /** Wrap an existing view without copying — the common case. */
  static of(data: Float32Array, shape: Shape, name = "anon"): Tensor {
    return new Tensor(data, shape, name);
  }

  get rank(): number {
    return this.shape.length;
  }

  get length(): number {
    return this.data.length;
  }

  get rows(): number {
    return this.rank >= 2 ? this.shape[0] : 1;
  }

  get cols(): number {
    return this.shape[this.rank - 1];
  }

  /** Row `i` of a 2-D tensor, as a view. Still no copy. */
  row(i: number): Float32Array {
    const c = this.cols;
    return this.data.subarray(i * c, (i + 1) * c);
  }

  /** A sub-range of the last axis, as a view — used to slice heads apart. */
  slice(start: number, end: number): Float32Array {
    return this.data.subarray(start, end);
  }

  fill(v: number): this {
    this.data.fill(v);
    return this;
  }

  copyFrom(src: ArrayLike<number>): this {
    this.data.set(src);
    return this;
  }

  toString(): string {
    return `Tensor(${this.name}, [${this.shape}])`;
  }
}

arena 就活在这个页面里。mem 会按顺序列出它发放过的每一次分配:先是 KV 缓存,然后是每一块工作区缓冲区。

试一下
$

重点在回收器,不在速度

在 C 里,arena 省下的是 malloc 调用。在 JavaScript 里它省下的东西更值钱。每次 matmul 新建的 Float32Array 都是回收器必须追踪的垃圾,而一次发生在两个 token 之间的回收,在流式界面里就是一次肉眼可见的停顿。因为每个视图都指向那一块预先分配好的 buffer,token 循环里一次分配都没有,token 间延迟也就「平稳」而不只是「低」。

动手观察

第 4、5、9 步 —— 算子

四个小文件。性能的重头戏全在 matmul 上;其余都是一行代码加一段说明它为什么长这样的注释。

tensor/matmul.ts

矩阵-向量乘,因为 decode 没有 batch 维。按行主序顺序遍历以便预取器跟得上,四路展开以便加法链相互独立,而且从不分配:输出视图是传进来的。

src/lib/minipicolm/tensor/matmul.tstypescript
/**
 * mini-picoLM — step 4: matmul
 * ============================
 *
 * Decode is one token at a time, so every matmul in this engine is really a
 * matrix-vector product: `out[d] = dot(W[d], x)`. There is no batch dimension,
 * exactly as in picoLM, and for the same reason — one user, one core.
 *
 * That makes this function completely memory-bound, which is S05's whole point
 * arriving in JavaScript. The arithmetic is trivial; the cost is streaming `W`
 * through the CPU once per token. Two consequences shape the code below:
 *
 *   * **Row-major, sequential.** `W` is walked in the order it is stored, so
 *     the prefetcher can keep up. Transposing the loops costs ~5× on the same
 *     arithmetic.
 *   * **No allocation.** The output view is passed in. A function that returned
 *     a fresh Float32Array would allocate `n_layers × 7` arrays per token.
 *
 * The quantised variants dequantise inline and never materialise a float copy
 * of `W` — the fused path S08 argues for and the Python port of S21 could not
 * express. TypeScript can, because a hand-written loop over a Uint8Array is
 * exactly as fast as any other loop here.
 */

import { dequantBlockInto, blockSize, type QuantType } from "../format/quant.ts";

/** out[d] = dot(W[d, :], x), W row-major [d, n], float32. */
export function matmul(
  out: Float32Array,
  x: Float32Array,
  w: Float32Array,
  n: number,
  d: number,
): void {
  for (let i = 0; i < d; i++) {
    const base = i * n;
    let acc = 0;
    // Four-way unrolled: JS engines do not vectorise this for us, but they do
    // keep four independent accumulators in registers, which hides the latency
    // of the dependent add chain.
    let j = 0;
    let a0 = 0;
    let a1 = 0;
    let a2 = 0;
    let a3 = 0;
    for (; j + 3 < n; j += 4) {
      a0 += w[base + j] * x[j];
      a1 += w[base + j + 1] * x[j + 1];
      a2 += w[base + j + 2] * x[j + 2];
      a3 += w[base + j + 3] * x[j + 3];
    }
    acc = a0 + a1 + a2 + a3;
    for (; j < n; j++) acc += w[base + j] * x[j];
    out[i] = acc;
  }
}

/**
 * The fused path: `W` is still quantised bytes, and one block is decoded into a
 * small stack scratch buffer immediately before it is consumed.
 *
 * Nothing full-precision the size of `W` is ever stored, so the bytes crossing
 * the memory bus are the quantised ones — which is the entire reason to
 * quantise. Compare `dequantizeAll` + `matmul`, which reads the int4 weights
 * AND writes and re-reads a float32 copy: strictly more traffic than never
 * quantising at all.
 */
export function matmulQuant(
  out: Float32Array,
  x: Float32Array,
  w: Uint8Array,
  n: number,
  d: number,
  qtype: QuantType,
  scratch: Float32Array,
): void {
  const bs = blockSize(qtype);
  if (n % bs !== 0) {
    throw new RangeError(`matmulQuant: ${n} is not a multiple of the ${bs}-weight block`);
  }
  const blocksPerRow = n / bs;

  for (let i = 0; i < d; i++) {
    let acc = 0;
    for (let b = 0; b < blocksPerRow; b++) {
      // decode 32 or 256 weights into registers-worth of scratch, use, discard
      dequantBlockInto(w, i * blocksPerRow + b, qtype, scratch);
      const off = b * bs;
      for (let j = 0; j < bs; j++) acc += scratch[j] * x[off + j];
    }
    out[i] = acc;
  }
}

/** out += a (in place) — the residual stream's only operation. */
export function addInto(out: Float32Array, a: Float32Array): void {
  for (let i = 0; i < out.length; i++) out[i] += a[i];
}

/** out[i] = a[i] * b[i] — SwiGLU's gate. */
export function mulInto(out: Float32Array, a: Float32Array, b: Float32Array): void {
  for (let i = 0; i < out.length; i++) out[i] = a[i] * b[i];
}

export function dot(a: Float32Array, b: Float32Array, n: number): number {
  let acc = 0;
  for (let i = 0; i < n; i++) acc += a[i] * b[i];
  return acc;
}

tensor/rmsnorm.ts

把其实无关紧要的部分统统去掉的 LayerNorm。注意 epsilon。它是随 GGUF 元数据一起发布的模型配置,各家取值不同:TinyLlama 和 Llama-2 用 1e-5 训练,最初的 LLaMA 用的是 S03 的 1e-6。加载时用错了,每个激活都会轻微偏移,并在层间累积。

src/lib/minipicolm/tensor/rmsnorm.tstypescript
/**
 * mini-picoLM — step 5: RMSNorm
 * =============================
 *
 * LayerNorm with the parts that turned out not to matter removed: no mean
 * subtraction, no bias. `x / rms(x) * weight`.
 *
 * The epsilon is inside the square root and is not decorative. It is model
 * configuration, shipped in GGUF metadata: TinyLlama and Llama-2 train with
 * 1e-5, the original LLaMA with 1e-6 (S03's value). Load a model with the
 * wrong one and every activation shifts slightly, compounding over 22 layers —
 * the kind of mismatch that produces a model which works and is quietly a
 * little worse.
 */

export const RMS_EPS = 1e-5;

export function rmsnorm(
  out: Float32Array,
  x: Float32Array,
  weight: Float32Array,
  eps: number = RMS_EPS,
): void {
  const n = x.length;
  let ss = 0;
  for (let i = 0; i < n; i++) ss += x[i] * x[i];
  const scale = 1 / Math.sqrt(ss / n + eps);
  for (let i = 0; i < n; i++) out[i] = x[i] * scale * weight[i];
}

tensor/softmax.ts

同一个函数的两种形态。真正重要的是 OnlineSoftmax。它从不看到整行,注意力因此能在任意长度的缓存上流式计算而不分配。

src/lib/minipicolm/tensor/softmax.tstypescript
/**
 * mini-picoLM — softmax, twice
 * ============================
 *
 * Two shapes of the same function, and the difference is the subject of S12.
 *
 * `softmax` is the ordinary one: find the max, exponentiate, normalise. It
 * needs the whole row in memory at once.
 *
 * `OnlineSoftmax` never sees the whole row. It keeps a running maximum and a
 * running sum, and rescales both whenever a larger value arrives. That is what
 * lets attention stream over a KV cache of any length without ever allocating
 * an array of scores — the single most important structural idea in a decode
 * kernel, and it is nine lines.
 */

/** In-place softmax over `x[0..n)`. Subtracting the max is what stops exp() overflowing. */
export function softmax(x: Float32Array, n: number = x.length): void {
  let max = -Infinity;
  for (let i = 0; i < n; i++) if (x[i] > max) max = x[i];
  let sum = 0;
  for (let i = 0; i < n; i++) {
    const e = Math.exp(x[i] - max);
    x[i] = e;
    sum += e;
  }
  const inv = 1 / sum;
  for (let i = 0; i < n; i++) x[i] *= inv;
}

/**
 * The streaming form. Feed it one score and one value vector at a time; the
 * accumulator holds the correctly-weighted partial result at every point.
 *
 *   m  running maximum
 *   l  running sum of exp(score - m)
 *   acc  running sum of exp(score - m) * v
 *
 * When a score exceeds `m`, everything accumulated so far was scaled against
 * the old maximum, so it is corrected by exp(m_old - m_new) — one multiply per
 * element, and the invariant holds again.
 */
export class OnlineSoftmax {
  private m = -Infinity;
  private l = 0;
  private readonly acc: Float32Array;

  private readonly dim: number;

  constructor(dim: number, scratch: Float32Array) {
    this.dim = dim;
    if (scratch.length < dim) {
      throw new RangeError(`OnlineSoftmax needs ${dim} scratch elements, got ${scratch.length}`);
    }
    this.acc = scratch.subarray(0, dim);
  }

  reset(): void {
    this.m = -Infinity;
    this.l = 0;
    this.acc.fill(0);
  }

  /** Fold one (score, value) pair into the accumulator. */
  push(score: number, v: Float32Array, vOffset: number): void {
    // A -Infinity score is a masked position and its weight is exp(-inf) = 0,
    // so drop it at the door. Without this, a fully-masked stream leaves m at
    // -Infinity and the else-branch below computes exp(-inf - -inf) = NaN —
    // the classic fully-masked-tile bug. Finite scores need no guard at all:
    // exp(-inf - score) is already 0.
    if (score === -Infinity) return;
    const { acc, dim } = this;
    if (score > this.m) {
      const correction = Math.exp(this.m - score);
      this.l = this.l * correction + 1;
      for (let i = 0; i < dim; i++) acc[i] = acc[i] * correction + v[vOffset + i];
      this.m = score;
    } else {
      const wgt = Math.exp(score - this.m);
      this.l += wgt;
      for (let i = 0; i < dim; i++) acc[i] += wgt * v[vOffset + i];
    }
  }

  /** Normalise into `out`. Safe to call once per query head. */
  finish(out: Float32Array, outOffset: number): void {
    const inv = this.l === 0 ? 0 : 1 / this.l;
    for (let i = 0; i < this.dim; i++) out[outOffset + i] = this.acc[i] * inv;
  }
}

tensor/activation.ts

SwiGLU。经典 FFN 用两个矩阵,它用三个;多出来的那个是门控,于是网络可以「压制」某个通道,而不只是「没能激发」它。

src/lib/minipicolm/tensor/activation.tstypescript
/**
 * mini-picoLM — step 9: SwiGLU
 * ============================
 *
 * `SwiGLU(x) = (SiLU(x·W1) * (x·W3)) · W2`
 *
 * Three matrices where a classic FFN has two. The extra one is the *gate*: its
 * output multiplies the activation elementwise, so the network can suppress a
 * channel rather than merely fail to excite it.
 *
 * The cost is the reason `d_ff` looks odd in Llama configs. A two-matrix FFN at
 * `4 * d_model` and a three-matrix one at `(8/3) * d_model` hold the same
 * parameter count, which is why you see 5632 next to 2048 rather than 8192.
 */

/** SiLU, a.k.a. swish: x * sigmoid(x). */
export function silu(x: Float32Array, n: number = x.length): void {
  for (let i = 0; i < n; i++) x[i] = x[i] / (1 + Math.exp(-x[i]));
}

export function siluScalar(v: number): number {
  return v / (1 + Math.exp(-v));
}

/**
 * Fuse the activation and the gate into one pass.
 *
 * `hb` holds the gate projection, `hb2` the up projection. Doing this in one
 * loop rather than two keeps `hb` in cache between the two reads — the same
 * argument as kernel fusion in S13, at a scale where you can see the whole
 * thing.
 */
export function siluMulInto(hb: Float32Array, hb2: Float32Array, n: number = hb.length): void {
  for (let i = 0; i < n; i++) {
    const g = hb[i];
    hb[i] = (g / (1 + Math.exp(-g))) * hb2[i];
  }
}

bench 会真的把上面这些算子跑起来:十六次前向,计时,并给出它们花掉的 matmul 次数与 MAC 数。

试一下
$

特例是被掩码的位置,不是第一次折叠

对有限的 score,exp(-Infinity - score) 本身就是 0,第一次折叠不需要任何守卫。真正需要守卫的是全被掩码的流:每个 score 都是 -Infinity,滚动最大值永远不动,累加分支就会算出 exp(-Infinity - -Infinity),也就是 NaN。真实 kernel 守的正是这一点,push 也一样:-Infinity 的 score 在门口就被丢弃,反正它的权重就是 exp(-inf) = 0

亲手实现

第 15–16 步 —— 提前登场的 Q8 与 Q4

示意图融合与不融合
浏览器里的融合与不融合MATMULQUANT — FUSEDint4 字节把一个 block 解码到 scratch256 floats of scratch立即累加1× trafficDEQUANTIZEROW + MATMUL — UNFUSEDint4 字节整个矩阵的 float32 副本先写进堆,再读回来matmul4× trafficTypeScript 能写出 NumPy 写不出的融合循环:在这里,手写的 Uint8Array 循环和任何别的循环一样快。于是浏览器版拿到了 Python 版不得不放弃的那份 S08 收益。
不融合的路径既要读 int4 权重,又要写出并读回一份 float32 副本:比压根不量化搬得还多。

按编号它们是第 15、16 步,但上面的 matmul 已经导入了它们,所以它们该放在这里。这些格式来自 S21,当时曾与 picoLM 自己的 quant.c 编译对拍、在随机字节上逐字节比较过。

format/quant.ts

Q4_0 和 Q8_0 是 32 个权重配一个 fp16 scale。Q4_K 和 Q6_K 是 256 权重的超块,带嵌套 scale:Q4_K 给每 32 个权重的子块配一个 6-bit 的 scale 和 min,Q6_K 则是十六个带符号的 8-bit scale,每 16 个权重一个。正是这层嵌套让 4 bit 也能跟住离群值,也正是真实 4-bit 模型每权重 4.5 bit 的原因。

src/lib/minipicolm/format/quant.tstypescript
/**
 * mini-picoLM — steps 15 & 16: Q8 and Q4
 * ======================================
 *
 * The GGUF block formats, in TypeScript. These are the same algorithms the S21
 * Python port implements, and that port was compiled against picoLM's own
 * `quant.c` and compared byte for byte on random blocks — so the numbers here
 * have a verified reference. `selftest.ts` re-checks them against blocks
 * written by hand from the spec.
 *
 * Two families:
 *
 *   **Q8_0 / Q4_0** — 32 weights, one fp16 scale. Simple, symmetric, and what
 *   "4-bit" meant before 2023.
 *
 *   **Q4_K / Q6_K** — 256-weight super-blocks with *nested* scales. Q4_K packs
 *   a 6-bit scale and min per 32-weight sub-block under one fp16 pair; Q6_K
 *   carries sixteen signed 8-bit scales, one per 16-weight sub-block, under a
 *   single fp16 scale. That nesting is what tracks outliers at four bits, and
 *   it is why a real 4-bit model is 4.5 bits per weight rather than 4.0.
 *
 * The awkward bit-packing in `getScaleMin` is the format, not the port: for the
 * first four sub-blocks the 6-bit fields sit in their own bytes, and for the
 * last four they straddle byte boundaries.
 */

export type QuantType = "F32" | "F16" | "Q4_0" | "Q8_0" | "Q4_K" | "Q6_K";

/** GGUF's numeric tensor-type tags. */
export const GGUF_TYPE: Record<number, QuantType> = {
  0: "F32",
  1: "F16",
  2: "Q4_0",
  8: "Q8_0",
  12: "Q4_K",
  14: "Q6_K",
};

const BLOCK: Record<QuantType, number> = {
  F32: 1,
  F16: 1,
  Q4_0: 32,
  Q8_0: 32,
  Q4_K: 256,
  Q6_K: 256,
};

const QSIZE: Record<QuantType, number> = {
  F32: 4,
  F16: 2,
  Q4_0: 18,
  Q8_0: 34,
  Q4_K: 144,
  Q6_K: 210,
};

export function blockSize(t: QuantType): number {
  return BLOCK[t];
}

export function quantSize(t: QuantType): number {
  return QSIZE[t];
}

export function rowSize(t: QuantType, n: number): number {
  return (n / BLOCK[t]) * QSIZE[t];
}

export function bitsPerWeight(t: QuantType): number {
  return (8 * QSIZE[t]) / BLOCK[t];
}

/* ------------------------------------------------------------------ fp16 */

/**
 * JavaScript has no Float16Array in every engine we care about yet, so decode
 * IEEE-754 binary16 by hand — the same bit surgery picoLM writes in C because
 * its target CPUs lack hardware fp16.
 */
export function fp16ToFp32(h: number): number {
  const sign = (h >> 15) & 1;
  const exp = (h >> 10) & 0x1f;
  const mant = h & 0x3ff;
  let value: number;
  if (exp === 0) {
    value = mant === 0 ? 0 : mant * 2 ** -24; // subnormal
  } else if (exp === 31) {
    value = mant === 0 ? Infinity : NaN;
  } else {
    value = (mant + 1024) * 2 ** (exp - 25);
  }
  return sign ? -value : value;
}

const _f32 = new Float32Array(1);
const _u32 = new Uint32Array(_f32.buffer);

export function fp32ToFp16(f: number): number {
  _f32[0] = f;
  const bits = _u32[0];
  const sign = (bits >>> 16) & 0x8000;
  const exp = ((bits >>> 23) & 0xff) - 127 + 15;
  const mant = bits & 0x7fffff;
  if (((bits >>> 23) & 0xff) === 0xff) return sign | 0x7c00 | (mant ? 0x200 : 0);
  if (exp >= 31) return sign | 0x7c00;
  if (exp <= 0) {
    if (exp < -10) return sign;
    const m = (mant | 0x800000) >>> (14 - exp);
    return sign | m;
  }
  return sign | (exp << 10) | (mant >>> 13);
}

/* ------------------------------------------------- one block at a time */

/** Q4_K's six-bit scale/min pair for sub-block `j`, unpacked from 12 bytes. */
function getScaleMin(q: Uint8Array, base: number, j: number): [number, number] {
  if (j < 4) {
    return [q[base + j] & 63, q[base + j + 4] & 63];
  }
  const sc = (q[base + j + 4] & 0xf) | ((q[base + j - 4] >> 6) << 4);
  const mn = (q[base + j + 4] >> 4) | ((q[base + j] >> 6) << 4);
  return [sc, mn];
}

/**
 * Decode block `index` of a quantised tensor into `out[0..blockSize)`.
 *
 * This is the primitive the fused matmul calls. It writes into a caller-owned
 * scratch buffer so that nothing the size of the weight matrix is ever
 * allocated — the whole point of fusing.
 */
export function dequantBlockInto(
  raw: Uint8Array,
  index: number,
  qtype: QuantType,
  out: Float32Array,
): void {
  const off = index * QSIZE[qtype];
  const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);

  switch (qtype) {
    case "F32": {
      out[0] = dv.getFloat32(off, true);
      return;
    }
    case "F16": {
      out[0] = fp16ToFp32(dv.getUint16(off, true));
      return;
    }
    case "Q8_0": {
      const d = fp16ToFp32(dv.getUint16(off, true));
      for (let i = 0; i < 32; i++) out[i] = d * dv.getInt8(off + 2 + i);
      return;
    }
    case "Q4_0": {
      const d = fp16ToFp32(dv.getUint16(off, true));
      for (let i = 0; i < 16; i++) {
        const b = raw[off + 2 + i];
        out[i] = d * ((b & 0xf) - 8);
        out[i + 16] = d * ((b >> 4) - 8);
      }
      return;
    }
    case "Q4_K": {
      const d = fp16ToFp32(dv.getUint16(off, true));
      const dmin = fp16ToFp32(dv.getUint16(off + 2, true));
      const sBase = off + 4;
      const qBase = off + 16;
      let o = 0;
      let qb = 0;
      for (let j = 0; j < 4; j++) {
        const [sc1, mn1] = getScaleMin(raw, sBase, j * 2);
        const [sc2, mn2] = getScaleMin(raw, sBase, j * 2 + 1);
        const d1 = d * sc1;
        const m1 = dmin * mn1;
        const d2 = d * sc2;
        const m2 = dmin * mn2;
        for (let l = 0; l < 32; l++) {
          const b = raw[qBase + qb + l];
          out[o + l] = d1 * (b & 0xf) - m1;
          out[o + l + 32] = d2 * (b >> 4) - m2;
        }
        o += 64;
        qb += 32;
      }
      return;
    }
    case "Q6_K": {
      const d = fp16ToFp32(dv.getUint16(off + 208, true));
      const qlBase = off;
      const qhBase = off + 128;
      const scBase = off + 192;
      for (let c = 0; c < 2; c++) {
        const ql = qlBase + c * 64;
        const qh = qhBase + c * 32;
        const isBase = c * 8;
        const y = c * 128;
        for (let l = 0; l < 32; l++) {
          const isL = isBase + (l >> 4);
          const lo = raw[ql + l];
          const hi = raw[ql + l + 32];
          const h = raw[qh + l];
          out[y + l] = d * dv.getInt8(scBase + isL) * (((lo & 0xf) | (((h >> 0) & 3) << 4)) - 32);
          out[y + l + 32] =
            d * dv.getInt8(scBase + isL + 2) * (((hi & 0xf) | (((h >> 2) & 3) << 4)) - 32);
          out[y + l + 64] =
            d * dv.getInt8(scBase + isL + 4) * (((lo >> 4) | (((h >> 4) & 3) << 4)) - 32);
          out[y + l + 96] =
            d * dv.getInt8(scBase + isL + 6) * (((hi >> 4) | (((h >> 6) & 3) << 4)) - 32);
        }
      }
      return;
    }
    default: {
      const never: never = qtype;
      throw new Error(`unsupported quant type ${String(never)}`);
    }
  }
}

/**
 * Decode `n` consecutive weights.
 *
 * Convenient, and exactly what a hot loop must not do — it materialises the
 * float copy that fusing exists to avoid. Used for the norm weights (tiny) and
 * by the tests.
 */
export function dequantizeRow(raw: Uint8Array, n: number, qtype: QuantType): Float32Array {
  const bs = BLOCK[qtype];
  if (n % bs !== 0) {
    throw new RangeError(`${n} weights is not a whole number of ${qtype} blocks`);
  }
  const out = new Float32Array(n);
  const scratch = new Float32Array(bs);
  const nb = n / bs;
  for (let b = 0; b < nb; b++) {
    dequantBlockInto(raw, b, qtype, scratch);
    out.set(scratch, b * bs);
  }
  return out;
}

运行 quant 看块结构表,或 quant Q4_K 解码一个真实的超块。fp16 65504 给出最大的有限半精度值;试试 fp16 1e-8 看次正规下限,fp16 70000 看饱和。

试一下
$
试一下
$

这就是 TypeScript 赢过 NumPy 的地方

S08 论证过:量化只有在反量化被融合进 matmul 时才划算。S21 的 Python 版做不到:在 NumPy 里表达那个循环意味着回到 Python 逐元素迭代,比它想避免的东西还慢。而在这里,手写的 Uint8Array 循环和任何别的循环一样快,所以上面的 matmulQuant 就只是代码本身。浏览器版拿到了 Python 版不得不放弃的那份收益。

生产实践

第 6–10 步 —— 模型

RoPE、缓存、注意力,以及 block。六个文件,把算子变成一个网络。

model/rope.ts

旋转位置编码,交错式,把 x[2i]x[2i+1] 配对。S03 讲的是 NeoX 约定,配对的是 x[i]x[i + D/2]。两者都叫 RoPE,各自都自洽,而用错了的模型会输出通顺、自信、却错得不易察觉的文本。

src/lib/minipicolm/model/rope.tstypescript
/**
 * mini-picoLM — step 6: RoPE
 * ==========================
 *
 * Rotary position embedding. Instead of adding a position vector, rotate each
 * pair of dimensions by an angle proportional to the position. The dot product
 * of two rotated vectors then depends only on the *gap* between their
 * positions — which is exactly why a KV cache is sound: a cached key's rotation
 * is fixed forever, because its absolute position never changes.
 *
 * There are two incompatible conventions, both called RoPE:
 *
 *   **interleaved** — pair `x[2i]` with `x[2i+1]`. llama.cpp, picoLM, and every
 *   Llama GGUF file.
 *
 *   **NeoX / split-half** — pair `x[i]` with `x[i + D/2]`. GPT-NeoX, and the
 *   one S03 teaches.
 *
 * Pick the wrong one and the model runs, sounds fluent, and is subtly wrong.
 * This is the interleaved one, because this engine loads GGUF.
 */

/** Precomputed cos/sin for every position — keeps trig out of the token loop. */
export class RopeTables {
  readonly cos: Float32Array;
  readonly sin: Float32Array;
  readonly half: number;

  constructor(maxSeq: number, headDim: number, freqBase = 10000) {
    this.half = headDim >> 1;
    this.cos = new Float32Array(maxSeq * this.half);
    this.sin = new Float32Array(maxSeq * this.half);
    for (let pos = 0; pos < maxSeq; pos++) {
      for (let i = 0; i < this.half; i++) {
        const theta = pos / Math.pow(freqBase, (2 * i) / headDim);
        this.cos[pos * this.half + i] = Math.cos(theta);
        this.sin[pos * this.half + i] = Math.sin(theta);
      }
    }
  }
}

/**
 * Rotate `x` in place: `nHeads` heads of `headDim` laid out contiguously.
 *
 * Applied to Q and K, never to V — V carries content, not position.
 */
export function ropeInPlace(
  x: Float32Array,
  nHeads: number,
  headDim: number,
  tables: RopeTables,
  pos: number,
): void {
  const half = tables.half;
  const base = pos * half;
  for (let h = 0; h < nHeads; h++) {
    const o = h * headDim;
    for (let i = 0; i < half; i++) {
      const c = tables.cos[base + i];
      const s = tables.sin[base + i];
      const x0 = x[o + i * 2];
      const x1 = x[o + i * 2 + 1];
      x[o + i * 2] = x0 * c - x1 * s;
      x[o + i * 2 + 1] = x0 * s + x1 * c;
    }
  }
}

runtime/kv-cache.ts

一块按 [层][位置][kv_dim] 索引的平坦区域。key 存的是旋转之后的,因为缓存中 token 的位置永不改变;条目一旦写入就不可变。

src/lib/minipicolm/runtime/kv-cache.tstypescript
/**
 * mini-picoLM — step 8: the KV cache
 * ==================================
 *
 * One flat region of the arena, indexed `[layer][position][kv_dim]`. Writing
 * position `p` is a `set()` into a subarray; reading positions `0..p` is a
 * subarray. No allocation on either path.
 *
 * Two properties do all the work:
 *
 *   **Keys are stored after RoPE.** A cached token's position never changes, so
 *   its rotation never changes. Storing pre-rotation would force re-rotating
 *   the whole cache every step — replacing one compute cost with a smaller one
 *   and defeating most of the point.
 *
 *   **Entries are immutable once written.** Writing position N must not disturb
 *   anything before it. `selftest.ts` asserts this, because a cache that
 *   silently corrupts history produces text that degrades over hundreds of
 *   tokens with no error anywhere.
 *
 * Unlike picoLM this stores float32, not fp16 — `Float16Array` is not yet
 * available across the browsers this has to run in. That doubles the cache, and
 * since the cache is the resident cost, it is the main reason the context
 * length here is smaller than picoLM's on the same device. The arithmetic is in
 * the S22 simulator.
 */

import type { Arena } from "../memory/arena.ts";

export class KVCache {
  readonly key: Float32Array;
  readonly value: Float32Array;
  readonly nLayers: number;
  readonly maxSeq: number;
  readonly kvDim: number;
  private _length = 0;

  constructor(arena: Arena, nLayers: number, maxSeq: number, kvDim: number) {
    this.nLayers = nLayers;
    this.maxSeq = maxSeq;
    this.kvDim = kvDim;
    const n = nLayers * maxSeq * kvDim;
    this.key = arena.f32(n, "kv.key");
    this.value = arena.f32(n, "kv.value");
  }

  /** Positions written so far. */
  get length(): number {
    return this._length;
  }

  get bytes(): number {
    return this.key.byteLength + this.value.byteLength;
  }

  private offset(layer: number, pos: number): number {
    return (layer * this.maxSeq + pos) * this.kvDim;
  }

  /** Append this layer's K and V for position `pos`. */
  write(layer: number, pos: number, k: Float32Array, v: Float32Array): void {
    if (pos >= this.maxSeq) {
      throw new RangeError(`position ${pos} past the ${this.maxSeq}-token cache`);
    }
    const o = this.offset(layer, pos);
    this.key.set(k, o);
    this.value.set(v, o);
    if (layer === this.nLayers - 1) this._length = Math.max(this._length, pos + 1);
  }

  /** Byte offset of one head's K vector — attention reads through this. */
  headOffset(layer: number, pos: number, kvHead: number, headDim: number): number {
    return this.offset(layer, pos) + kvHead * headDim;
  }

  reset(): void {
    this._length = 0;
    this.key.fill(0);
    this.value.fill(0);
  }
}

model/attention.ts

在缓存上做 GQA,配合在线 softmax。没有分数数组,所以无论上下文多长,中间内存峰值都只是 head_dim 个浮点数。

src/lib/minipicolm/model/attention.tstypescript
/**
 * mini-picoLM — step 7: attention
 * ===============================
 *
 * Grouped-query attention over the KV cache, computed with an online softmax.
 *
 * The shape of this function is the whole argument of S12. There is no `scores`
 * array. At no context length does this allocate anything proportional to the
 * sequence — it streams over the cache once per head, folding each (score,
 * value) pair into a running accumulator. Peak intermediate memory is
 * `head_dim` floats, whatever the context.
 *
 * GQA is the `kvHead = h / kvMul` line. Several query heads read the same K and
 * V rows, so the cache shrinks by that factor while the query side keeps its
 * full expressiveness. It is a memory optimisation, not a quality one.
 */

import { OnlineSoftmax } from "../tensor/softmax.ts";
import type { KVCache } from "../runtime/kv-cache.ts";

export type AttentionShape = {
  nHeads: number;
  nKvHeads: number;
  headDim: number;
};

/**
 * out[0..nHeads*headDim) = attention(q, cache[layer][0..=pos])
 *
 * `scratch` must hold at least `headDim` floats and is reused across heads.
 */
export function attention(
  out: Float32Array,
  q: Float32Array,
  cache: KVCache,
  layer: number,
  pos: number,
  shape: AttentionShape,
  scratch: Float32Array,
): void {
  const { nHeads, nKvHeads, headDim } = shape;
  const kvMul = nHeads / nKvHeads;
  const inv = 1 / Math.sqrt(headDim);
  const online = new OnlineSoftmax(headDim, scratch);

  for (let h = 0; h < nHeads; h++) {
    const kvHead = Math.floor(h / kvMul);
    const qo = h * headDim;
    online.reset();

    for (let t = 0; t <= pos; t++) {
      const ko = cache.headOffset(layer, t, kvHead, headDim);
      let score = 0;
      for (let i = 0; i < headDim; i++) score += q[qo + i] * cache.key[ko + i];
      online.push(score * inv, cache.value, ko);
    }

    online.finish(out, qo);
  }
}

model/embedding.ts

一行反量化。嵌入表通常是文件里最大的张量,而一次 decode 只碰它的其中一行。

src/lib/minipicolm/model/embedding.tstypescript
/**
 * mini-picoLM — the embedding lookup
 * ==================================
 *
 * A row of the embedding table, dequantised on demand.
 *
 * Worth noticing how little happens here. `token_embd.weight` is usually the
 * largest single tensor in the file — 32000 × 2048 for TinyLlama — and a decode
 * step touches exactly one of its rows. Dequantising the whole table at load
 * time would cost hundreds of megabytes to save a few microseconds per token.
 * So it stays quantised, mapped, and untouched except for the one row.
 */

import { dequantBlockInto, blockSize, rowSize, type QuantType } from "../format/quant.ts";

/**
 * Write row `token` of a [vocab, dim] quantised table into `out`.
 *
 * `out` is a caller-owned view — the residual stream itself, in practice, so
 * the embedding is written straight into the buffer the first layer reads.
 */
export function embedInto(
  out: Float32Array,
  table: Uint8Array,
  token: number,
  dim: number,
  qtype: QuantType,
  scratch: Float32Array,
): void {
  const bs = blockSize(qtype);
  const perRow = rowSize(qtype, dim);
  const rowStart = token * perRow;
  const blocksPerRow = dim / bs;
  // `dequantBlockInto` indexes blocks from the start of the view it is given,
  // so hand it a view that starts at this row.
  const row = table.subarray(rowStart, rowStart + perRow);

  for (let b = 0; b < blocksPerRow; b++) {
    dequantBlockInto(row, b, qtype, scratch);
    out.set(scratch.subarray(0, bs), b * bs);
  }
}

model/mlp.ts

gate 与 up 并行,激活并相乘,然后 down。一个 Llama block 大约 70% 的参数住在这里。

src/lib/minipicolm/model/mlp.tstypescript
/**
 * mini-picoLM — the feed-forward network
 * ======================================
 *
 * SwiGLU: gate and up in parallel, activate-and-multiply, then down.
 *
 * This is where most of the model's parameters live — roughly 70% of a Llama
 * block — which makes it the dominant term in decode bandwidth. Everything the
 * quantisation chapters buy, they buy mostly here.
 */

import { siluMulInto } from "../tensor/activation.ts";
import type { QuantWeight } from "./llama.ts";
import type { Workspace } from "../runtime/workspace.ts";

export function feedForward(
  out: Float32Array,
  x: Float32Array,
  gate: QuantWeight,
  up: QuantWeight,
  down: QuantWeight,
  dim: number,
  hidden: number,
  ws: Workspace,
): void {
  ws.matmul(ws.hb, x, gate, dim, hidden);
  ws.matmul(ws.hb2, x, up, dim, hidden);
  siluMulInto(ws.hb, ws.hb2, hidden);
  ws.matmul(out, ws.hb, down, hidden, dim);
}

runtime/workspace.ts

所有 scratch 缓冲区,一次分配完;外加那个唯一的 matmul 入口,按权重的存储方式分派。

src/lib/minipicolm/runtime/workspace.tstypescript
/**
 * mini-picoLM — the workspace
 * ===========================
 *
 * Every scratch buffer the forward pass needs, allocated once from the arena.
 *
 * The list is short and fixed because the shapes are known before the first
 * token: one residual stream, two same-size temporaries, a query vector, two
 * FFN-width buffers, a logits row, and one block of dequantisation scratch.
 * That is the entire working set of a decode step.
 *
 * `matmul` lives here rather than in `tensor/` because it needs the scratch
 * buffer to fuse against. Routing every weight through this one method also
 * makes the WebGPU backend a single substitution rather than a rewrite.
 */

import type { Arena } from "../memory/arena.ts";
import { matmul, matmulQuant } from "../tensor/matmul.ts";
import { blockSize } from "../format/quant.ts";
import type { QuantWeight } from "../model/llama.ts";

export class Workspace {
  readonly x: Float32Array; // residual stream
  readonly xb: Float32Array; // post-norm / attention out
  readonly xb2: Float32Array; // second temporary
  readonly q: Float32Array;
  readonly k: Float32Array;
  readonly v: Float32Array;
  readonly hb: Float32Array; // ffn hidden (gate)
  readonly hb2: Float32Array; // ffn hidden (up)
  readonly logits: Float32Array;
  readonly attScratch: Float32Array;
  readonly qScratch: Float32Array; // one dequantised block

  /** Counted so the simulator can show where the time goes. */
  matmulCalls = 0;
  matmulMacs = 0;

  constructor(arena: Arena, dim: number, hidden: number, kvDim: number, vocab: number) {
    this.x = arena.f32(dim, "ws.x");
    this.xb = arena.f32(dim, "ws.xb");
    this.xb2 = arena.f32(dim, "ws.xb2");
    this.q = arena.f32(dim, "ws.q");
    this.k = arena.f32(kvDim, "ws.k");
    this.v = arena.f32(kvDim, "ws.v");
    this.hb = arena.f32(hidden, "ws.hb");
    this.hb2 = arena.f32(hidden, "ws.hb2");
    this.logits = arena.f32(vocab, "ws.logits");
    this.attScratch = arena.f32(Math.max(dim, 256), "ws.att");
    this.qScratch = arena.f32(256, "ws.dequant"); // one Q4_K/Q6_K super-block
  }

  /**
   * The single matmul entry point. Dispatches on how the weight is stored.
   *
   * Synchronous by design — see `tensor/webgpu.ts` for why the GPU path cannot
   * live behind this signature, and why that is the honest answer rather than a
   * limitation to route around.
   */
  matmul(out: Float32Array, x: Float32Array, w: QuantWeight, n: number, d: number): void {
    this.matmulCalls++;
    this.matmulMacs += n * d;

    if (w.kind === "f32") {
      matmul(out, x, w.data, n, d);
      return;
    }
    const bs = blockSize(w.qtype);
    matmulQuant(out, x, w.raw, n, d, w.qtype, this.qScratch.subarray(0, bs));
  }

  resetCounters(): void {
    this.matmulCalls = 0;
    this.matmulMacs = 0;
  }
}

model/block.ts

七个操作,两次穿过残差流。x 从不被拷贝。

src/lib/minipicolm/model/block.tstypescript
/**
 * mini-picoLM — step 10: the transformer block
 * ============================================
 *
 * Seven operations, twice through the residual stream:
 *
 *     x += attn(rmsnorm(x))
 *     x += ffn(rmsnorm(x))
 *
 * Everything before this file was a part; this is where they become a layer.
 * Note that `x` is never copied — both sublayers read it, write into scratch,
 * and add back. The residual stream is one buffer for the whole forward pass.
 */

import { rmsnorm } from "../tensor/rmsnorm.ts";
import { addInto } from "../tensor/matmul.ts";
import { ropeInPlace, type RopeTables } from "./rope.ts";
import { attention } from "./attention.ts";
import { feedForward } from "./mlp.ts";
import type { LayerWeights, ModelConfig } from "./llama.ts";
import type { Workspace } from "../runtime/workspace.ts";
import type { KVCache } from "../runtime/kv-cache.ts";

export function forwardBlock(
  layer: number,
  pos: number,
  cfg: ModelConfig,
  w: LayerWeights,
  ws: Workspace,
  cache: KVCache,
  rope: RopeTables,
): void {
  const { dim, nHeads, nKvHeads, headDim, kvDim, hidden } = cfg;

  /* ---- attention ---- */
  rmsnorm(ws.xb, ws.x, w.attnNorm);

  ws.matmul(ws.q, ws.xb, w.attnQ, dim, dim);
  ws.matmul(ws.k, ws.xb, w.attnK, dim, kvDim);
  ws.matmul(ws.v, ws.xb, w.attnV, dim, kvDim);

  // Rotate before caching: a cached key's position is fixed forever.
  ropeInPlace(ws.q, nHeads, headDim, rope, pos);
  ropeInPlace(ws.k, nKvHeads, headDim, rope, pos);

  cache.write(layer, pos, ws.k, ws.v);

  attention(ws.xb, ws.q, cache, layer, pos, { nHeads, nKvHeads, headDim }, ws.attScratch);

  ws.matmul(ws.xb2, ws.xb, w.attnOut, dim, dim);
  addInto(ws.x, ws.xb2);

  /* ---- feed-forward ---- */
  rmsnorm(ws.xb, ws.x, w.ffnNorm);
  feedForward(ws.xb2, ws.xb, w.ffnGate, w.ffnUp, w.ffnDown, dim, hidden, ws);
  addInto(ws.x, ws.xb2);
}

model/llama.ts

本身就是整门课的那个签名:forward(token, pos)。一次调用一个 token 是 S01,用 pos 索引持久缓存是 S05,量化存储是 S08,在线 softmax 是 S12,nKvHeads < nHeads 是 S03。

src/lib/minipicolm/model/llama.tstypescript
/**
 * mini-picoLM — the model
 * =======================
 *
 * Config, weights, and `forward(token, pos)`.
 *
 * That signature is the whole course in one line. One token per call is the S01
 * loop; `pos` indexing a persistent cache is S05; the weights arriving as
 * quantised bytes is S08; the online softmax inside attention is S12;
 * `nKvHeads < nHeads` is S03's GQA.
 *
 * There is no batch dimension anywhere, which is why there is no scheduler, no
 * block manager and no continuous batching. One user, one tab.
 */

import { Arena } from "../memory/arena.ts";
import { rmsnorm } from "../tensor/rmsnorm.ts";
import { embedInto } from "./embedding.ts";
import { forwardBlock } from "./block.ts";
import { RopeTables } from "./rope.ts";
import { KVCache } from "../runtime/kv-cache.ts";
import { Workspace } from "../runtime/workspace.ts";
import { blockSize, rowSize, type QuantType } from "../format/quant.ts";

export type ModelConfig = {
  dim: number;
  hidden: number;
  nHeads: number;
  nKvHeads: number;
  nLayers: number;
  vocabSize: number;
  maxSeq: number;
  ropeFreqBase: number;
  headDim: number;
  kvDim: number;
};

export function makeConfig(
  p: Omit<ModelConfig, "headDim" | "kvDim"> & Partial<Pick<ModelConfig, "headDim" | "kvDim">>,
): ModelConfig {
  const headDim = p.headDim ?? p.dim / p.nHeads;
  return { ...p, headDim, kvDim: p.kvDim ?? p.nKvHeads * headDim };
}

/** A weight is either a plain float view or quantised bytes plus its type. */
export type QuantWeight =
  | { kind: "f32"; data: Float32Array }
  | { kind: "quant"; raw: Uint8Array; qtype: QuantType };

export type LayerWeights = {
  attnNorm: Float32Array;
  ffnNorm: Float32Array;
  attnQ: QuantWeight;
  attnK: QuantWeight;
  attnV: QuantWeight;
  attnOut: QuantWeight;
  ffnGate: QuantWeight;
  ffnUp: QuantWeight;
  ffnDown: QuantWeight;
};

export type ModelWeights = {
  tokenEmbd: { raw: Uint8Array; qtype: QuantType };
  outputNorm: Float32Array;
  output: QuantWeight;
  layers: LayerWeights[];
};

export class LlamaModel {
  readonly cfg: ModelConfig;
  readonly weights: ModelWeights;
  readonly arena: Arena;
  readonly cache: KVCache;
  readonly ws: Workspace;
  readonly rope: RopeTables;

  constructor(cfg: ModelConfig, weights: ModelWeights, arena?: Arena) {
    this.cfg = cfg;
    this.weights = weights;
    this.arena = arena ?? new Arena(LlamaModel.arenaBytes(cfg));
    this.cache = new KVCache(this.arena, cfg.nLayers, cfg.maxSeq, cfg.kvDim);
    this.ws = new Workspace(this.arena, cfg.dim, cfg.hidden, cfg.kvDim, cfg.vocabSize);
    this.rope = new RopeTables(cfg.maxSeq, cfg.headDim, cfg.ropeFreqBase);
  }

  /** Exactly how much the arena needs — computed, not guessed. */
  static arenaBytes(cfg: ModelConfig): number {
    const kv = 2 * cfg.nLayers * cfg.maxSeq * cfg.kvDim * 4;
    const ws = (4 * cfg.dim + 2 * cfg.kvDim + 2 * cfg.hidden + cfg.vocabSize + 512) * 4;
    const slack = 64 * 1024; // alignment padding, one page of headroom
    return kv + ws + slack;
  }

  get kvBytes(): number {
    return this.cache.bytes;
  }

  reset(): void {
    this.cache.reset();
    this.ws.x.fill(0);
  }

  /** One token in, one row of logits out. */
  forward(token: number, pos: number): Float32Array {
    const { cfg, ws, weights } = this;

    embedInto(
      ws.x,
      weights.tokenEmbd.raw,
      token,
      cfg.dim,
      weights.tokenEmbd.qtype,
      ws.qScratch.subarray(0, blockSize(weights.tokenEmbd.qtype)),
    );

    for (let l = 0; l < cfg.nLayers; l++) {
      forwardBlock(l, pos, cfg, weights.layers[l], ws, this.cache, this.rope);
    }

    rmsnorm(ws.xb, ws.x, weights.outputNorm);
    ws.matmul(ws.logits, ws.xb, weights.output, cfg.dim, cfg.vocabSize);
    return ws.logits;
  }
}

/** Bytes a quantised tensor of `n` weights occupies — used by the loader. */
export function weightBytes(qtype: QuantType, n: number): number {
  return rowSize(qtype, n);
}

model 打印的是这个页面真正加载到的配置,从 GGUF 头里读回来,而不是某个常量。

试一下
$

缺席的东西才是重点

没有 batch 维、没有块管理器、没有调度器、没有连续批处理。S06 以及 S09–S11 全部缺席,因为「一个用户、一个标签页」没有给它们任何可利用的东西。推理引擎的体量由并发需求决定,与模型无关。

继续学习

第 11–13、17 步 —— 分词器、采样器、语法、循环

示意图这个循环,以及它为什么必须让出
这个循环,以及它为什么必须让出forward(token, pos)采样把文本追加到 DOMawait —— 交还控制权浏览器重绘,按钮仍然可点同步跑完 200 个 token 会冻结整个标签页:不重绘、不能滚动、无法取消。每个 token 之后让出控制权,正是 S19 那套取消机制在浏览器里的对应物。
同步跑完 200 个 token 会冻结标签页。每个 token 之后让出,才能让页面持续重绘、停止按钮保持可用。

tokenizer/bpe.ts

SentencePiece BPE:每个 token 带一个 score,分数最高者胜,这与 S02 那个按 rank 合并的字节级 BPE 不同。空格变成 并在开头补一个;未知字符退回 <0xHH> 字节 token。

src/lib/minipicolm/tokenizer/bpe.tstypescript
/**
 * mini-picoLM — step 11: the BPE tokenizer
 * ========================================
 *
 * SentencePiece-style BPE, the family Llama GGUF files carry. Every token has a
 * **score**, and the merge with the highest score wins — not the lowest rank,
 * which is the GPT/tiktoken convention.
 *
 * Three details that are easy to get wrong and hard to notice:
 *
 *   * spaces become `▁` (U+2581) and one is *prepended*, so "Once" and " Once"
 *     tokenize identically;
 *   * anything not in the vocabulary falls back to a `<0xHH>` byte token, which
 *     is what makes the vocabulary total;
 *   * detokenization has to be **incremental and byte-aware**. A multi-byte
 *     character can span several tokens, so emitting each token's bytes as they
 *     arrive produces replacement characters in the UI. `StreamDecoder` holds
 *     partial sequences back.
 */

export const SPACE = "▁";
const BYTE_TOKEN = /^<0x([0-9A-Fa-f]{2})>$/;

export class Tokenizer {
  readonly tokens: string[];
  readonly scores: Float32Array;
  readonly bosId: number;
  readonly eosId: number;
  private readonly lookup = new Map<string, number>();
  private readonly byteOf = new Map<number, number>();
  private readonly encoder = new TextEncoder();

  constructor(tokens: string[], scores: Float32Array, bosId = 1, eosId = 2) {
    this.tokens = tokens;
    this.scores = scores;
    this.bosId = bosId;
    this.eosId = eosId;
    for (let i = 0; i < tokens.length; i++) {
      if (!this.lookup.has(tokens[i])) this.lookup.set(tokens[i], i);
      const m = BYTE_TOKEN.exec(tokens[i]);
      if (m) this.byteOf.set(i, parseInt(m[1], 16));
    }
  }

  get vocabSize(): number {
    return this.tokens.length;
  }

  /** One token per character, with a byte fallback for anything unknown. */
  private seed(text: string): number[] {
    const out: number[] = [];
    for (const ch of text) {
      const direct = this.lookup.get(ch);
      if (direct !== undefined) {
        out.push(direct);
        continue;
      }
      for (const b of this.encoder.encode(ch)) {
        const bt = this.lookup.get(`<0x${b.toString(16).toUpperCase().padStart(2, "0")}>`);
        if (bt !== undefined) out.push(bt);
      }
    }
    return out;
  }

  encode(text: string, addBos = true): number[] {
    const norm = SPACE + text.replaceAll(" ", SPACE);
    const parts = this.seed(norm);

    // Greedy merge. Quadratic in the number of pieces, which is fine: it runs
    // once per request on a short string.
    for (;;) {
      let bestScore = -Infinity;
      let bestIdx = -1;
      let bestId = -1;
      for (let i = 0; i < parts.length - 1; i++) {
        const merged = this.tokens[parts[i]] + this.tokens[parts[i + 1]];
        const id = this.lookup.get(merged);
        if (id !== undefined && this.scores[id] > bestScore) {
          bestScore = this.scores[id];
          bestIdx = i;
          bestId = id;
        }
      }
      if (bestIdx < 0) break;
      parts.splice(bestIdx, 2, bestId);
    }

    return addBos ? [this.bosId, ...parts] : parts;
  }

  /** The raw bytes a token contributes. */
  pieceBytes(id: number): Uint8Array {
    const b = this.byteOf.get(id);
    if (b !== undefined) return new Uint8Array([b]);
    return this.encoder.encode(this.tokens[id].replaceAll(SPACE, " "));
  }

  /** Human-readable text for a token — what the grammar masks against. */
  piece(id: number): string {
    const b = this.byteOf.get(id);
    if (b !== undefined) return String.fromCharCode(b);
    return this.tokens[id].replaceAll(SPACE, " ");
  }

  decode(ids: number[], stripLeadingSpace = true): string {
    let total = 0;
    const parts = ids.map((i) => {
      const p = this.pieceBytes(i);
      total += p.length;
      return p;
    });
    const buf = new Uint8Array(total);
    let o = 0;
    for (const p of parts) {
      buf.set(p, o);
      o += p.length;
    }
    const text = new TextDecoder().decode(buf);
    return stripLeadingSpace && text.startsWith(" ") ? text.slice(1) : text;
  }
}

/**
 * Emits text only once the pending bytes form a complete character.
 *
 * `TextDecoder` with `{ stream: true }` does exactly this natively — it buffers
 * an incomplete sequence internally and emits nothing until the continuation
 * bytes arrive. Using it is both shorter and more correct than hand-rolling.
 */
export class StreamDecoder {
  private readonly decoder = new TextDecoder("utf-8");
  private readonly tok: Tokenizer;

  constructor(tok: Tokenizer) {
    this.tok = tok;
  }

  push(id: number): string {
    return this.decoder.decode(this.tok.pieceBytes(id), { stream: true });
  }

  /** Flush anything still buffered at end of stream. */
  finish(): string {
    return this.decoder.decode(new Uint8Array(0));
  }
}

改一下文本再运行。这条命令会自己断言往返一致,所以一个丢信息的分词器会当场说出来。

试一下
$

runtime/sampler.ts

temperature 为 0 时贪心,否则 temperature → softmax → top-k → top-p。cutoff 里那个 >= 就是 S04 的 +1:跨过阈值的那个 token 要保留,否则当最高 token 概率已超过 top_p 时你会一个都不留,然后除以零。

src/lib/minipicolm/runtime/sampler.tstypescript
/**
 * mini-picoLM — step 12: sampling
 * ===============================
 *
 * Greedy at temperature 0, otherwise temperature → softmax → top-k → top-p.
 *
 * Two things carried over from S04 that matter in production and are usually
 * got wrong:
 *
 *   **The `+1`.** Top-p keeps the *smallest set whose cumulative mass reaches
 *   p*, which means the token that crosses the threshold is included. Drop it
 *   and a distribution whose top token already has p=0.95 keeps nothing at
 *   `top_p=0.9`, and the renormalisation divides by zero.
 *
 *   **The RNG is per-sampler, not global.** With a shared generator a request's
 *   output depends on what else was sampled first, which makes bug reports
 *   impossible to reproduce. This one is seeded and owns its state.
 */

/** mulberry32 — small, fast, and reproducible across engines. */
export function makeRng(seed: number): () => number {
  let a = seed >>> 0;
  return function next(): number {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export type SamplerOptions = {
  temperature?: number;
  topK?: number;
  topP?: number;
  seed?: number;
};

export class Sampler {
  temperature: number;
  topK: number;
  topP: number;
  private rng: () => number;
  private order: Int32Array | null = null;
  private probs: Float32Array | null = null;

  constructor(opts: SamplerOptions = {}) {
    this.temperature = opts.temperature ?? 0.8;
    this.topK = opts.topK ?? 0;
    this.topP = opts.topP ?? 0.9;
    this.rng = makeRng(opts.seed ?? 0);
  }

  reseed(seed: number): void {
    this.rng = makeRng(seed);
  }

  /** Number of tokens that survive the filters — what the simulator plots. */
  reachable(logits: Float32Array): number {
    if (this.temperature <= 0) return 1;
    const { probs, order } = this.prepare(logits);
    return this.cutoff(probs, order);
  }

  sample(logits: Float32Array): number {
    if (this.temperature <= 0) {
      let best = 0;
      for (let i = 1; i < logits.length; i++) if (logits[i] > logits[best]) best = i;
      return best;
    }

    const { probs, order } = this.prepare(logits);
    const keep = this.cutoff(probs, order);

    let mass = 0;
    for (let i = 0; i < keep; i++) mass += probs[order[i]];

    let r = this.rng() * mass;
    for (let i = 0; i < keep; i++) {
      r -= probs[order[i]];
      if (r <= 0) return order[i];
    }
    return order[keep - 1];
  }

  /** Softmax at temperature, plus indices sorted by descending probability. */
  private prepare(logits: Float32Array): { probs: Float32Array; order: Int32Array } {
    const n = logits.length;
    if (!this.probs || this.probs.length !== n) {
      this.probs = new Float32Array(n);
      this.order = new Int32Array(n);
    }
    const probs = this.probs;
    const order = this.order!;

    let max = -Infinity;
    for (let i = 0; i < n; i++) {
      const v = logits[i] / this.temperature;
      probs[i] = v;
      if (v > max) max = v;
    }
    let sum = 0;
    for (let i = 0; i < n; i++) {
      const e = Math.exp(probs[i] - max);
      probs[i] = e;
      sum += e;
    }
    const inv = 1 / sum;
    for (let i = 0; i < n; i++) {
      probs[i] *= inv;
      order[i] = i;
    }

    // A full sort is O(V log V) per token — 130k entries in a real vocabulary.
    // Production kernels use a radix-select on the threshold instead; this is
    // the honest simple version, and the cost is visible in the simulator.
    const idx = Array.from(order);
    idx.sort((a, b) => probs[b] - probs[a]);
    order.set(idx);
    return { probs, order };
  }

  /** How many of the sorted tokens survive top-k and top-p. */
  private cutoff(probs: Float32Array, order: Int32Array): number {
    const n = order.length;
    const limit = this.topK > 0 ? Math.min(this.topK, n) : n;
    if (this.topP >= 1) return limit;

    let cum = 0;
    for (let i = 0; i < limit; i++) {
      cum += probs[order[i]];
      // >= keeps the token that crosses the threshold. This is the `+1`.
      if (cum >= this.topP) return i + 1;
    }
    return limit;
  }
}

runtime/grammar.ts

一个针对 JSON 的下推自动机,被提升到 token 层面。容器栈正是它「下推」而非「有限状态」的原因:嵌套是无界的,没有任何固定状态集能记住下一个 } 是否合法。

src/lib/minipicolm/runtime/grammar.tstypescript
/**
 * mini-picoLM — step 17: grammar constraints
 * ==========================================
 *
 * A pushdown automaton over JSON, lifted to the token level: a token is legal
 * only if *every* character it would emit keeps the machine alive.
 *
 * The container stack is what makes this pushdown rather than finite-state.
 * JSON nesting is unbounded, so no fixed set of states can remember whether the
 * next `}` is legal — a stack can.
 *
 * Illegal tokens are set to -Infinity before the sampler runs, so invalid
 * output is not discouraged, it is unreachable at any temperature.
 *
 * What this does *not* guarantee is completion. A token budget can cut the
 * object off mid-structure, and a truncated `{"name": "ad` is as unusable to
 * the caller as invalid JSON. Check `accepting()` before you return.
 */

const WS = " \t\n\r";
const DIGITS = "0123456789";
const LITERALS: Record<string, string> = { t: "rue", f: "alse", n: "ull" };

export type GrammarState = {
  node: string;
  stack: readonly string[];
  /** Characters a literal still owes, or "d" once a number has seen a digit. */
  buf: string;
};

export class JsonGrammar {
  private readonly maxDepth: number;

  constructor(maxDepth = 16) {
    this.maxDepth = maxDepth;
  }

  start(): GrammarState {
    return { node: "value", stack: [], buf: "" };
  }

  step(s: GrammarState, ch: string): GrammarState | null {
    const { node, stack, buf } = s;

    switch (node) {
      case "value":
        if (WS.includes(ch)) return s;
        if (ch === "{")
          return stack.length < this.maxDepth
            ? { node: "objOpen", stack: [...stack, "o"], buf: "" }
            : null;
        if (ch === "[")
          return stack.length < this.maxDepth
            ? { node: "arrOpen", stack: [...stack, "a"], buf: "" }
            : null;
        if (ch === '"') return { node: "string", stack, buf: "" };
        if (ch === "-") return { node: "number", stack, buf: "" };
        if (DIGITS.includes(ch)) return { node: "number", stack, buf: "d" };
        if (ch in LITERALS) return { node: "literal", stack, buf: LITERALS[ch] };
        return null;

      case "literal":
        if (!buf || ch !== buf[0]) return null;
        return buf.length === 1
          ? { node: "after", stack, buf: "" }
          : { node: "literal", stack, buf: buf.slice(1) };

      case "number":
        if (DIGITS.includes(ch)) return { node: "number", stack, buf: "d" };
        if (".eE+-".includes(ch) && buf === "d") return { node: "number", stack, buf: "d" };
        return buf === "d" ? this.step({ node: "after", stack, buf: "" }, ch) : null;

      case "string":
        if (ch === "\\") return { node: "stringEsc", stack, buf: "" };
        if (ch === '"') return { node: "after", stack, buf: "" };
        return "\n\r".includes(ch) ? null : s;

      case "stringEsc":
        return '"\\/bfnrtu'.includes(ch) ? { node: "string", stack, buf: "" } : null;

      case "objOpen":
        if (WS.includes(ch)) return s;
        if (ch === '"') return { node: "key", stack, buf: "" };
        return ch === "}" ? this.close(stack, "o") : null;

      case "key":
        if (ch === '"') return { node: "keyEnd", stack, buf: "" };
        return "\n\r".includes(ch) ? null : s;

      case "keyEnd":
        if (WS.includes(ch)) return s;
        return ch === ":" ? { node: "value", stack, buf: "" } : null;

      case "commaObj":
        if (WS.includes(ch)) return s;
        return ch === '"' ? { node: "key", stack, buf: "" } : null;

      case "arrOpen":
        if (WS.includes(ch)) return s;
        if (ch === "]") return this.close(stack, "a");
        return this.step({ node: "value", stack, buf: "" }, ch);

      case "after": {
        if (WS.includes(ch)) return s;
        if (stack.length === 0) return null;
        const top = stack[stack.length - 1];
        if (ch === ",")
          return top === "o"
            ? { node: "commaObj", stack, buf: "" }
            : { node: "value", stack, buf: "" };
        if (ch === "}" && top === "o") return this.close(stack, "o");
        if (ch === "]" && top === "a") return this.close(stack, "a");
        return null;
      }

      default:
        return null;
    }
  }

  private close(stack: readonly string[], kind: string): GrammarState | null {
    if (!stack.length || stack[stack.length - 1] !== kind) return null;
    return { node: "after", stack: stack.slice(0, -1), buf: "" };
  }

  accepting(s: GrammarState | null): boolean {
    if (!s || s.stack.length) return false;
    return s.node === "after" || (s.node === "number" && s.buf === "d");
  }

  /** Feed a whole token piece. Null if any character leaves the grammar. */
  advance(s: GrammarState | null, text: string): GrammarState | null {
    let cur = s;
    for (const ch of text) {
      if (!cur) return null;
      cur = this.step(cur, ch);
    }
    return cur;
  }

  /**
   * Set every grammar-illegal logit to -Infinity, in place.
   *
   * Cost is O(vocab × token length) per step, which is why production engines
   * compile and cache these masks per state. Returns how many survived.
   */
  maskLogits(logits: Float32Array, s: GrammarState, pieces: string[]): number {
    let legal = 0;
    for (let i = 0; i < pieces.length; i++) {
      if (this.advance(s, pieces[i]) === null) logits[i] = -Infinity;
      else legal++;
    }
    return legal;
  }
}

自检里的 grammar 分组会拿这个自动机与 JSON.parse 在九个字符串上对拍,其中几个是特意做得「几乎合法」的。

试一下
$

runtime/generation.ts

异步生成器。它在每个 token 之后让出,好让事件循环重绘;调用方不再要下一个值,生成就停下来。

src/lib/minipicolm/runtime/generation.tstypescript
/**
 * mini-picoLM — step 13: generation
 * =================================
 *
 * Prefill, then the loop. After every other step in this list, it is still the
 * while-loop S01 opened with.
 *
 * The one thing that is genuinely different in a browser is that **you may not
 * block the main thread**. A synchronous loop over 200 tokens freezes the tab:
 * no repaint, no scrolling, no cancel button. So `generate` is an async
 * generator that yields after each token, which gives the event loop a chance
 * to paint and lets the caller stop simply by not asking for the next value.
 *
 * That is the browser's version of the cancellation story in S19 — and it is
 * why streaming is not a UI nicety here but a structural requirement.
 */

import type { LlamaModel } from "../model/llama.ts";
import type { Tokenizer } from "../tokenizer/bpe.ts";
import { StreamDecoder } from "../tokenizer/bpe.ts";
import type { Sampler } from "./sampler.ts";
import { JsonGrammar, type GrammarState } from "./grammar.ts";

export type GenerateOptions = {
  prompt: string;
  maxTokens?: number;
  json?: boolean;
  /** Yield to the event loop every N tokens. 1 keeps the UI perfectly live. */
  yieldEvery?: number;
  signal?: { aborted: boolean };
};

export type TokenEvent = {
  token: number;
  text: string;
  index: number;
  position: number;
  /** ms spent inside forward() for this token */
  ms: number;
  legalTokens?: number;
};

export type GenerateResult = {
  text: string;
  promptTokens: number;
  outputTokens: number;
  prefillMs: number;
  decodeMs: number;
  tokensPerSecond: number;
  stopReason: "eos" | "maxTokens" | "contextFull" | "aborted" | "grammarComplete";
  grammarValid?: boolean;
};

const now = (): number =>
  typeof performance !== "undefined" ? performance.now() : Date.now();

/** Hand control back so the browser can paint. A no-op cost in Node. */
const breathe = (): Promise<void> =>
  new Promise((resolve) => {
    setTimeout(resolve, 0);
  });

export async function* generate(
  model: LlamaModel,
  tok: Tokenizer,
  sampler: Sampler,
  opts: GenerateOptions,
): AsyncGenerator<TokenEvent, GenerateResult, void> {
  const maxTokens = opts.maxTokens ?? 64;
  const yieldEvery = opts.yieldEvery ?? 1;

  const ids = tok.encode(opts.prompt, true);
  const dec = new StreamDecoder(tok);

  const grammar = opts.json ? new JsonGrammar() : null;
  let gstate: GrammarState | null = grammar ? grammar.start() : null;
  const pieces = grammar ? tok.tokens.map((_, i) => tok.piece(i)) : [];

  /* ---- prefill: one position at a time, exactly like decode ---- */
  const t0 = now();
  let logits = model.ws.logits;
  for (let i = 0; i < ids.length; i++) {
    logits = model.forward(ids[i], i);
  }
  const prefillMs = now() - t0;

  let pos = ids.length;
  let produced = 0;
  let out = "";
  let stopReason: GenerateResult["stopReason"] = "maxTokens";
  const decodeStart = now();

  while (produced < maxTokens) {
    if (opts.signal?.aborted) {
      stopReason = "aborted";
      break;
    }
    if (pos >= model.cfg.maxSeq) {
      stopReason = "contextFull";
      break;
    }

    let legal: number | undefined;
    if (grammar && gstate) {
      legal = grammar.maskLogits(logits, gstate, pieces);
      if (legal === 0) {
        stopReason = "grammarComplete";
        break;
      }
    }

    const next = sampler.sample(logits);
    if (next === tok.eosId) {
      stopReason = "eos";
      break;
    }

    if (grammar && gstate) {
      const advanced = grammar.advance(gstate, pieces[next]);
      if (advanced) gstate = advanced;
    }

    const text = dec.push(next);
    out += text;

    const tf = now();
    logits = model.forward(next, pos);
    const ms = now() - tf;

    yield { token: next, text, index: produced, position: pos, ms, legalTokens: legal };

    pos++;
    produced++;

    if (grammar && grammar.accepting(gstate)) {
      stopReason = "grammarComplete";
      break;
    }
    if (produced % yieldEvery === 0) await breathe();
  }

  out += dec.finish();
  const decodeMs = now() - decodeStart;

  return {
    text: out,
    promptTokens: ids.length,
    outputTokens: produced,
    prefillMs,
    decodeMs,
    tokensPerSecond: decodeMs > 0 ? (produced / decodeMs) * 1000 : 0,
    stopReason,
    grammarValid: grammar ? grammar.accepting(gstate) : undefined,
  };
}

/** Convenience wrapper for callers that just want the string. */
export async function generateText(
  model: LlamaModel,
  tok: Tokenizer,
  sampler: Sampler,
  opts: GenerateOptions,
): Promise<GenerateResult> {
  const it = generate(model, tok, sampler, opts);
  for (;;) {
    const r = await it.next();
    if (r.done) return r.value;
  }
}

流式输出,就在此刻。加上 --json 可以约束输出,命令会对拿回来的结果跑一遍 JSON.parse

试一下
$

掩码保证「合法」,不保证「完整」

语法掩码让畸形 JSON 不可达,但它不会让模型收尾。token 预算可能在结构中途把对象切断,而一个被截断的 {"name": "ad 对调用方来说和非法 JSON 一样没法用。generate 因此把 grammarValidstopReason 分开报告。

第 8 步

第 14 步 —— GGUF 与入口

在 C 里,加载模型就是 mmap。浏览器里没有 mmap,但来自文件输入或 fetchArrayBuffer 扮演了同样的角色:一整块区域,而张量就是已知偏移处的一个 subarray。那些 subarray 调用就是 picoLM 指针算术的对应物,而且同样免费。

format/gguf.ts

读取器与写入器。写入器的存在,是为了让页面能自己合成一个合法模型、在零下载的情况下把读取器跑一遍。于是加载器每次运行都被测试,而不是只有当手边正好有个 640 MB 文件时才被测试。

src/lib/minipicolm/format/gguf.tstypescript
/**
 * mini-picoLM — step 14: the GGUF reader
 * ======================================
 *
 * Parse the container, then hand out `Uint8Array` views onto the tensor data.
 * Nothing is copied and nothing is dequantised at load time.
 *
 * In C this is `mmap`. In a browser there is no mmap — but an `ArrayBuffer`
 * from `File.arrayBuffer()` or a streamed `fetch` plays the same role: one
 * region, and a tensor is a subarray at a known offset. The `subarray` calls
 * below are the equivalent of picoLM's pointer arithmetic, and they are equally
 * free.
 *
 * The writer exists so the S22 simulator can synthesise a valid model in the
 * page and exercise this reader with no download.
 */

import { GGUF_TYPE, rowSize, type QuantType } from "./quant.ts";

export const GGUF_MAGIC = 0x46554747; // "GGUF"
export const DEFAULT_ALIGNMENT = 32;

const U8 = 0,
  I8 = 1,
  U16 = 2,
  I16 = 3,
  U32 = 4,
  I32 = 5,
  F32 = 6,
  BOOL = 7,
  STRING = 8,
  ARRAY = 9,
  U64 = 10,
  I64 = 11,
  F64 = 12;

export type MetaValue = number | boolean | string | MetaValue[];

export type TensorInfo = {
  name: string;
  dims: number[];
  qtype: QuantType;
  offset: number;
  nElements: number;
  nBytes: number;
};

class Reader {
  pos = 0;
  readonly dv: DataView;
  readonly bytes: Uint8Array;

  constructor(dv: DataView, bytes: Uint8Array) {
    this.dv = dv;
    this.bytes = bytes;
  }

  u32(): number {
    const v = this.dv.getUint32(this.pos, true);
    this.pos += 4;
    return v;
  }
  i32(): number {
    const v = this.dv.getInt32(this.pos, true);
    this.pos += 4;
    return v;
  }
  /** GGUF counts are u64; JS numbers hold them exactly below 2^53. */
  u64(): number {
    const lo = this.dv.getUint32(this.pos, true);
    const hi = this.dv.getUint32(this.pos + 4, true);
    this.pos += 8;
    if (hi > 0x1fffff) throw new RangeError("GGUF value exceeds Number.MAX_SAFE_INTEGER");
    return hi * 2 ** 32 + lo;
  }
  str(): string {
    const n = this.u64();
    const s = new TextDecoder().decode(this.bytes.subarray(this.pos, this.pos + n));
    this.pos += n;
    return s;
  }
  value(t: number): MetaValue {
    switch (t) {
      case U8: return this.bytes[this.pos++];
      case I8: return this.dv.getInt8(this.pos++);
      case U16: { const v = this.dv.getUint16(this.pos, true); this.pos += 2; return v; }
      case I16: { const v = this.dv.getInt16(this.pos, true); this.pos += 2; return v; }
      case U32: return this.u32();
      case I32: return this.i32();
      case F32: { const v = this.dv.getFloat32(this.pos, true); this.pos += 4; return v; }
      case F64: { const v = this.dv.getFloat64(this.pos, true); this.pos += 8; return v; }
      case BOOL: return this.bytes[this.pos++] !== 0;
      case U64: return this.u64();
      case I64: return this.u64();
      case STRING: return this.str();
      case ARRAY: {
        const at = this.u32();
        const n = this.u64();
        const out: MetaValue[] = new Array(n);
        for (let i = 0; i < n; i++) out[i] = this.value(at);
        return out;
      }
      default:
        throw new Error(`unknown GGUF metadata type ${t}`);
    }
  }
}

export class GGUFFile {
  readonly metadata = new Map<string, MetaValue>();
  readonly tensors = new Map<string, TensorInfo>();
  readonly dataStart: number;
  private readonly bytes: Uint8Array;

  constructor(buffer: ArrayBuffer) {
    this.bytes = new Uint8Array(buffer);
    const r = new Reader(new DataView(buffer), this.bytes);

    if (r.u32() !== GGUF_MAGIC) {
      throw new Error("not a GGUF file (bad magic) — is this really a .gguf?");
    }
    const version = r.u32();
    if (version < 2) throw new Error(`GGUF version ${version} is too old; need >= 2`);

    const nTensors = r.u64();
    const nKv = r.u64();

    for (let i = 0; i < nKv; i++) {
      const key = r.str();
      this.metadata.set(key, r.value(r.u32()));
    }

    const infos: TensorInfo[] = [];
    for (let i = 0; i < nTensors; i++) {
      const name = r.str();
      const nDims = r.u32();
      const dims: number[] = [];
      for (let d = 0; d < nDims; d++) dims.push(r.u64());
      const raw = r.u32();
      const qtype = GGUF_TYPE[raw];
      const offset = r.u64();
      if (!qtype) {
        throw new Error(
          `tensor "${name}" uses GGUF type ${raw}, which mini-picoLM does not read. ` +
            `Supported: ${Object.values(GGUF_TYPE).join(", ")}.`,
        );
      }
      const nElements = dims.reduce((a, b) => a * b, 1);
      infos.push({ name, dims, qtype, offset, nElements, nBytes: rowSize(qtype, nElements) });
    }

    const align = Number(this.metadata.get("general.alignment") ?? DEFAULT_ALIGNMENT);
    this.dataStart = Math.ceil(r.pos / align) * align;
    for (const t of infos) {
      if (this.dataStart + t.offset + t.nBytes > this.bytes.length) {
        throw new Error(`tensor "${t.name}" runs past the end of the file`);
      }
      this.tensors.set(t.name, t);
    }
  }

  has(name: string): boolean {
    return this.tensors.has(name);
  }

  info(name: string): TensorInfo {
    const t = this.tensors.get(name);
    if (!t) throw new Error(`no tensor named "${name}" in this GGUF file`);
    return t;
  }

  /** The tensor's bytes, as a view. No copy — this is the mmap equivalent. */
  raw(name: string): Uint8Array {
    const t = this.info(name);
    return this.bytes.subarray(this.dataStart + t.offset, this.dataStart + t.offset + t.nBytes);
  }

  num(key: string, fallback?: number): number {
    const v = this.metadata.get(key);
    if (typeof v === "number") return v;
    if (fallback !== undefined) return fallback;
    throw new Error(`GGUF metadata is missing "${key}"`);
  }

  str(key: string, fallback?: string): string {
    const v = this.metadata.get(key);
    if (typeof v === "string") return v;
    if (fallback !== undefined) return fallback;
    throw new Error(`GGUF metadata is missing "${key}"`);
  }

  get totalTensorBytes(): number {
    let n = 0;
    for (const t of this.tensors.values()) n += t.nBytes;
    return n;
  }
}

/* ------------------------------------------------------------- writing */

type WriteTensor = { data: Float32Array; dims: number[] };

function pushString(out: number[], s: string): void {
  const b = new TextEncoder().encode(s);
  pushU64(out, b.length);
  for (const x of b) out.push(x);
}

function pushU32(out: number[], v: number): void {
  out.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
}

function pushU64(out: number[], v: number): void {
  const lo = v >>> 0;
  const hi = Math.floor(v / 2 ** 32);
  pushU32(out, lo);
  pushU32(out, hi);
}

function pushValue(out: number[], v: MetaValue, tagged = true): void {
  if (typeof v === "boolean") {
    if (tagged) pushU32(out, BOOL);
    out.push(v ? 1 : 0);
  } else if (typeof v === "number") {
    if (Number.isInteger(v) && v >= 0 && v < 2 ** 31) {
      if (tagged) pushU32(out, U32);
      pushU32(out, v);
    } else {
      if (tagged) pushU32(out, F32);
      const b = new Uint8Array(4);
      new DataView(b.buffer).setFloat32(0, v, true);
      out.push(...b);
    }
  } else if (typeof v === "string") {
    if (tagged) pushU32(out, STRING);
    pushString(out, v);
  } else {
    if (tagged) pushU32(out, ARRAY);
    const inner = v.length ? v[0] : "";
    const at =
      typeof inner === "string"
        ? STRING
        : typeof inner === "boolean"
          ? BOOL
          : Number.isInteger(inner as number) && (inner as number) >= 0
            ? U32
            : F32;
    pushU32(out, at);
    pushU64(out, v.length);
    for (const item of v) pushValue(out, item, false);
  }
}

/**
 * Write a valid GGUF file into a fresh ArrayBuffer.
 *
 * Only F32 tensors are written — the point is to exercise the container and the
 * loader, not to reimplement a quantiser. The quantised paths are checked
 * against hand-built blocks in `selftest.ts`.
 */
export function writeGGUF(
  metadata: Record<string, MetaValue>,
  tensors: Record<string, WriteTensor>,
): ArrayBuffer {
  const align = Number(metadata["general.alignment"] ?? DEFAULT_ALIGNMENT);
  const names = Object.keys(tensors);

  const head: number[] = [];
  pushU32(head, GGUF_MAGIC);
  pushU32(head, 3);
  pushU64(head, names.length);
  pushU64(head, Object.keys(metadata).length);
  for (const [k, v] of Object.entries(metadata)) {
    pushString(head, k);
    pushValue(head, v);
  }

  let offset = 0;
  const blobs: { bytes: Uint8Array; padded: number }[] = [];
  for (const name of names) {
    const { data, dims } = tensors[name];
    pushString(head, name);
    pushU32(head, dims.length);
    // GGUF dims are fastest-varying first — the reverse of a row-major shape.
    for (const d of [...dims].reverse()) pushU64(head, d);
    pushU32(head, 0); // F32
    pushU64(head, offset);

    const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
    const padded = Math.ceil(bytes.length / align) * align;
    blobs.push({ bytes, padded });
    offset += padded;
  }

  const headerLen = Math.ceil(head.length / align) * align;
  const total = headerLen + offset;
  const buf = new ArrayBuffer(total);
  const view = new Uint8Array(buf);
  view.set(head, 0);
  let o = headerLen;
  for (const b of blobs) {
    view.set(b.bytes, o);
    o += b.padded;
  }
  return buf;
}

main.ts

loadFromGGUF 用于真实模型,createToyModel 用于内存里构建的模型。玩具路径是穿过文件格式而不是绕过它,所以它顺带也把读取器跑了一遍。

src/lib/minipicolm/main.tstypescript
/**
 * mini-picoLM — the public entry point
 * ====================================
 *
 * Two ways in:
 *
 *   `loadFromGGUF(buffer)` — a real model. Feed it an `ArrayBuffer` from a file
 *   input, a `fetch`, or drag-and-drop. Nothing is copied; the tensors stay
 *   quantised and are read in place.
 *
 *   `createToyModel()` — a small, randomly-weighted model built in memory,
 *   through a genuine GGUF file this module writes itself. The output is noise,
 *   but every code path is the real one, which is what makes the whole engine
 *   testable in a browser with no download.
 */

import { Arena } from "./memory/arena.ts";
import { GGUFFile, writeGGUF } from "./format/gguf.ts";
import { dequantizeRow, rowSize } from "./format/quant.ts";
import {
  LlamaModel,
  makeConfig,
  type LayerWeights,
  type ModelConfig,
  type ModelWeights,
  type QuantWeight,
} from "./model/llama.ts";
import { Tokenizer } from "./tokenizer/bpe.ts";
import { Sampler, type SamplerOptions } from "./runtime/sampler.ts";

export type Engine = {
  model: LlamaModel;
  tokenizer: Tokenizer;
  sampler: Sampler;
  config: ModelConfig;
  /** Bytes of weights, still quantised, referenced not copied. */
  weightBytes: number;
  /** Bytes the arena actually reserved (KV cache + workspace). */
  arenaBytes: number;
  source: string;
};

function asWeight(f: GGUFFile, name: string): QuantWeight {
  const info = f.info(name);
  if (info.qtype !== "F32") return { kind: "quant", raw: f.raw(name), qtype: info.qtype };
  const copy = f.raw(name).slice(); // copy: the view is not guaranteed 4-aligned
  return { kind: "f32", data: new Float32Array(copy.buffer, 0, info.nElements) };
}

export type LoadOptions = {
  maxSeq?: number;
  sampler?: SamplerOptions;
};

export function loadFromGGUF(buffer: ArrayBuffer, opts: LoadOptions = {}): Engine {
  const f = new GGUFFile(buffer);
  const arch = f.str("general.architecture", "llama");
  const p = `${arch}.`;

  const dim = f.num(`${p}embedding_length`);
  const nHeads = f.num(`${p}attention.head_count`);
  const trained = f.num(`${p}context_length`, 2048);
  const tokensMeta = f.metadata.get("tokenizer.ggml.tokens");
  if (!Array.isArray(tokensMeta)) {
    throw new Error("this GGUF file carries no tokenizer.ggml.tokens array");
  }
  const tokens = tokensMeta as string[];

  const cfg = makeConfig({
    dim,
    hidden: f.num(`${p}feed_forward_length`),
    nHeads,
    nKvHeads: f.num(`${p}attention.head_count_kv`, nHeads),
    nLayers: f.num(`${p}block_count`),
    vocabSize: f.num(`${p}vocab_size`, tokens.length),
    maxSeq: Math.min(opts.maxSeq ?? trained, trained),
    ropeFreqBase: f.num(`${p}rope.freq_base`, 10000),
  });

  const layers: LayerWeights[] = [];
  for (let l = 0; l < cfg.nLayers; l++) {
    layers.push({
      attnNorm: dequantToFloats(f, `blk.${l}.attn_norm.weight`),
      ffnNorm: dequantToFloats(f, `blk.${l}.ffn_norm.weight`),
      attnQ: asWeight(f, `blk.${l}.attn_q.weight`),
      attnK: asWeight(f, `blk.${l}.attn_k.weight`),
      attnV: asWeight(f, `blk.${l}.attn_v.weight`),
      attnOut: asWeight(f, `blk.${l}.attn_output.weight`),
      ffnGate: asWeight(f, `blk.${l}.ffn_gate.weight`),
      ffnUp: asWeight(f, `blk.${l}.ffn_up.weight`),
      ffnDown: asWeight(f, `blk.${l}.ffn_down.weight`),
    });
  }

  const embdInfo = f.info("token_embd.weight");
  const weights: ModelWeights = {
    tokenEmbd: { raw: f.raw("token_embd.weight"), qtype: embdInfo.qtype },
    outputNorm: dequantToFloats(f, "output_norm.weight"),
    // Many models tie the output projection to the embedding table.
    output: asWeight(f, f.has("output.weight") ? "output.weight" : "token_embd.weight"),
    layers,
  };

  const scoresMeta = f.metadata.get("tokenizer.ggml.scores");
  const scores = new Float32Array(tokens.length);
  if (Array.isArray(scoresMeta)) {
    for (let i = 0; i < tokens.length; i++) scores[i] = Number(scoresMeta[i] ?? 0);
  }
  const tokenizer = new Tokenizer(
    tokens,
    scores,
    f.num("tokenizer.ggml.bos_token_id", 1),
    f.num("tokenizer.ggml.eos_token_id", 2),
  );

  const arena = new Arena(LlamaModel.arenaBytes(cfg));
  const model = new LlamaModel(cfg, weights, arena);

  return {
    model,
    tokenizer,
    sampler: new Sampler(opts.sampler),
    config: cfg,
    weightBytes: f.totalTensorBytes,
    arenaBytes: arena.capacity,
    source: `${arch} · ${f.tensors.size} tensors`,
  };
}

/**
 * Dequantise a whole (small) tensor to floats. Used only for norm weights,
 * which are read every layer and are a few kilobytes in total.
 */
function dequantToFloats(f: GGUFFile, name: string): Float32Array {
  const info = f.info(name);
  const raw = f.raw(name);
  if (info.qtype === "F32") {
    // `raw` is a view at an arbitrary byte offset, and Float32Array can only
    // alias a 4-aligned one — so copy these few hundred bytes rather than
    // depend on the file's padding.
    const copy = raw.slice();
    return new Float32Array(copy.buffer, 0, info.nElements);
  }
  return dequantizeRow(raw, info.nElements, info.qtype);
}

/* --------------------------------------------------- the in-memory model */

export type ToyOptions = {
  nLayers?: number;
  dim?: number;
  nHeads?: number;
  nKvHeads?: number;
  hidden?: number;
  maxSeq?: number;
  seed?: number;
};

/** A deterministic PRNG so the toy model is identical everywhere. */
function toyRng(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return (((t ^ (t >>> 14)) >>> 0) / 4294967296 - 0.5) * 2;
  };
}

export const TOY_VOCAB: string[] = [
  "<unk>",
  "<s>",
  "</s>",
  ...Array.from({ length: 256 }, (_, b) => `<0x${b.toString(16).toUpperCase().padStart(2, "0")}>`),
  "▁",
  "▁the",
  "▁cat",
  "▁sat",
  "▁on",
  "▁mat",
  "▁and",
  "a",
  "b",
  "c",
  "{",
  "}",
  '"',
  ":",
  ",",
  "[",
  "]",
  "▁1",
  "▁2",
  "true",
  "false",
  "▁name",
  "▁age",
  "▁ok",
];

/**
 * Build a small model, serialise it as a real GGUF file, and load it back
 * through the real reader.
 *
 * Going through the file format rather than constructing the weights directly
 * is the point: it means the browser demo exercises `format/gguf.ts` on every
 * run, so the loader cannot rot.
 */
export function createToyModel(
  opts: ToyOptions = {},
): Engine & { ggufBytes: number; ggufBuffer: ArrayBuffer } {
  const nLayers = opts.nLayers ?? 2;
  const dim = opts.dim ?? 64;
  const nHeads = opts.nHeads ?? 4;
  const nKvHeads = opts.nKvHeads ?? 2;
  const hidden = opts.hidden ?? 128;
  const maxSeq = opts.maxSeq ?? 128;
  const vocab = TOY_VOCAB.length;
  const headDim = dim / nHeads;
  const kvDim = nKvHeads * headDim;

  const rnd = toyRng(opts.seed ?? 1);
  const mk = (n: number, scale = 0.08) => {
    const a = new Float32Array(n);
    for (let i = 0; i < n; i++) a[i] = rnd() * scale;
    return a;
  };
  const ones = (n: number) => new Float32Array(n).fill(1);

  const tensors: Record<string, { data: Float32Array; dims: number[] }> = {
    "token_embd.weight": { data: mk(vocab * dim), dims: [vocab, dim] },
    "output_norm.weight": { data: ones(dim), dims: [dim] },
    "output.weight": { data: mk(vocab * dim), dims: [vocab, dim] },
  };
  for (let l = 0; l < nLayers; l++) {
    tensors[`blk.${l}.attn_norm.weight`] = { data: ones(dim), dims: [dim] };
    tensors[`blk.${l}.ffn_norm.weight`] = { data: ones(dim), dims: [dim] };
    tensors[`blk.${l}.attn_q.weight`] = { data: mk(dim * dim), dims: [dim, dim] };
    tensors[`blk.${l}.attn_k.weight`] = { data: mk(kvDim * dim), dims: [kvDim, dim] };
    tensors[`blk.${l}.attn_v.weight`] = { data: mk(kvDim * dim), dims: [kvDim, dim] };
    tensors[`blk.${l}.attn_output.weight`] = { data: mk(dim * dim), dims: [dim, dim] };
    tensors[`blk.${l}.ffn_gate.weight`] = { data: mk(hidden * dim), dims: [hidden, dim] };
    tensors[`blk.${l}.ffn_up.weight`] = { data: mk(hidden * dim), dims: [hidden, dim] };
    tensors[`blk.${l}.ffn_down.weight`] = { data: mk(dim * hidden), dims: [dim, hidden] };
  }

  const buffer = writeGGUF(
    {
      "general.architecture": "llama",
      "general.alignment": 32,
      "llama.embedding_length": dim,
      "llama.feed_forward_length": hidden,
      "llama.attention.head_count": nHeads,
      "llama.attention.head_count_kv": nKvHeads,
      "llama.block_count": nLayers,
      "llama.context_length": maxSeq,
      "llama.vocab_size": vocab,
      "llama.rope.freq_base": 10000,
      "tokenizer.ggml.tokens": TOY_VOCAB,
      "tokenizer.ggml.scores": TOY_VOCAB.map((_, i) => -i),
      "tokenizer.ggml.bos_token_id": 1,
      "tokenizer.ggml.eos_token_id": 2,
    },
    tensors,
  );

  const engine = loadFromGGUF(buffer, { maxSeq, sampler: { temperature: 0.9, topP: 0.9 } });
  return {
    ...engine,
    source: "toy model (in-memory GGUF)",
    ggufBytes: buffer.byteLength,
    ggufBuffer: buffer,
  };
}

export { rowSize };
export * from "./runtime/generation.ts";
export { Sampler } from "./runtime/sampler.ts";
export { Tokenizer, StreamDecoder } from "./tokenizer/bpe.ts";
export { JsonGrammar } from "./runtime/grammar.ts";
export { Arena } from "./memory/arena.ts";
export { GGUFFile, writeGGUF } from "./format/gguf.ts";
export { LlamaModel } from "./model/llama.ts";
export { benchmark, probeWebGPU, hasWebGPU } from "./tensor/webgpu.ts";

gguf 会解析这个页面启动时写出的那份文件,并列出它读到的东西:magic、对齐方式,以及张量索引。

试一下
$

第 9 步

第 18 步 —— WebGPU

tensor/webgpu.ts

一个真正的 compute shader:每个输出行一个 workgroup,64 个 lane 跨步扫过它,然后做树形归约。一切都做了特性检测,因为 navigator.gpu 在 SSR 时不存在,在若干已发布浏览器里不存在,在 flag 后面也不存在。

src/lib/minipicolm/tensor/webgpu.tstypescript
/**
 * mini-picoLM — step 18: WebGPU acceleration
 * ==========================================
 *
 * A real compute shader for the operation that dominates decode: matrix-vector
 * multiply. One workgroup per output row, a 64-lane tree reduction inside it.
 *
 * **Why this is not inside the token loop.** Reading a result back from the GPU
 * is asynchronous — `mapAsync` returns a promise — while `Workspace.matmul` is
 * synchronous and called seven times per layer. Making the forward pass async
 * would mean awaiting dozens of GPU round trips per token, and a round trip
 * costs more than the matmul saves at these sizes. So the CPU path stays the
 * reference, and the GPU path is exposed as an explicit async call used for
 * prefill-shaped work and for the benchmark the S22 simulator runs.
 *
 * That is not a cop-out, it is the actual shape of the problem: a decode step is
 * memory-bound and tiny, and the fixed cost of dispatch is the thing you are
 * fighting. It is the same argument as S13's launch overhead, arriving in a
 * browser. `benchmark()` measures the crossover instead of asserting it.
 *
 * Everything is feature-detected. `navigator.gpu` is absent during SSR, absent
 * in several shipping browsers, and absent behind flags. The engine runs
 * identically without it.
 */

import type { QuantWeight } from "../model/llama.ts";
import { dequantizeRow } from "../format/quant.ts";
import { matmul } from "./matmul.ts";

/* ---------------------------------------------------------------- types */
/* WebGPU is not in every TS DOM lib yet, so declare the narrow surface we use
 * rather than taking a dependency on @webgpu/types. */

type GPUBufferT = {
  destroy(): void;
  mapAsync(mode: number): Promise<void>;
  getMappedRange(): ArrayBuffer;
  unmap(): void;
};
type GPUPassT = {
  setPipeline(p: unknown): void;
  setBindGroup(i: number, g: unknown): void;
  dispatchWorkgroups(x: number): void;
  end(): void;
};
type GPUEncoderT = {
  beginComputePass(): GPUPassT;
  copyBufferToBuffer(a: GPUBufferT, ao: number, b: GPUBufferT, bo: number, n: number): void;
  finish(): unknown;
};
type GPUPipelineT = { getBindGroupLayout(i: number): unknown };
type GPUDeviceT = {
  createBuffer(d: { size: number; usage: number }): GPUBufferT;
  createShaderModule(d: { code: string }): unknown;
  createComputePipeline(d: unknown): GPUPipelineT;
  createBindGroup(d: unknown): unknown;
  createCommandEncoder(): GPUEncoderT;
  queue: {
    writeBuffer(b: GPUBufferT, o: number, d: ArrayBufferView): void;
    submit(c: unknown[]): void;
  };
  destroy(): void;
};
type GPUAdapterT = { requestDevice(): Promise<GPUDeviceT>; info?: { vendor?: string } };
type NavigatorGPU = { gpu?: { requestAdapter(): Promise<GPUAdapterT | null> } };

/** Spec-defined GPUBufferUsage bits — hardcoded so no global type is needed. */
const USAGE = {
  MAP_READ: 0x0001,
  COPY_SRC: 0x0004,
  COPY_DST: 0x0008,
  UNIFORM: 0x0040,
  STORAGE: 0x0080,
} as const;
const MAP_READ_MODE = 0x0001;

/* --------------------------------------------------------------- shader */

export const MATMUL_WGSL = /* wgsl */ `
struct Dims { n : u32, d : u32 };

@group(0) @binding(0) var<storage, read>       W : array<f32>;
@group(0) @binding(1) var<storage, read>       X : array<f32>;
@group(0) @binding(2) var<storage, read_write> Y : array<f32>;
@group(0) @binding(3) var<uniform>             dims : Dims;

var<workgroup> partial : array<f32, 64>;

// One workgroup per output row; 64 lanes cooperate on one dot product.
@compute @workgroup_size(64)
fn main(@builtin(workgroup_id) wg : vec3<u32>,
        @builtin(local_invocation_id) lid : vec3<u32>) {
  let row = wg.x;
  var acc = 0.0;

  if (row < dims.d) {
    var i = lid.x;
    loop {
      if (i >= dims.n) { break; }
      acc = acc + W[row * dims.n + i] * X[i];
      i = i + 64u;
    }
  }

  partial[lid.x] = acc;
  workgroupBarrier();

  var stride = 32u;
  loop {
    if (stride == 0u) { break; }
    if (lid.x < stride) { partial[lid.x] = partial[lid.x] + partial[lid.x + stride]; }
    workgroupBarrier();
    stride = stride / 2u;
  }

  if (lid.x == 0u && row < dims.d) { Y[row] = partial[0]; }
}
`;

/* ------------------------------------------------------------ detection */

export type GpuSupport =
  | { supported: true; adapter: string }
  | { supported: false; reason: string };

/** Cheap synchronous check — safe during render. */
export function hasWebGPU(): boolean {
  return typeof navigator !== "undefined" && "gpu" in navigator;
}

export async function probeWebGPU(): Promise<GpuSupport> {
  if (typeof navigator === "undefined") {
    return { supported: false, reason: "no navigator (server-side render)" };
  }
  const gpu = (navigator as unknown as NavigatorGPU).gpu;
  if (!gpu) {
    return { supported: false, reason: "navigator.gpu is undefined — no WebGPU here" };
  }
  try {
    const adapter = await gpu.requestAdapter();
    if (!adapter) return { supported: false, reason: "requestAdapter() returned null" };
    return { supported: true, adapter: adapter.info?.vendor || "unknown adapter" };
  } catch (err) {
    return { supported: false, reason: String(err) };
  }
}

/**
 * Dequantise a weight to f32 — what the GPU path uploads.
 *
 * Deliberately the *unfused* path from S08. Uploading once and reusing across
 * tokens makes it the right trade for a small model, and the wrong one for a
 * large model where 4-bit existed precisely so the weights would fit. A
 * production WebGPU engine keeps them quantised on device and decodes in the
 * shader.
 */
export function weightToFloat(w: QuantWeight, n: number, d: number): Float32Array {
  return w.kind === "f32" ? w.data : dequantizeRow(w.raw, n * d, w.qtype);
}

/* -------------------------------------------------------------- backend */

/** A single uploaded weight matrix plus the buffers its dispatch needs. */
export class WebGPUMatmul {
  private readonly device: GPUDeviceT;
  private readonly pipeline: GPUPipelineT;
  private readonly wBuf: GPUBufferT;
  private readonly xBuf: GPUBufferT;
  private readonly yBuf: GPUBufferT;
  private readonly dimsBuf: GPUBufferT;
  private readonly readBuf: GPUBufferT;
  private readonly bindGroup: unknown;
  readonly n: number;
  readonly d: number;
  readonly adapter: string;

  private constructor(init: {
    device: GPUDeviceT;
    pipeline: GPUPipelineT;
    wBuf: GPUBufferT;
    xBuf: GPUBufferT;
    yBuf: GPUBufferT;
    dimsBuf: GPUBufferT;
    readBuf: GPUBufferT;
    bindGroup: unknown;
    n: number;
    d: number;
    adapter: string;
  }) {
    this.device = init.device;
    this.pipeline = init.pipeline;
    this.wBuf = init.wBuf;
    this.xBuf = init.xBuf;
    this.yBuf = init.yBuf;
    this.dimsBuf = init.dimsBuf;
    this.readBuf = init.readBuf;
    this.bindGroup = init.bindGroup;
    this.n = init.n;
    this.d = init.d;
    this.adapter = init.adapter;
  }

  /** Upload `w` ([d, n], row-major) and prepare a reusable dispatch. */
  static async create(w: Float32Array, n: number, d: number): Promise<WebGPUMatmul | null> {
    const support = await probeWebGPU();
    if (!support.supported) return null;

    const gpu = (navigator as unknown as NavigatorGPU).gpu!;
    const adapter = await gpu.requestAdapter();
    if (!adapter) return null;
    const device = await adapter.requestDevice();

    if (w.length !== n * d) {
      throw new RangeError(`weight has ${w.length} elements, expected ${n * d}`);
    }

    const shader = device.createShaderModule({ code: MATMUL_WGSL });
    const pipeline = device.createComputePipeline({
      layout: "auto",
      compute: { module: shader, entryPoint: "main" },
    });

    const wBuf = device.createBuffer({
      size: w.byteLength,
      usage: USAGE.STORAGE | USAGE.COPY_DST,
    });
    device.queue.writeBuffer(wBuf, 0, w);

    const xBuf = device.createBuffer({ size: n * 4, usage: USAGE.STORAGE | USAGE.COPY_DST });
    const yBuf = device.createBuffer({ size: d * 4, usage: USAGE.STORAGE | USAGE.COPY_SRC });
    const dimsBuf = device.createBuffer({ size: 8, usage: USAGE.UNIFORM | USAGE.COPY_DST });
    device.queue.writeBuffer(dimsBuf, 0, new Uint32Array([n, d]));
    const readBuf = device.createBuffer({ size: d * 4, usage: USAGE.COPY_DST | USAGE.MAP_READ });

    const bindGroup = device.createBindGroup({
      layout: pipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: wBuf } },
        { binding: 1, resource: { buffer: xBuf } },
        { binding: 2, resource: { buffer: yBuf } },
        { binding: 3, resource: { buffer: dimsBuf } },
      ],
    });

    return new WebGPUMatmul({
      device, pipeline, wBuf, xBuf, yBuf, dimsBuf, readBuf, bindGroup, n, d,
      adapter: support.adapter,
    });
  }

  /** out = W @ x. Async, because reading back from the GPU is. */
  async run(x: Float32Array): Promise<Float32Array> {
    const { device } = this;
    device.queue.writeBuffer(this.xBuf, 0, x);

    const enc = device.createCommandEncoder();
    const pass = enc.beginComputePass();
    pass.setPipeline(this.pipeline);
    pass.setBindGroup(0, this.bindGroup);
    pass.dispatchWorkgroups(this.d);
    pass.end();
    enc.copyBufferToBuffer(this.yBuf, 0, this.readBuf, 0, this.d * 4);
    device.queue.submit([enc.finish()]);

    await this.readBuf.mapAsync(MAP_READ_MODE);
    const out = new Float32Array(this.readBuf.getMappedRange().slice(0));
    this.readBuf.unmap();
    return out;
  }

  destroy(): void {
    for (const b of [this.wBuf, this.xBuf, this.yBuf, this.dimsBuf, this.readBuf]) b.destroy();
    this.device.destroy();
  }
}

export type BenchmarkResult = {
  available: boolean;
  reason?: string;
  adapter?: string;
  n: number;
  d: number;
  cpuMs: number;
  gpuMs?: number;
  maxAbsError?: number;
  speedup?: number;
};

/**
 * Run the same matmul on both paths and report the difference.
 *
 * This is what the chapter's simulator calls. It answers the only question that
 * matters — is the GPU actually faster here, and does it agree with the CPU —
 * rather than assuming either.
 */
export async function benchmark(n = 2048, d = 2048, iters = 8): Promise<BenchmarkResult> {
  const w = new Float32Array(n * d);
  const x = new Float32Array(n);
  let seed = 12345;
  const rnd = () => {
    seed = (seed * 1103515245 + 12345) & 0x7fffffff;
    return seed / 0x7fffffff - 0.5;
  };
  for (let i = 0; i < w.length; i++) w[i] = rnd();
  for (let i = 0; i < x.length; i++) x[i] = rnd();

  const cpuOut = new Float32Array(d);
  const t0 = performance.now();
  for (let i = 0; i < iters; i++) matmul(cpuOut, x, w, n, d);
  const cpuMs = (performance.now() - t0) / iters;

  const gpu = await WebGPUMatmul.create(w, n, d);
  if (!gpu) {
    const probe = await probeWebGPU();
    return {
      available: false,
      reason: probe.supported ? "device creation failed" : probe.reason,
      n, d, cpuMs,
    };
  }

  try {
    await gpu.run(x); // warm up: first dispatch includes pipeline compilation
    const t1 = performance.now();
    let gpuOut: Float32Array = new Float32Array(d);
    for (let i = 0; i < iters; i++) gpuOut = await gpu.run(x);
    const gpuMs = (performance.now() - t1) / iters;

    let maxAbsError = 0;
    for (let i = 0; i < d; i++) {
      const e = Math.abs(gpuOut[i] - cpuOut[i]);
      if (e > maxAbsError) maxAbsError = e;
    }

    return {
      available: true,
      adapter: gpu.adapter,
      n, d, cpuMs, gpuMs, maxAbsError,
      speedup: cpuMs / gpuMs,
    };
  } finally {
    gpu.destroy();
  }
}

gpu 会探测适配器;找得到就在你的机器上把 shader 与 CPU 路径对拍,找不到就直说,而引擎照跑不误。

试一下
$

GPU 是另一条路径,不是即插即用的替代品

从 GPU 回读是异步的,而 Workspace.matmul 是同步的、每层要调用七次。把 decode 走 GPU,意味着每个 token 要 await 几十次往返,而一次往返的开销高于这些 matmul 能省下的时间。所以 GPU 是一个显式的异步调用,服务于 prefill 形状的工作和大矩阵;CPU 路径保持为参考实现;而 benchmark() 会在你自己的机器上量出交叉点,而不是替你断言。这就是 S13 的启动开销论证在浏览器里的版本。

第 10 步

证明它能用

两个文件,都不导入 Node 也不导入 DOM。同一批断言能同时无头运行和在本页运行,靠的就是这一点。

selftest.ts

覆盖每个模块的 90 条断言:arena 的视图别名与耗尽、在各种别扭长度上把 matmul 与朴素循环对拍、流式 softmax 与物化版本对拍、RoPE 的相对位置性质及其与 NeoX 的不兼容、每种量化格式与手写字节的对照、融合路径与不融合路径的对照、GGUF 往返、缓存不可变性、分词器往返、top-p 的那个 +1、九个语法用例与 JSON.parse 的对照,以及贪心解码的可复现性。

src/lib/minipicolm/selftest.tstypescript
/**
 * mini-picoLM — the self-test
 * ===========================
 *
 * Every claim the S22 chapter makes, checked. Same discipline as the Python
 * chapters: a file that asserts its own claims rather than printing numbers and
 * hoping.
 *
 * Runs in both places, unchanged:
 *
 *     node --experimental-strip-types src/lib/minipicolm/selftest.ts
 *
 * and inside the browser, where the chapter's simulator renders the results.
 * That is the point of keeping it free of DOM and Node APIs.
 */

import { Arena } from "./memory/arena.ts";
import { Tensor } from "./memory/tensor.ts";
import { alignUp } from "./memory/buffer.ts";
import { matmul, matmulQuant } from "./tensor/matmul.ts";
import { rmsnorm } from "./tensor/rmsnorm.ts";
import { softmax, OnlineSoftmax } from "./tensor/softmax.ts";
import { siluMulInto, siluScalar } from "./tensor/activation.ts";
import { RopeTables, ropeInPlace } from "./model/rope.ts";
import {
  bitsPerWeight,
  blockSize,
  dequantizeRow,
  fp16ToFp32,
  fp32ToFp16,
  quantSize,
  type QuantType,
} from "./format/quant.ts";
import { GGUFFile } from "./format/gguf.ts";
import { JsonGrammar } from "./runtime/grammar.ts";
import { Sampler } from "./runtime/sampler.ts";
import { createToyModel } from "./main.ts";
import { generateText } from "./runtime/generation.ts";

export type Check = {
  group: string;
  name: string;
  pass: boolean;
  detail: string;
};

class Suite {
  readonly checks: Check[] = [];
  private group = "";

  section(name: string): void {
    this.group = name;
  }

  ok(name: string, pass: boolean, detail = ""): void {
    this.checks.push({ group: this.group, name, pass, detail });
  }

  close(name: string, a: number, b: number, tol: number, detail?: string): void {
    const d = Math.abs(a - b);
    this.ok(name, d <= tol, detail ?? `|${a.toPrecision(6)} − ${b.toPrecision(6)}| = ${d.toExponential(2)}`);
  }

  get passed(): number {
    return this.checks.filter((c) => c.pass).length;
  }
  get failed(): Check[] {
    return this.checks.filter((c) => !c.pass);
  }
}

/* ------------------------------------------------ hand-built quant blocks */

/** One Q4_K super-block with known fields, so the expected output is derivable. */
function handmadeQ4K(): { raw: Uint8Array; want: Float32Array } {
  const sc = 3;
  const mn = 1;
  const raw = new Uint8Array(144);
  const dv = new DataView(raw.buffer);
  dv.setUint16(0, fp32ToFp16(0.5), true); // d
  dv.setUint16(2, fp32ToFp16(0.25), true); // dmin
  for (let j = 0; j < 4; j++) {
    raw[4 + j] = sc;
    raw[4 + j + 4] = mn;
  }
  for (let j = 4; j < 8; j++) {
    raw[4 + j + 4] = (sc & 0xf) | ((mn & 0xf) << 4);
    raw[4 + j - 4] |= ((sc >> 4) & 3) << 6;
    raw[4 + j] |= ((mn >> 4) & 3) << 6;
  }
  raw.fill(0x21, 16, 144); // low nibble 1, high nibble 2

  const lo = 0.5 * sc * 1 - 0.25 * mn;
  const hi = 0.5 * sc * 2 - 0.25 * mn;
  const want = new Float32Array(256);
  for (let j = 0; j < 4; j++) {
    want.fill(lo, j * 64, j * 64 + 32);
    want.fill(hi, j * 64 + 32, j * 64 + 64);
  }
  return { raw, want };
}

function handmadeQ8_0(): { raw: Uint8Array; want: Float32Array } {
  const raw = new Uint8Array(34);
  const dv = new DataView(raw.buffer);
  dv.setUint16(0, fp32ToFp16(0.125), true);
  const want = new Float32Array(32);
  for (let i = 0; i < 32; i++) {
    const q = i - 16;
    dv.setInt8(2 + i, q);
    want[i] = 0.125 * q;
  }
  return { raw, want };
}

function handmadeQ4_0(): { raw: Uint8Array; want: Float32Array } {
  const raw = new Uint8Array(18);
  new DataView(raw.buffer).setUint16(0, fp32ToFp16(0.25), true);
  raw.fill(0x30, 2, 18); // low nibble 0, high nibble 3
  const want = new Float32Array(32);
  want.fill(0.25 * (0 - 8), 0, 16);
  want.fill(0.25 * (3 - 8), 16, 32);
  return { raw, want };
}

/* ----------------------------------------------------------- the suite */

export async function runSelfTest(): Promise<Suite> {
  const s = new Suite();

  /* ---- steps 1-3: memory ---- */
  s.section("memory");
  {
    const arena = new Arena(64 * 1024);
    const a = arena.f32(100, "a");
    const b = arena.f32(50, "b");
    s.ok("one buffer backs every view", a.buffer === b.buffer && a.buffer === arena.buffer);
    s.ok(
      "allocations are aligned",
      b.byteOffset % 256 === 0,
      `b at byte ${b.byteOffset}`,
    );
    s.ok("views do not overlap", a.byteOffset + a.byteLength <= b.byteOffset);

    a.fill(7);
    b.fill(9);
    s.ok("writing one view leaves the other alone", a[0] === 7 && a[99] === 7 && b[0] === 9);

    const mark = arena.mark();
    arena.f32(10, "scratch");
    arena.release(mark);
    s.ok("release rewinds the watermark", arena.used === mark);

    let threw = false;
    try {
      arena.f32(1_000_000, "too big");
    } catch {
      threw = true;
    }
    s.ok("exhaustion throws rather than corrupting", threw);
    s.ok("alignUp rounds up", alignUp(1, 256) === 256 && alignUp(256, 256) === 256);

    const t = Tensor.zeros(arena, [4, 8], "t");
    t.row(2).fill(3);
    s.ok("tensor rows are views", t.data[16] === 3 && t.data[15] === 0, "row(2) starts at 16");

    let shapeThrew = false;
    try {
      Tensor.of(new Float32Array(7), [4, 2]);
    } catch {
      shapeThrew = true;
    }
    s.ok("shape mismatch is caught", shapeThrew);
  }

  /* ---- steps 4-5, 9: kernels ---- */
  s.section("kernels");
  {
    // matmul against a hand-computed 2x3 case
    const w = new Float32Array([1, 2, 3, 4, 5, 6]); // [2,3]
    const x = new Float32Array([1, 0, -1]);
    const out = new Float32Array(2);
    matmul(out, x, w, 3, 2);
    s.ok("matmul is correct", out[0] === -2 && out[1] === -2, `got [${out[0]}, ${out[1]}]`);

    // unrolling must not change the answer at awkward lengths
    for (const n of [1, 3, 5, 7, 16, 17, 33]) {
      const ww = new Float32Array(n * 2);
      const xx = new Float32Array(n);
      for (let i = 0; i < n; i++) {
        xx[i] = Math.sin(i);
        ww[i] = Math.cos(i);
        ww[n + i] = Math.cos(i * 2);
      }
      const o = new Float32Array(2);
      matmul(o, xx, ww, n, 2);
      let ref0 = 0;
      let ref1 = 0;
      for (let i = 0; i < n; i++) {
        ref0 += ww[i] * xx[i];
        ref1 += ww[n + i] * xx[i];
      }
      s.close(`matmul n=${n} matches a naive loop`, o[0], ref0, 1e-5);
      s.close(`matmul n=${n} row 1`, o[1], ref1, 1e-5);
    }

    // rmsnorm: an all-ones vector normalises to ~1
    const v = new Float32Array(64).fill(1);
    const g = new Float32Array(64).fill(1);
    const no = new Float32Array(64);
    rmsnorm(no, v, g);
    s.close("rmsnorm of a constant vector is ~1", no[0], 1, 1e-4);

    const sm = new Float32Array([1, 2, 3]);
    softmax(sm);
    let sum = 0;
    for (const p of sm) sum += p;
    s.close("softmax sums to 1", sum, 1, 1e-6);
    s.ok("softmax is monotone", sm[0] < sm[1] && sm[1] < sm[2]);

    const big = new Float32Array([1000, 1001, 1002]);
    softmax(big);
    s.ok("softmax does not overflow at large logits", Number.isFinite(big[0]) && big[2] > big[0]);

    // SwiGLU
    const hb = new Float32Array([1, -1]);
    const hb2 = new Float32Array([2, 2]);
    siluMulInto(hb, hb2);
    s.close("SwiGLU gate", hb[0], siluScalar(1) * 2, 1e-6);
    s.close("SwiGLU gate (negative)", hb[1], siluScalar(-1) * 2, 1e-6);
  }

  /* ---- online softmax: the S12 claim ---- */
  s.section("online softmax");
  {
    const n = 40;
    const dim = 8;
    const scores = new Float32Array(n);
    const values = new Float32Array(n * dim);
    for (let i = 0; i < n; i++) {
      scores[i] = Math.sin(i) * 5;
      for (let j = 0; j < dim; j++) values[i * dim + j] = Math.cos(i * 0.3 + j);
    }

    // reference: materialise the whole score row, then weight
    const probs = scores.slice();
    softmax(probs);
    const ref = new Float32Array(dim);
    for (let i = 0; i < n; i++) {
      for (let j = 0; j < dim; j++) ref[j] += probs[i] * values[i * dim + j];
    }

    const online = new OnlineSoftmax(dim, new Float32Array(dim));
    online.reset();
    for (let i = 0; i < n; i++) online.push(scores[i], values, i * dim);
    const got = new Float32Array(dim);
    online.finish(got, 0);

    let worst = 0;
    for (let j = 0; j < dim; j++) worst = Math.max(worst, Math.abs(got[j] - ref[j]));
    s.ok(
      "streaming softmax equals the materialised one",
      worst < 1e-6,
      `max abs error ${worst.toExponential(2)} over ${n} positions`,
    );

    // ascending scores are the worst case for the rescaling path
    const asc = new OnlineSoftmax(dim, new Float32Array(dim));
    asc.reset();
    for (let i = 0; i < n; i++) asc.push(i, values, (i % n) * dim);
    const ascOut = new Float32Array(dim);
    asc.finish(ascOut, 0);
    s.ok("monotonically rising scores stay finite", ascOut.every((v) => Number.isFinite(v)));

    // a fully-masked stream — every score -Infinity — must come out zero, not NaN
    const masked = new OnlineSoftmax(dim, new Float32Array(dim));
    masked.reset();
    for (let i = 0; i < 4; i++) masked.push(-Infinity, values, 0);
    const maskedOut = new Float32Array(dim);
    masked.finish(maskedOut, 0);
    s.ok("a fully-masked stream yields zeros, not NaN", maskedOut.every((v) => v === 0));
  }

  /* ---- step 6: RoPE ---- */
  s.section("rope");
  {
    const headDim = 8;
    const tables = new RopeTables(64, headDim, 10000);
    const a = new Float32Array(headDim);
    const b = new Float32Array(headDim);
    for (let i = 0; i < headDim; i++) {
      a[i] = Math.sin(i + 1);
      b[i] = Math.cos(i * 2 + 1);
    }

    // the property that makes a KV cache sound
    const scores: number[] = [];
    for (const base of [0, 5, 20, 50]) {
      const q = a.slice();
      const k = b.slice();
      ropeInPlace(q, 1, headDim, tables, base);
      ropeInPlace(k, 1, headDim, tables, base + 3);
      let dot = 0;
      for (let i = 0; i < headDim; i++) dot += q[i] * k[i];
      scores.push(dot);
    }
    const spread = Math.max(...scores) - Math.min(...scores);
    s.ok(
      "the score depends on the gap, not the offset",
      spread < 1e-5,
      `spread ${spread.toExponential(2)} across offsets 0..50 at gap 3`,
    );

    // interleaved is NOT the NeoX layout S03 teaches
    const inter = a.slice();
    ropeInPlace(inter, 1, headDim, tables, 3);
    const half = headDim >> 1;
    const neox = new Float32Array(headDim);
    for (let i = 0; i < half; i++) {
      const c = tables.cos[3 * half + i];
      const sn = tables.sin[3 * half + i];
      neox[i] = a[i] * c - a[i + half] * sn;
      neox[i + half] = a[i + half] * c + a[i] * sn;
    }
    let diff = 0;
    for (let i = 0; i < headDim; i++) diff = Math.max(diff, Math.abs(inter[i] - neox[i]));
    s.ok(
      "interleaved differs from NeoX",
      diff > 0.1,
      `max abs difference ${diff.toFixed(4)} — pick the wrong one and the model is subtly broken`,
    );

    // position 0 is the identity rotation
    const p0 = a.slice();
    ropeInPlace(p0, 1, headDim, tables, 0);
    let same = 0;
    for (let i = 0; i < headDim; i++) same = Math.max(same, Math.abs(p0[i] - a[i]));
    s.ok("position 0 rotates by nothing", same < 1e-6);
  }

  /* ---- steps 15-16: quantisation ---- */
  s.section("quantisation");
  {
    const cases: [QuantType, { raw: Uint8Array; want: Float32Array }][] = [
      ["Q4_K", handmadeQ4K()],
      ["Q8_0", handmadeQ8_0()],
      ["Q4_0", handmadeQ4_0()],
    ];
    for (const [t, { raw, want }] of cases) {
      const got = dequantizeRow(raw, blockSize(t), t);
      let worst = 0;
      for (let i = 0; i < want.length; i++) worst = Math.max(worst, Math.abs(got[i] - want[i]));
      s.ok(
        `${t} matches bytes written by hand from the spec`,
        worst < 1e-6,
        `${quantSize(t)} B → ${blockSize(t)} weights, max err ${worst.toExponential(2)}`,
      );
    }

    s.close("Q4_K is exactly 4.5 bits per weight", bitsPerWeight("Q4_K"), 4.5, 1e-9);
    s.close("Q6_K is 6.5625 bits per weight", bitsPerWeight("Q6_K"), 6.5625, 1e-9);

    // fp16 round trip
    for (const v of [0, 1, -2.5, 65504, 6.1e-5]) {
      const back = fp16ToFp32(fp32ToFp16(v));
      s.close(`fp16 round-trips ${v}`, back, v, Math.max(1e-7, Math.abs(v) * 1e-3));
    }
    s.ok("1e-8 flushes to zero (below the subnormal floor)", fp16ToFp32(fp32ToFp16(1e-8)) === 0);
    s.ok("65504 is the largest finite fp16", fp16ToFp32(fp32ToFp16(65504)) === 65504);

    // the fused path must equal the unfused one
    const n = 256;
    const d = 3;
    const q4k = handmadeQ4K();
    const raw = new Uint8Array(144 * d);
    for (let i = 0; i < d; i++) raw.set(q4k.raw, i * 144);
    const x = new Float32Array(n);
    for (let i = 0; i < n; i++) x[i] = Math.sin(i);

    const fused = new Float32Array(d);
    matmulQuant(fused, x, raw, n, d, "Q4_K", new Float32Array(256));

    const dequantised = dequantizeRow(raw, n * d, "Q4_K");
    const unfused = new Float32Array(d);
    matmul(unfused, x, dequantised, n, d);

    let worst = 0;
    for (let i = 0; i < d; i++) worst = Math.max(worst, Math.abs(fused[i] - unfused[i]));
    s.ok(
      "fused dequant+matmul equals dequantise-then-matmul",
      worst < 1e-3,
      `max abs difference ${worst.toExponential(2)} — same maths, different bytes moved`,
    );
  }

  /* ---- step 14: GGUF ---- */
  s.section("gguf");
  {
    const engine = createToyModel({ seed: 3 });
    // Re-read the very bytes createToyModel wrote, through the real reader.
    const f = new GGUFFile(engine.ggufBuffer);
    s.ok("header parses", f.metadata.get("general.architecture") === "llama");
    s.ok("alignment is honoured", f.dataStart % 32 === 0, `data starts at byte ${f.dataStart}`);
    s.ok("tensor index is complete", f.tensors.size === 3 + 9 * 2, `${f.tensors.size} tensors`);
    s.ok(
      "every tensor lies inside the file",
      [...f.tensors.values()].every(
        (t) => f.dataStart + t.offset + t.nBytes <= engine.ggufBuffer.byteLength,
      ),
    );
    const embd = f.info("token_embd.weight");
    s.ok(
      "shapes survive the dims reversal",
      embd.dims.length === 2 && embd.nElements === engine.config.vocabSize * engine.config.dim,
      `dims [${embd.dims}]`,
    );
    s.ok(
      "config was read back",
      engine.config.nLayers === 2 && engine.config.dim === 64 && engine.config.nKvHeads === 2,
      `${engine.config.nLayers}L d=${engine.config.dim}`,
    );
    s.ok(
      "the vocabulary survived the metadata round trip",
      (f.metadata.get("tokenizer.ggml.tokens") as string[]).length === engine.tokenizer.vocabSize,
    );

    let badMagic = false;
    try {
      new GGUFFile(new ArrayBuffer(64));
    } catch {
      badMagic = true;
    }
    s.ok("a non-GGUF buffer is rejected with a clear error", badMagic);
  }

  /* ---- steps 7, 8, 10: the forward pass ---- */
  s.section("forward pass");
  {
    const { model, tokenizer } = createToyModel({ seed: 5 });
    const ids = tokenizer.encode("the cat sat", true);
    s.ok("BOS is prepended", ids[0] === tokenizer.bosId);

    let logits = model.forward(ids[0], 0);
    for (let i = 1; i < ids.length; i++) logits = model.forward(ids[i], i);

    s.ok("logits have the vocab shape", logits.length === model.cfg.vocabSize);
    s.ok("activations stay finite", logits.every((v) => Number.isFinite(v)));

    const first = logits.slice();

    // replaying the same prefix from a clean cache must be bit-identical
    model.reset();
    let again = model.forward(ids[0], 0);
    for (let i = 1; i < ids.length; i++) again = model.forward(ids[i], i);
    let worst = 0;
    for (let i = 0; i < first.length; i++) worst = Math.max(worst, Math.abs(first[i] - again[i]));
    s.ok(
      "replaying a prefix is bit-identical",
      worst === 0,
      `max abs difference ${worst} — the cache is an optimisation, not an approximation`,
    );

    // history is immutable
    const snapshot = model.cache.key.slice(0, model.cfg.kvDim * ids.length);
    model.forward(5, ids.length);
    let changed = false;
    for (let i = 0; i < snapshot.length; i++) {
      if (model.cache.key[i] !== snapshot[i]) changed = true;
    }
    s.ok("writing position N leaves earlier positions alone", !changed);

    // GQA actually shares
    s.ok(
      "GQA divides the cache",
      model.cfg.nHeads % model.cfg.nKvHeads === 0 && model.cfg.nKvHeads < model.cfg.nHeads,
      `${model.cfg.nHeads} query heads over ${model.cfg.nKvHeads} kv heads = ${model.cfg.nHeads / model.cfg.nKvHeads}×`,
    );
  }

  /* ---- steps 11-12: tokenizer and sampler ---- */
  s.section("tokenizer & sampler");
  {
    const { tokenizer } = createToyModel({ seed: 7 });
    const text = "the cat sat on the mat";
    const ids = tokenizer.encode(text, true);
    s.ok("round-trips", tokenizer.decode(ids.slice(1)) === text, JSON.stringify(tokenizer.decode(ids.slice(1))));

    const weird = tokenizer.encode("q~z", false);
    s.ok(
      "byte fallback makes the vocabulary total",
      tokenizer.decode(weird, false).endsWith("q~z"),
      JSON.stringify(tokenizer.decode(weird, false)),
    );

    const sampler = new Sampler({ temperature: 0, seed: 1 });
    const logits = new Float32Array(64);
    logits[13] = 5;
    const draws = new Set([0, 1, 2, 3, 4].map(() => sampler.sample(logits)));
    s.ok("greedy ignores the seed", draws.size === 1 && draws.has(13));

    // the +1 that keeps the crossing token
    const peaked = new Float32Array(64).fill(-20);
    peaked[7] = 20;
    const nucleus = new Sampler({ temperature: 1, topP: 0.9, seed: 2 });
    s.ok(
      "top-p keeps the token that crosses the threshold",
      nucleus.reachable(peaked) === 1 && nucleus.sample(peaked) === 7,
      `reachable = ${nucleus.reachable(peaked)}`,
    );

    // seeded reproducibility
    const a = new Sampler({ temperature: 1, topP: 1, seed: 42 });
    const b = new Sampler({ temperature: 1, topP: 1, seed: 42 });
    const flat = new Float32Array(32);
    for (let i = 0; i < 32; i++) flat[i] = Math.sin(i);
    const seqA = [0, 1, 2, 3, 4].map(() => a.sample(flat));
    const seqB = [0, 1, 2, 3, 4].map(() => b.sample(flat));
    s.ok("the same seed gives the same sequence", seqA.join() === seqB.join(), seqA.join(","));
  }

  /* ---- step 17: grammar ---- */
  s.section("grammar");
  {
    const g = new JsonGrammar();
    const cases: [string, boolean][] = [
      ['{"a":1}', true],
      ['{"a":1', false],
      ["[1,2]", true],
      ["{]", false],
      ["true", true],
      ['{"a":}', false],
      ['{"a":{"b":[1,true]}}', true],
      ["1.5e3", true],
      ["", false],
    ];
    for (const [text, want] of cases) {
      const end = g.advance(g.start(), text);
      const got = g.accepting(end);
      s.ok(`grammar ${want ? "accepts" : "rejects"} ${JSON.stringify(text)}`, got === want);
      if (want) {
        let parses = true;
        try {
          JSON.parse(text);
        } catch {
          parses = false;
        }
        s.ok(`JSON.parse agrees on ${JSON.stringify(text)}`, parses);
      }
    }

    const pieces = ["{", "}", '"', ":", ",", "a", "1", "[", "]", "true"];
    const mask = new Float32Array(pieces.length).fill(0);
    const legal = g.maskLogits(mask, g.start(), pieces);
    s.ok("'{' is legal at the start", mask[0] === 0);
    s.ok("'}' is not", mask[1] === -Infinity);
    s.ok("some tokens survive the mask", legal > 0 && legal < pieces.length, `${legal}/${pieces.length}`);
  }

  /* ---- step 13: generation ---- */
  s.section("generation");
  {
    const { model, tokenizer, sampler } = createToyModel({ seed: 11 });
    sampler.temperature = 0;
    const r = await generateText(model, tokenizer, sampler, {
      prompt: "the cat",
      maxTokens: 12,
      yieldEvery: 4,
    });
    s.ok("generation produces tokens", r.outputTokens > 0, `${r.outputTokens} tokens`);
    s.ok("prompt was tokenized", r.promptTokens > 1, `${r.promptTokens} prompt tokens`);
    s.ok(
      "a stop reason is always reported",
      ["eos", "maxTokens", "contextFull", "aborted", "grammarComplete"].includes(r.stopReason),
      r.stopReason,
    );

    // determinism at temperature 0
    const second = createToyModel({ seed: 11 });
    second.sampler.temperature = 0;
    const r2 = await generateText(second.model, second.tokenizer, second.sampler, {
      prompt: "the cat",
      maxTokens: 12,
      yieldEvery: 4,
    });
    s.ok("greedy generation is reproducible", r.text === r2.text, JSON.stringify(r.text.slice(0, 40)));

    // constrained decoding yields parseable JSON or an explicit incompletion
    const j = createToyModel({ seed: 13 });
    j.sampler.temperature = 0;
    const jr = await generateText(j.model, j.tokenizer, j.sampler, {
      prompt: "json",
      maxTokens: 40,
      json: true,
    });
    if (jr.grammarValid) {
      let parses = true;
      try {
        JSON.parse(jr.text);
      } catch {
        parses = false;
      }
      s.ok("completed constrained output parses", parses, JSON.stringify(jr.text));
    } else {
      s.ok(
        "incomplete constrained output is reported, not returned as valid",
        jr.grammarValid === false,
        `stopped: ${jr.stopReason} — masking guarantees valid, not complete`,
      );
    }
  }

  return s;
}

/* --------------------------------------------------------------- runner */

export type SelfTestSummary = {
  total: number;
  passed: number;
  failed: Check[];
  checks: Check[];
};

export async function selfTest(): Promise<SelfTestSummary> {
  const s = await runSelfTest();
  return { total: s.checks.length, passed: s.passed, failed: s.failed, checks: s.checks };
}

run-selftest.mts

唯一知道「终端」这回事的文件。

src/lib/minipicolm/run-selftest.mtstypescript
/**
 * Headless runner for the mini-picoLM self-test.
 *
 *     node --experimental-strip-types src/lib/minipicolm/run-selftest.ts
 *
 * The engine itself uses no Node and no DOM APIs, so the exact same checks run
 * in the browser behind the S22 simulator. This file is the only part that
 * knows about a terminal.
 */

import { selfTest } from "./selftest.ts";

const GREEN = "";
const RED = "";
const DIM = "";
const BOLD = "";
const RESET = "";

const t0 = performance.now();
const { total, passed, failed, checks } = await selfTest();
const ms = performance.now() - t0;

let group = "";
for (const c of checks) {
  if (c.group !== group) {
    group = c.group;
    process.stdout.write(`\n${BOLD}▸ ${group}${RESET}\n`);
  }
  const mark = c.pass ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
  const detail = c.detail ? `  ${DIM}${c.detail}${RESET}` : "";
  process.stdout.write(`  ${mark} ${c.name}${detail}\n`);
}

process.stdout.write(
  `\n  ${BOLD}${passed}/${total} checks passed${RESET} in ${ms.toFixed(0)} ms\n`,
);

if (failed.length) {
  process.stdout.write(`\n${RED}${BOLD}failures:${RESET}\n`);
  for (const f of failed) {
    process.stdout.write(`  ${RED}✗${RESET} ${f.group} / ${f.name}${f.detail ? ` — ${f.detail}` : ""}\n`);
  }
  process.exit(1);
}
两道关卡bash
# 90 assertions across every module
node --experimental-strip-types src/lib/minipicolm/run-selftest.mts

# every terminal command, dispatched and checked
node --experimental-strip-types src/lib/minipicolm/run-shell-test.mts
在本地运行
先跑 90 条引擎断言,再把每条终端命令都分派一遍,并检查各自的输出是否证明了它应当证明的东西。
$ node --experimental-strip-types src/lib/minipicolm/run-selftest.mts
预期输出: 约 50 毫秒内 90/90,以及 18/18 的 shell 检查;无需下载模型,也不需要联网。

只需要 Node 22.6+:不用 npm install,也没有构建步骤。

第 11 步

终端

下面就是完成后的引擎,加载在本页,外面套了一层 shell。每条命令调用的都是真实代码:selftest 跑的就是无头运行器那 90 条断言,quant 解码的是真实的块,gen 来自真实的前向传播,src 打印的正是你刚读过的模块。

模拟器mini-picolm —— 浏览器终端
mini-picolm —— 浏览器终端
mini-picoLM · 引擎已加载进这个标签页
输入 help · Tab 补全 · ↑ 调出历史 · 每条命令跑的都是真实代码
$

拿它来对照检查你自己的版本。如果你一路建了下来,先在这里跑 selftest,再对着你自己的文件跑一遍,分组和数量应该一致。bench 会不一样,因为那是你的机器。

shell 本身也是一个模块

shell.ts 存放命令表,且完全不知道 DOM 的存在,所以 run-shell-test.mts 能无头驱动全部十二条命令。一个自身无法被验证的验证工具,只是装饰品。

shell.ts

命令表。每一条都调用你已经读过的模块;这里没有任何预设的假回复,所以写入器一旦坏掉,gguf 是会失败的。

src/lib/minipicolm/shell.tstypescript
/**
 * mini-picoLM — a shell over the engine
 * =====================================
 *
 * The command layer behind the terminal at the end of S22. It is deliberately
 * separate from the React component: this file knows about the engine and
 * nothing about the DOM, so the same commands can be driven from a test.
 *
 * Every command exercises a real module. `selftest` runs the same 89 assertions
 * the headless runner does; `quant` decodes an actual GGUF block; `gen` streams
 * from the real forward pass. Nothing here is a canned response.
 */

import { createToyModel, type Engine } from "./main.ts";
import { generate } from "./runtime/generation.ts";
import { selfTest } from "./selftest.ts";
import { GGUFFile } from "./format/gguf.ts";
import {
  bitsPerWeight,
  blockSize,
  dequantizeRow,
  fp16ToFp32,
  fp32ToFp16,
  quantSize,
  type QuantType,
} from "./format/quant.ts";
import { benchmark, probeWebGPU } from "./tensor/webgpu.ts";

export type Line = { text: string; tone?: "ok" | "warn" | "err" | "dim" | "accent" };
export type Emit = (line: Line) => void;

/** What `src` prints. Injected so the engine never imports chapter content. */
export type SourceIndex = Record<string, { path: string; lines: number; text: string }>;

export type ShellState = {
  engine: Engine & { ggufBytes: number; ggufBuffer: ArrayBuffer };
  source: SourceIndex;
  sourceLines: number;
};

const bytes = (n: number) =>
  n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 ** 2).toFixed(2)} MB`;

const pad = (s: string, n: number) => s.padEnd(n, " ");

export type Command = {
  name: string;
  args?: string;
  help: string;
  run: (argv: string[], out: Emit, state: ShellState) => Promise<void> | void;
};

/* ------------------------------------------------------------- commands */

export const COMMANDS: Command[] = [
  {
    name: "help",
    help: "list every command",
    run: (_a, out) => {
      out({ text: "commands — everything here calls the real engine", tone: "dim" });
      for (const c of COMMANDS) {
        out({ text: `  ${pad(c.name + (c.args ? " " + c.args : ""), 26)}${c.help}` });
      }
    },
  },
  {
    name: "selftest",
    args: "[group]",
    help: "run the 89 assertions in this tab",
    run: async (argv, out) => {
      const filter = argv[0]?.toLowerCase();
      out({ text: "running…", tone: "dim" });
      const r = await selfTest();
      let group = "";
      let shown = 0;
      for (const c of r.checks) {
        if (filter && !c.group.toLowerCase().includes(filter)) continue;
        if (c.group !== group) {
          group = c.group;
          out({ text: `▸ ${group}`, tone: "accent" });
        }
        shown++;
        out({
          text: `  ${c.pass ? "✓" : "✗"} ${c.name}${c.detail ? `  ${c.detail}` : ""}`,
          tone: c.pass ? "ok" : "err",
        });
      }
      if (filter && shown === 0) out({ text: `no group matching "${filter}"`, tone: "warn" });
      out({
        text: `${r.passed}/${r.total} checks passed`,
        tone: r.passed === r.total ? "ok" : "err",
      });
    },
  },
  {
    name: "gen",
    args: '"prompt" [-n 24] [--json] [-t 0.9]',
    help: "stream tokens from the forward pass",
    run: async (argv, out, state) => {
      const joined = argv.join(" ");
      const quoted = /"([^"]*)"/.exec(joined);
      const prompt = quoted ? quoted[1] : "the cat sat";
      const n = Number(/-n\s+(\d+)/.exec(joined)?.[1] ?? 24);
      const temp = Number(/-t\s+([\d.]+)/.exec(joined)?.[1] ?? 0.9);
      const json = joined.includes("--json");

      const { model, tokenizer, sampler } = state.engine;
      model.reset();
      sampler.temperature = temp;
      sampler.reseed(7);

      out({ text: `prompt ${JSON.stringify(prompt)} · n=${n} · t=${temp}${json ? " · json" : ""}`, tone: "dim" });

      const it = generate(model, tokenizer, sampler, { prompt, maxTokens: n, json, yieldEvery: 4 });
      let acc = "";
      for (;;) {
        const step = await it.next();
        if (step.done) {
          out({ text: JSON.stringify(acc), tone: "accent" });
          out({
            text:
              `${step.value.outputTokens} tokens · ${step.value.tokensPerSecond.toFixed(1)} tok/s · ` +
              `prefill ${step.value.prefillMs.toFixed(1)} ms · stopped: ${step.value.stopReason}` +
              (step.value.grammarValid !== undefined
                ? ` · grammar ${step.value.grammarValid ? "complete" : "incomplete"}`
                : ""),
            tone: "dim",
          });
          if (json && step.value.grammarValid) {
            try {
              JSON.parse(acc);
              out({ text: "JSON.parse accepted it", tone: "ok" });
            } catch {
              out({ text: "JSON.parse REJECTED it — that would be a grammar bug", tone: "err" });
            }
          }
          break;
        }
        acc += step.value.text;
      }
    },
  },
  {
    name: "tokenize",
    args: "<text>",
    help: "SentencePiece BPE, with a round-trip check",
    run: (argv, out, state) => {
      const text = argv.join(" ") || "the cat sat on the mat";
      const tok = state.engine.tokenizer;
      const ids = tok.encode(text, true);
      out({ text: `ids   ${JSON.stringify(ids)}` });
      out({ text: `pieces ${ids.map((i) => JSON.stringify(tok.piece(i))).join(" ")}` });
      const back = tok.decode(ids.slice(1));
      const same = back === text;
      out({ text: `decode ${JSON.stringify(back)}`, tone: same ? "ok" : "err" });
      out({
        text: same ? "round-trip exact" : "round-trip DIFFERS — that is a bug",
        tone: same ? "ok" : "err",
      });
    },
  },
  {
    name: "quant",
    args: "[Q4_0|Q8_0|Q4_K|Q6_K]",
    help: "block layout, bits/weight, and a decoded block",
    run: (argv, out) => {
      const types: QuantType[] = ["Q4_0", "Q8_0", "Q4_K", "Q6_K"];
      const want = argv[0]?.toUpperCase() as QuantType | undefined;
      if (!want) {
        out({ text: `${pad("type", 8)}${pad("bytes", 8)}${pad("weights", 9)}${pad("bpw", 8)}vs fp16` });
        for (const t of types) {
          out({
            text:
              pad(t, 8) +
              pad(String(quantSize(t)), 8) +
              pad(String(blockSize(t)), 9) +
              pad(bitsPerWeight(t).toFixed(2), 8) +
              `${(16 / bitsPerWeight(t)).toFixed(2)}×`,
          });
        }
        out({ text: 'pass a type to decode one block, e.g. "quant Q4_K"', tone: "dim" });
        return;
      }
      if (!types.includes(want)) {
        out({ text: `unknown type ${want}`, tone: "err" });
        return;
      }
      // A block whose fields are set so the expected values are derivable.
      const size = quantSize(want);
      const raw = new Uint8Array(size);
      const dv = new DataView(raw.buffer);
      dv.setUint16(0, fp32ToFp16(0.5), true);
      if (want === "Q4_K") dv.setUint16(2, fp32ToFp16(0.25), true);
      if (want === "Q6_K") dv.setUint16(208, fp32ToFp16(0.5), true);
      for (let i = want === "Q6_K" ? 0 : 2; i < size; i++) raw[i] = 0x21;
      const decoded = dequantizeRow(raw, blockSize(want), want);
      out({ text: `${want}: ${size} bytes → ${blockSize(want)} weights (${bitsPerWeight(want).toFixed(2)} bpw)` });
      out({
        text: `first 12: ${Array.from(decoded.slice(0, 12)).map((v) => v.toFixed(3)).join(" ")}`,
        tone: "accent",
      });
    },
  },
  {
    name: "fp16",
    args: "<number>",
    help: "round-trip a value through IEEE-754 binary16",
    run: (argv, out) => {
      const v = Number(argv[0] ?? 1);
      if (!Number.isFinite(v)) {
        out({ text: "give me a finite number", tone: "err" });
        return;
      }
      const bits = fp32ToFp16(v);
      const back = fp16ToFp32(bits);
      out({ text: `in    ${v}` });
      out({ text: `bits  0x${bits.toString(16).padStart(4, "0")}` });
      out({ text: `out   ${back}`, tone: back === v ? "ok" : "warn" });
      if (back !== v) {
        out({
          text:
            Math.abs(v) > 65504
              ? "outside fp16's range — saturated"
              : Math.abs(v) < 6.1e-5
                ? "below the subnormal floor — flushed toward zero"
                : `relative error ${(Math.abs(back - v) / Math.abs(v)).toExponential(2)}`,
          tone: "dim",
        });
      }
    },
  },
  {
    name: "mem",
    help: "the arena: every allocation, in order",
    run: (_a, out, state) => {
      const { model, ggufBytes } = state.engine;
      const arena = model.arena;
      out({ text: `arena     ${bytes(arena.used)} used of ${bytes(arena.capacity)}` });
      out({ text: `kv cache  ${bytes(model.kvBytes)}` });
      out({ text: `gguf      ${bytes(ggufBytes)} (written in memory, then re-read)` });
      out({ text: `allocations (${arena.allocations.length}):`, tone: "dim" });
      for (const a of arena.allocations) {
        out({ text: `  ${pad(a.name, 16)}@ ${pad(String(a.byteOffset), 10)}${bytes(a.byteLength)}` });
      }
    },
  },
  {
    name: "model",
    help: "the config read back out of the GGUF header",
    run: (_a, out, state) => {
      const c = state.engine.config;
      out({ text: `layers      ${c.nLayers}` });
      out({ text: `d_model     ${c.dim}` });
      out({ text: `heads       ${c.nHeads} query / ${c.nKvHeads} kv  (GQA ${c.nHeads / c.nKvHeads}×)` });
      out({ text: `head_dim    ${c.headDim}` });
      out({ text: `ffn hidden  ${c.hidden}` });
      out({ text: `vocab       ${c.vocabSize}` });
      out({ text: `context     ${c.maxSeq}` });
      out({ text: `rope base   ${c.ropeFreqBase}` });
      out({ text: state.engine.source, tone: "dim" });
    },
  },
  {
    name: "gguf",
    help: "parse the in-memory file and list its tensors",
    run: (_a, out, state) => {
      const f = new GGUFFile(state.engine.ggufBuffer);
      out({ text: `magic ok · ${f.tensors.size} tensors · ${f.metadata.size} metadata keys` });
      out({ text: `data starts at byte ${f.dataStart} (aligned)`, tone: "dim" });
      let n = 0;
      for (const t of f.tensors.values()) {
        if (n++ >= 8) {
          out({ text: `  … ${f.tensors.size - 8} more`, tone: "dim" });
          break;
        }
        out({ text: `  ${pad(t.name, 26)}${pad(`[${t.dims}]`, 14)}${pad(t.qtype, 6)}${bytes(t.nBytes)}` });
      }
    },
  },
  {
    name: "gpu",
    help: "WebGPU adapter, and a CPU-vs-GPU matmul benchmark",
    run: async (_a, out) => {
      const probe = await probeWebGPU();
      if (!probe.supported) {
        out({ text: `WebGPU unavailable — ${probe.reason}`, tone: "warn" });
        out({ text: "the engine runs identically without it; that is the point", tone: "dim" });
        return;
      }
      out({ text: `adapter ${probe.adapter}`, tone: "ok" });
      out({ text: "benchmarking 1024×1024…", tone: "dim" });
      const r = await benchmark(1024, 1024, 4);
      if (!r.available) {
        out({ text: `benchmark unavailable — ${r.reason}`, tone: "warn" });
        return;
      }
      out({ text: `cpu  ${r.cpuMs?.toFixed(2)} ms` });
      out({ text: `gpu  ${r.gpuMs?.toFixed(2)} ms` });
      out({
        text: `speedup ${(r.speedup ?? 0).toFixed(2)}×  ·  max |gpu − cpu| ${(r.maxAbsError ?? 0).toExponential(1)}`,
        tone: (r.speedup ?? 0) > 1 ? "ok" : "warn",
      });
      if ((r.speedup ?? 0) <= 1) {
        out({ text: "the CPU won — upload and readback cost more than the matmul saves", tone: "dim" });
      }
    },
  },
  {
    name: "src",
    args: "[module]",
    help: "the source of any module, as shipped",
    run: (argv, out, state) => {
      const key = argv[0];
      const keys = Object.keys(state.source);
      if (!key) {
        out({ text: `${state.sourceLines} lines across ${keys.length} files`, tone: "dim" });
        for (const k of keys) {
          const m = state.source[k];
          out({ text: `  ${pad(k, 14)}${pad(m.path, 30)}${String(m.lines).padStart(4)} lines` });
        }
        out({ text: 'e.g. "src arena"', tone: "dim" });
        return;
      }
      const mod = state.source[key];
      if (!mod) {
        out({ text: `no module "${key}" — try "src" for the list`, tone: "err" });
        return;
      }
      out({ text: `${mod.path} · ${mod.lines} lines`, tone: "accent" });
      for (const line of mod.text.split("\n")) out({ text: line });
    },
  },
  {
    name: "bench",
    args: "[tokens]",
    help: "time the forward pass",
    run: async (argv, out, state) => {
      const n = Math.min(Number(argv[0] ?? 16), 64);
      const { model } = state.engine;
      model.reset();
      model.ws.resetCounters();
      const t0 = performance.now();
      for (let i = 0; i < n; i++) model.forward(7, i);
      const ms = performance.now() - t0;
      out({ text: `${n} forward passes in ${ms.toFixed(1)} ms` });
      out({ text: `${(ms / n).toFixed(2)} ms/token · ${((n / ms) * 1000).toFixed(1)} tok/s`, tone: "accent" });
      out({
        text: `${model.ws.matmulCalls} matmuls · ${(model.ws.matmulMacs / 1e6).toFixed(2)}M MACs`,
        tone: "dim",
      });
    },
  },
];

export const COMMAND_NAMES = COMMANDS.map((c) => c.name);

export function createShellState(source: SourceIndex = {}, sourceLines = 0): ShellState {
  return {
    engine: createToyModel({ nLayers: 2, dim: 64, hidden: 128, maxSeq: 128, seed: 1 }),
    source,
    sourceLines,
  };
}

/** Dispatch one line. Unknown commands suggest the closest match. */
export async function runCommand(line: string, out: Emit, state: ShellState): Promise<void> {
  const parts = line.trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return;
  const [name, ...argv] = parts;
  const cmd = COMMANDS.find((c) => c.name === name);
  if (!cmd) {
    const near = COMMAND_NAMES.filter((n) => n.startsWith(name[0]));
    out({
      text: `unknown command: ${name}${near.length ? ` — did you mean ${near.join(", ")}?` : ""}`,
      tone: "err",
    });
    out({ text: 'type "help" for the list', tone: "dim" });
    return;
  }
  try {
    await cmd.run(argv, out, state);
  } catch (err) {
    out({ text: `error: ${err instanceof Error ? err.message : String(err)}`, tone: "err" });
  }
}

run-shell-test.mts

把每条命令分派一遍,捕获输出,并对照它应当证明的东西做断言。

src/lib/minipicolm/run-shell-test.mtstypescript
/**
 * Headless exercise of every browser-shell command.
 *
 *     node --experimental-strip-types src/lib/minipicolm/run-shell-test.mts
 *
 * The terminal at the end of S22 is a real verification surface, so it gets a
 * real gate: each command is dispatched, its output captured, and asserted
 * against what it is supposed to prove. A command that silently stops working
 * fails here as well as in the page.
 */

import { COMMANDS, createShellState, runCommand, type Line } from "./shell.ts";

const GREEN = "[32m";
const RED = "[31m";
const DIM = "[2m";
const BOLD = "[1m";
const RESET = "[0m";

const state = createShellState(
  { arena: { path: "memory/arena.ts", lines: 116, text: "// stub for the headless run" } },
  116,
);

let passed = 0;
const failures: string[] = [];

async function expect(cmd: string, ...must: (string | RegExp)[]): Promise<void> {
  const lines: Line[] = [];
  await runCommand(cmd, (l) => lines.push(l), state);
  const text = lines.map((l) => l.text).join("\n");
  const missing = must.filter((m) => (typeof m === "string" ? !text.includes(m) : !m.test(text)));
  const errored = lines.some((l) => l.tone === "err");

  if (missing.length === 0 && !errored) {
    passed++;
    process.stdout.write(`  ${GREEN}✓${RESET} ${cmd}${DIM}  ${lines.length} lines${RESET}\n`);
  } else {
    const why = errored
      ? `emitted an error line: ${lines.find((l) => l.tone === "err")?.text}`
      : `missing ${missing.map(String).join(", ")}`;
    failures.push(`${cmd} — ${why}`);
    process.stdout.write(`  ${RED}✗${RESET} ${cmd}${DIM}  ${why}${RESET}\n`);
  }
}

process.stdout.write(`${BOLD}▸ browser shell${RESET}\n`);

await expect("help", "selftest", "gen", "tokenize", "quant", "mem", "gguf", "src");
await expect("selftest memory", "one buffer backs every view", /\d+\/\d+ checks passed/);
await expect("gen \"the cat\" -n 8", /8 tokens/, /tok\/s/, "stopped:");
await expect("gen \"j\" --json -n 30", "grammar");
await expect("tokenize the cat sat", "round-trip exact", "ids");
await expect("quant", "Q4_K", "144", "4.50");
await expect("quant Q4_K", "256 weights", "first 12:");
await expect("fp16 1.5", "bits", "out");
await expect("fp16 1e-8", "subnormal");
await expect("mem", "arena", "kv cache", "allocations");
await expect("model", "layers", "GQA", "vocab");
await expect("gguf", "magic ok", "tensors", "token_embd.weight");
await expect("src", "arena", "memory/arena.ts");
await expect("src arena", "stub for the headless run");
await expect("bench 8", /8 forward passes/, "ms/token", "MACs");
await expect("gpu", /WebGPU unavailable|adapter/);

// unknown commands must be reported, not thrown
{
  const lines: Line[] = [];
  await runCommand("nope", (l) => lines.push(l), state);
  const ok = lines.some((l) => l.tone === "err" && l.text.includes("unknown command"));
  if (ok) {
    passed++;
    process.stdout.write(`  ${GREEN}✓${RESET} unknown command is reported, not thrown\n`);
  } else {
    failures.push("unknown command handling");
    process.stdout.write(`  ${RED}✗${RESET} unknown command handling\n`);
  }
}

// every registered command must have help text
{
  const bad = COMMANDS.filter((c) => !c.help || !c.name);
  if (bad.length === 0) {
    passed++;
    process.stdout.write(`  ${GREEN}✓${RESET} all ${COMMANDS.length} commands documented\n`);
  } else {
    failures.push(`undocumented: ${bad.map((c) => c.name).join(", ")}`);
    process.stdout.write(`  ${RED}✗${RESET} undocumented commands\n`);
  }
}

const total = passed + failures.length;
process.stdout.write(`\n  ${BOLD}${passed}/${total} shell checks passed${RESET}\n`);
if (failures.length) {
  process.stdout.write(`\n${RED}${BOLD}failures:${RESET}\n`);
  for (const f of failures) process.stdout.write(`  ${RED}✗${RESET} ${f}\n`);
  process.exit(1);
}

本章前面那个四标签面板仍然值得一看,尤其是内存视图和 WebGPU 基准:

模拟器引擎,就在这里运行
引擎,就在这里运行
标签
点击运行:模型是随机权重,所以文本是噪声

第 12 步

接下来

现在同一个引擎有了三份实现:NumPy 为了清晰,C 为了贴近机器,TypeScript 为了跑进标签页。零件没有变。每一次变的是语言让哪些代价变得可见:Python 藏起内存、惩罚标量循环;C 什么都不藏;JavaScript 藏起回收器,直到它卡住你的界面。

这才是把它建三遍的收获,而且它其实无关推理。想清楚你在为哪条约束做优化,架构就跟着出来了。

练习

  1. 1
    加载一个真实模型:把 <input type="file"> 接到 loadFromGGUF,指向一个小的 Q4_K GGUF。需要的一切都已经在上面了。测一下 token/秒,并与同一模型在 S21 的 Python 版下做对比。
  2. 2
    shell.ts 加一条 perf 命令,报告逐层耗时,然后找出七个 matmul 里哪一个占大头。同时扩展 run-shell-test.mts 去覆盖它:本章的规矩是,没有检查的命令等于不存在。
  3. 3
    把前向传播搬进 Web Worker,让主线程完全不跑 matmul。注意什么必须改变。arena 的 ArrayBuffer 得换成 SharedArrayBuffer,而那需要跨源隔离响应头——这是一个部署约束,不是代码约束。
  4. 4
    把 GPU 路径做完:让权重以量化形式留在设备内存里、在 shader 内部解码,然后重跑 gpu。这就是 S08 那个融合/不融合的论证,往下再来一层。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

mini-picoLM 为什么要预先分配一个 ArrayBuffer 并发放它的视图,而不是按需分配张量?

得分 0/6