`examples/<name>/` was a tested surface in name only. The source-quality job had no examples check at all, and scripts/smoke_examples.sh accepted any process that had not printed one of its error markers: a project that died during startup, exited after a single frame, or crashed without a marker was reported as PASS. Nothing stopped a malformed or unregistered example from reaching dev, which is how 14 examples arrived without READMEs and 20 were never registered in the overview table. Source side - scripts/check_examples.py classifies every directory under examples/ as either runnable (main.nut plus a config.nut that assigns the engine `config` table) or as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a reason. Both kinds must own a README.md and appear in examples/README.md. Stale exemptions and overview links to missing directories fail as well. - `make check/examples` and a new source-quality CI step run the gate. - scripts/tests/test_check_examples.py covers it and pins each failure mode against fixtures. Runtime side - scripts/smoke_examples.sh now enforces liveness, not just marker absence: every runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI passes 2s), and a process that dies inside that window fails whatever its exit code. A clean self-exit after the window passes with an explicit note instead of being silently accepted as a pass. - The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's wall clock is unchanged (13.7 min of its 20 min budget on dev). Windows / GBK - scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py program calls it as the first statement of its __main__ block. On a Chinese Windows a redirected stream falls back to the ANSI code page (cp936), and printing a character that code page cannot encode (arrows, superscripts, bullet separators, emoji - all common in this repository) raised UnicodeEncodeError and turned a checker into a traceback. - The mirror image is input: read_text() without encoding= decoded UTF-8 repository text as cp936 and raised UnicodeDecodeError. - scripts/tests/test_utf8_stdio.py enforces both conventions for every script and test module; text file I/O and child-process decoding now pass encoding="utf-8" explicitly. - Unit-test invocations run with `python3 -X utf8` so the runner's own output never follows the host code page. Violations the new gates found on top of dev, fixed here: 14 examples shipped without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite, pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration, pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and pcg-underwater added by #397), 20 were missing from the overview table (those 14 plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation, triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no UTF-8 stdio guard. Verified locally: `python -m ruff check scripts` (All checks passed); scripts/check_test_manifest.py; scripts/check_quality_metadata.py; scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py; `make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable, 7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests -p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux headless build, so it is verified by the CI examples smoke step rather than locally.
330 lines
12 KiB
Python
330 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Export a tiny GPT-2 (TinyStories-gpt2-3M by default) for the EVEngine tensor demo.
|
|
|
|
Downloads nothing itself: place the checkpoint + tokenizer files from a HuggingFace
|
|
GPT-2 repo under test/assets/mini_llm/ (default TinyStories model, from
|
|
calum/tinystories-gpt2-3M):
|
|
|
|
tinystories_pytorch_model.bin PyTorch state dict (torch .bin, parsed without torch)
|
|
tinystories_config.json
|
|
tinystories_vocab.json
|
|
tinystories_merges.txt
|
|
|
|
Outputs:
|
|
|
|
test/assets/mini_llm/tinystories.bin engine weight bundle (name -> float32 tensor)
|
|
test/assets/mini_llm/tokens.bin token id -> decoded UTF-8 string table
|
|
test/assets/mini_llm/prompt.txt BPE-encoded prompt ids (comma separated)
|
|
test/assets/mini_llm/reference_tokens.txt numpy greedy reference generation ids
|
|
test/assets/mini_llm/probs16.bin numpy softmax probs [16, vocab] (float32)
|
|
|
|
Run: python scripts/export_tiny_gpt2.py
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import pickle
|
|
import re
|
|
import struct
|
|
import sys
|
|
import zipfile
|
|
import collections
|
|
|
|
import numpy as np
|
|
import utf8_stdio
|
|
|
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
SRC = os.path.join(os.path.dirname(ROOT), "test", "assets", "mini_llm")
|
|
OUT = SRC
|
|
|
|
PROMPT = "Once upon a time, there was a little"
|
|
GENERATE_TOKENS = 32
|
|
EPS = 1e-5
|
|
MODEL_PREFIX = "tinystories"
|
|
|
|
|
|
# ---------------------------------------------------------------- safetensors
|
|
def load_safetensors(path):
|
|
with open(path, "rb") as f:
|
|
n = struct.unpack("<Q", f.read(8))[0]
|
|
header = json.loads(f.read(n))
|
|
data = f.read()
|
|
tensors = {}
|
|
for name, meta in header.items():
|
|
if name == "__metadata__":
|
|
continue
|
|
# The built-in causal mask / masked-bias are handled by our own kernel
|
|
# mask; skip them to keep the engine bundle small.
|
|
if name.endswith(".attn.bias") or name.endswith("attn.masked_bias"):
|
|
continue
|
|
dtype = meta["dtype"]
|
|
start, end = meta["data_offsets"]
|
|
if dtype == "F32":
|
|
dt = np.float32
|
|
elif dtype == "U8":
|
|
dt = np.uint8
|
|
elif dtype == "I64":
|
|
dt = np.int64
|
|
else:
|
|
raise ValueError(f"unsupported dtype {dtype} for {name}")
|
|
arr = np.frombuffer(data, dtype=dt, count=(end - start) // np.dtype(dt).itemsize,
|
|
offset=start).reshape(meta["shape"])
|
|
tensors[name] = np.array(arr, dtype=np.float32)
|
|
return tensors
|
|
|
|
|
|
def load_torch_bin(path):
|
|
"""Read a PyTorch state_dict .bin (zip + pickle) without importing torch."""
|
|
zf = zipfile.ZipFile(path)
|
|
member_prefix = next(n.split("/")[0] for n in zf.namelist()
|
|
if n.endswith("/data.pkl"))
|
|
|
|
class FloatStorage:
|
|
pass
|
|
|
|
class LongStorage:
|
|
pass
|
|
|
|
class Storage:
|
|
def __init__(self, data, esize):
|
|
self.data, self.esize = data, esize
|
|
|
|
class TensorInfo:
|
|
def __init__(self, storage, offset, size, stride):
|
|
self.storage, self.offset, self.size, self.stride = storage, offset, size, stride
|
|
|
|
class TorchUnpickler(pickle.Unpickler):
|
|
def find_class(self, module, name):
|
|
if module == "collections" and name == "OrderedDict":
|
|
return collections.OrderedDict
|
|
if module == "torch._utils":
|
|
if name in ("_rebuild_tensor_v2", "_rebuild_tensor"):
|
|
return lambda storage, offset, size, stride, *rest: TensorInfo(
|
|
storage, offset, size, stride)
|
|
if name == "_rebuild_parameter":
|
|
return lambda tensor, *rest: tensor
|
|
if module == "torch":
|
|
if name == "FloatStorage":
|
|
return FloatStorage
|
|
if name == "LongStorage":
|
|
return LongStorage
|
|
raise pickle.UnpicklingError(f"{module}.{name}")
|
|
|
|
def persistent_load(self, pid):
|
|
kind, storage_cls, key, _device, _nelems = pid
|
|
esize = 4 if storage_cls is FloatStorage else 8
|
|
return Storage(zf.read(f"{member_prefix}/data/{key}"), esize)
|
|
|
|
state = TorchUnpickler(zf.open(f"{member_prefix}/data.pkl")).load()
|
|
tensors = {}
|
|
for name, v in state.items():
|
|
if not isinstance(v, TensorInfo):
|
|
continue
|
|
n = 1
|
|
for d in v.size:
|
|
n *= int(d)
|
|
dtype = np.float32 if v.storage.esize == 4 else np.int64
|
|
arr = np.frombuffer(v.storage.data, dtype=dtype, count=n,
|
|
offset=v.offset * v.storage.esize).reshape(v.size)
|
|
tensors[name] = np.array(arr, dtype=np.float32)
|
|
return tensors
|
|
|
|
|
|
# ------------------------------------------------------------ engine .bin I/O
|
|
def write_engine_bundle(tensors, path):
|
|
with open(path, "wb") as f:
|
|
f.write(b"EVLLM11") # 8-byte magic (matches test/tensor_llm.cpp)
|
|
f.write(struct.pack("<i", len(tensors)))
|
|
for name in sorted(tensors):
|
|
arr = np.ascontiguousarray(tensors[name], dtype=np.float32)
|
|
nb = name.encode("utf-8")
|
|
f.write(struct.pack("<i", len(nb)))
|
|
f.write(nb)
|
|
f.write(struct.pack("<i", arr.ndim))
|
|
f.write(struct.pack("<%di" % arr.ndim, *arr.shape))
|
|
f.write(struct.pack("<q", arr.nbytes))
|
|
f.write(arr.tobytes())
|
|
print(f"wrote {path} ({os.path.getsize(path)} bytes, {len(tensors)} tensors)")
|
|
|
|
|
|
def write_token_table(vocab, path):
|
|
decode = bytes_to_unicode_reverse()
|
|
with open(path, "wb") as f:
|
|
f.write(struct.pack("<i", len(vocab)))
|
|
for i in range(len(vocab)):
|
|
token = next(k for k, v in vocab.items() if v == i)
|
|
try:
|
|
raw = bytes(decode[c] for c in token)
|
|
text = raw.decode("utf-8", errors="replace")
|
|
except KeyError:
|
|
text = token # special/added token such as <|endoftext|>
|
|
tb = text.encode("utf-8")
|
|
f.write(struct.pack("<i", len(tb)))
|
|
f.write(tb)
|
|
print(f"wrote {path} ({os.path.getsize(path)} bytes)")
|
|
|
|
|
|
# ---------------------------------------------------------------- tokenizer
|
|
def bytes_to_unicode():
|
|
bs = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + \
|
|
list(range(ord("®"), ord("ÿ") + 1))
|
|
cs = bs[:]
|
|
n = 0
|
|
for b in range(256):
|
|
if b not in bs:
|
|
bs.append(b)
|
|
cs.append(256 + n)
|
|
n += 1
|
|
return dict(zip(bs, [chr(c) for c in cs]))
|
|
|
|
|
|
def bytes_to_unicode_reverse():
|
|
return {v: k for k, v in bytes_to_unicode().items()}
|
|
|
|
|
|
def load_bpe(vocab_path, merges_path):
|
|
with open(vocab_path, "r", encoding="utf-8") as f:
|
|
vocab = json.load(f)
|
|
with open(merges_path, "r", encoding="utf-8") as f:
|
|
merges = [tuple(line.rstrip("\n").split()) for line in f if " " in line]
|
|
ranks = {pair: i for i, pair in enumerate(merges)}
|
|
return vocab, ranks
|
|
|
|
|
|
def bpe_encode(text, vocab, ranks):
|
|
pat = re.compile(
|
|
r"""'s|'t|'re|'ve|'m|'ll|'d| ?[A-Za-z]+| ?[0-9]+| ?[^\sA-Za-z0-9]+|\s+""")
|
|
byte_encoder = bytes_to_unicode()
|
|
ids = []
|
|
for word in pat.findall(text):
|
|
chars = [byte_encoder[b] for b in word.encode("utf-8")]
|
|
while len(chars) > 1:
|
|
pair = min(
|
|
((ranks[(chars[i], chars[i + 1])], i) for i in range(len(chars) - 1)
|
|
if (chars[i], chars[i + 1]) in ranks),
|
|
default=None,
|
|
)
|
|
if pair is None:
|
|
break
|
|
_, i = pair
|
|
chars = chars[:i] + [chars[i] + chars[i + 1]] + chars[i + 2:]
|
|
for tok in chars:
|
|
ids.append(vocab[tok])
|
|
return ids
|
|
|
|
|
|
# ---------------------------------------------------------------- numpy GPT-2
|
|
def gpt2_forward(tokens, w, n_layer, n_head, n_embd, n_inner, vocab_size):
|
|
wte = w["transformer.wte.weight"]
|
|
wpe = w["transformer.wpe.weight"]
|
|
T = len(tokens)
|
|
head = n_embd // n_head
|
|
x = wte[np.array(tokens)] + wpe[np.arange(T)]
|
|
for i in range(n_layer):
|
|
p = f"transformer.h.{i}."
|
|
h = layernorm(x, w[p + "ln_1.weight"], w[p + "ln_1.bias"])
|
|
# GPT-2 Conv1D layers store weight as [in, out]: linear = x @ W + b.
|
|
qkv = h @ w[p + "attn.c_attn.weight"] + w[p + "attn.c_attn.bias"]
|
|
q, k, v = np.split(qkv, 3, axis=1)
|
|
q = q.reshape(T, n_head, head).transpose(1, 0, 2)
|
|
k = k.reshape(T, n_head, head).transpose(1, 0, 2)
|
|
v = v.reshape(T, n_head, head).transpose(1, 0, 2)
|
|
scores = (q @ k.transpose(0, 2, 1)) / np.sqrt(head)
|
|
mask = np.triu(np.full((T, T), -np.inf, dtype=np.float32), k=1)[None]
|
|
scores = scores + mask
|
|
probs = softmax(scores, axis=-1)
|
|
att = probs @ v
|
|
att = att.transpose(1, 0, 2).reshape(T, n_embd)
|
|
x = x + att @ w[p + "attn.c_proj.weight"] + w[p + "attn.c_proj.bias"]
|
|
h = layernorm(x, w[p + "ln_2.weight"], w[p + "ln_2.bias"])
|
|
h = gelu_new(h @ w[p + "mlp.c_fc.weight"] + w[p + "mlp.c_fc.bias"])
|
|
x = x + h @ w[p + "mlp.c_proj.weight"] + w[p + "mlp.c_proj.bias"]
|
|
h = layernorm(x, w["transformer.ln_f.weight"], w["transformer.ln_f.bias"])
|
|
head = w.get("lm_head.weight", wte) # untied LM head when present
|
|
return h @ head.T
|
|
|
|
|
|
def layernorm(x, weight, bias):
|
|
mu = x.mean(axis=-1, keepdims=True)
|
|
var = x.var(axis=-1, keepdims=True)
|
|
return (x - mu) / np.sqrt(var + EPS) * weight + bias
|
|
|
|
|
|
def gelu_new(x):
|
|
return 0.5 * x * (1.0 + np.tanh(0.7978845608028654 * (x + 0.044715 * x ** 3)))
|
|
|
|
|
|
def softmax(x, axis=-1):
|
|
e = np.exp(x - np.max(x, axis=axis, keepdims=True))
|
|
return e / e.sum(axis=axis, keepdims=True)
|
|
|
|
|
|
def greedy_generate(tokens, w, cfg, max_tokens):
|
|
out = list(tokens)
|
|
for _ in range(max_tokens):
|
|
logits = gpt2_forward(out, w, cfg["n_layer"], cfg["n_head"], cfg["n_embd"],
|
|
cfg["n_inner"], cfg["vocab_size"])
|
|
nxt = int(np.argmax(logits[-1]))
|
|
out.append(nxt)
|
|
if nxt == cfg["eos_token_id"]:
|
|
break
|
|
return out
|
|
|
|
|
|
def decode_ids(ids, vocab):
|
|
dec = bytes_to_unicode_reverse()
|
|
text = ""
|
|
for i in ids:
|
|
token = next(k for k, v in vocab.items() if v == i)
|
|
try:
|
|
raw = bytes(dec[c] for c in token)
|
|
text += raw.decode("utf-8", errors="replace")
|
|
except KeyError:
|
|
text += token
|
|
return text
|
|
|
|
|
|
def main():
|
|
torch_bin = os.path.join(SRC, f"{MODEL_PREFIX}_pytorch_model.bin")
|
|
safetensors = os.path.join(SRC, "model.safetensors")
|
|
if os.path.exists(torch_bin):
|
|
w = load_torch_bin(torch_bin)
|
|
elif os.path.exists(safetensors):
|
|
w = load_safetensors(safetensors)
|
|
else:
|
|
sys.exit(f"missing {torch_bin} (or model.safetensors): download a tiny GPT-2 first")
|
|
vocab, ranks = load_bpe(os.path.join(SRC, f"{MODEL_PREFIX}_vocab.json"),
|
|
os.path.join(SRC, f"{MODEL_PREFIX}_merges.txt"))
|
|
with open(os.path.join(SRC, f"{MODEL_PREFIX}_config.json"), encoding="utf-8") as f:
|
|
cfg = json.load(f)
|
|
if cfg.get("n_inner") is None:
|
|
cfg["n_inner"] = 4 * cfg["n_embd"]
|
|
|
|
write_engine_bundle(w, os.path.join(OUT, "tinystories.bin"))
|
|
write_token_table(vocab, os.path.join(OUT, "tokens.bin"))
|
|
|
|
prompt_ids = bpe_encode(PROMPT, vocab, ranks)
|
|
with open(os.path.join(OUT, "prompt.txt"), "w", encoding="utf-8") as f:
|
|
f.write(",".join(map(str, prompt_ids)))
|
|
print(f"prompt {PROMPT!r} -> ids {prompt_ids}")
|
|
|
|
# numpy reference: greedy generation first, then softmax probs at T=16 over
|
|
# the same 16-token context the engine test will use.
|
|
cfg.setdefault("n_inner", 4 * cfg["n_embd"])
|
|
gen = greedy_generate(prompt_ids, w, cfg, GENERATE_TOKENS)
|
|
with open(os.path.join(OUT, "reference_tokens.txt"), "w", encoding="utf-8") as f:
|
|
f.write(",".join(map(str, gen)))
|
|
print(f"numpy greedy generation: {decode_ids(gen, vocab)!r}")
|
|
|
|
ref_tokens = gen[:16]
|
|
logits = gpt2_forward(ref_tokens, w, cfg["n_layer"], cfg["n_head"], cfg["n_embd"],
|
|
cfg["n_inner"], cfg["vocab_size"])
|
|
probs = softmax(logits, axis=-1).astype(np.float32)
|
|
probs.tofile(os.path.join(OUT, "probs16.bin"))
|
|
print(f"reference probs [{probs.shape[0]}, {probs.shape[1]}] -> probs16.bin")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
utf8_stdio.enable_utf8_stdio()
|
|
main()
|