Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,57 @@ thousand parameters, so this one holds about a hundred times more. It fits becau
most of the model lives in flash instead of RAM, using an idea from Google's Gemma
models called Per-Layer Embeddings.

## Reproducing It

This repo is source-first: the generated training data, checkpoints, export
artifacts, and firmware assets are not committed. To reproduce the ESP32 story
demo from a fresh checkout, you need:

1. Python 3.12+
2. `uv`
3. A TinyStories download and BPE/data bins from `data/prepare.py`
4. A trained checkpoint in `runs/`
5. Arduino CLI plus an ESP32 Arduino core for the firmware build

The easiest path is the bundled wrapper:

```bash
uv run python deploy.py
```

That command does the full sequence in order: prepare the data, train the deploy
checkpoint, export `firmware/model/model.bin`, generate `firmware/esp32_llm/vocab.h`,
and verify the exported binary on the host.

If you already have the data and checkpoint, you can skip ahead with:

```bash
uv run python deploy.py --skip-data --skip-train
```

For a start-to-finish run that prepares data, trains, exports, compiles, and
flashes in one command, use:

```bash
uv run python flash.py --full-pipeline --force-train
```

If you want to reuse an existing checkpoint (skip retraining), run:

```bash
uv run python flash.py --full-pipeline
```

If the model/checkpoint already exists and you only want build+flash, use:

```bash
uv run python flash.py
```

The device sketch plays the same “Once upon a time” prompt that the firmware
README documents, so the on-chip demo is a small storyteller rather than a chat
assistant.

## The numbers

| | |
Expand Down
148 changes: 148 additions & 0 deletions deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Single-command deploy for the ESP32 PLE demo.

This wraps the existing flow:
1. prepare data/tokenizer if needed
2. train the deploy checkpoint if needed
3. export the model binary and golden reference
4. generate firmware vocab assets
5. compile and run the host verifier

The default configuration matches the deploy run documented in the repo:
vocab 32768, d_model 96, n_layers 6, ple_dim 128, target core 560000.
"""

from __future__ import annotations

import argparse
import subprocess
import sys
from pathlib import Path


ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
RUNS = ROOT / "runs"
FIRMWARE_MODEL = ROOT / "firmware" / "model"
FIRMWARE = ROOT / "firmware"
SRC = ROOT / "src"


def run_step(title: str, argv: list[str]) -> None:
print(f"\n==> {title}", flush=True)
print(" " + " ".join(argv), flush=True)
subprocess.run(argv, cwd=ROOT, check=True)


def data_artifacts_exist(vocab: int) -> bool:
suffix = "" if vocab == 4096 else f"_v{vocab}"
return all(
(DATA / name).exists()
for name in (
f"train{suffix}.bin",
f"val{suffix}.bin",
f"bpe{vocab}.json",
)
)


def checkpoint_path(arm: str, tag: str, seed: int) -> Path:
return RUNS / f"{arm}{('-' + tag) if tag else ''}-s{seed}.pt"


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--arm", default="ple", choices=["baseline", "ple", "ple_notable", "fatembed", "bigcore"])
ap.add_argument("--vocab", type=int, default=32768)
ap.add_argument("--d-model", type=int, default=96)
ap.add_argument("--n-layers", type=int, default=6)
ap.add_argument("--ple-dim", type=int, default=128)
ap.add_argument("--target-core", type=int, default=560000)
ap.add_argument("--batch-size", type=int, default=16)
ap.add_argument("--seq-len", type=int, default=256)
ap.add_argument("--steps", type=int, default=5000)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--tag", default="cleandeploy")
ap.add_argument("--skip-data", action="store_true")
ap.add_argument("--skip-train", action="store_true")
ap.add_argument("--skip-export", action="store_true")
ap.add_argument("--skip-assets", action="store_true")
ap.add_argument("--skip-verify", action="store_true")
ap.add_argument("--force-train", action="store_true")
args = ap.parse_args()

RUNS.mkdir(exist_ok=True)
FIRMWARE_MODEL.mkdir(parents=True, exist_ok=True)

if not args.skip_data and not data_artifacts_exist(args.vocab):
run_step(
"prepare data",
[
sys.executable,
str(ROOT / "data" / "prepare.py"),
"--vocab",
str(args.vocab),
],
)
elif not args.skip_data:
print("\n==> prepare data: skipped (artifacts already exist)", flush=True)

ckpt = checkpoint_path(args.arm, args.tag, args.seed)
if not args.skip_train:
if ckpt.exists() and not args.force_train:
print(f"\n==> train: skipped (found {ckpt.relative_to(ROOT)})", flush=True)
else:
run_step(
"train deploy checkpoint",
[
sys.executable,
str(SRC / "train.py"),
"--arm",
args.arm,
"--vocab",
str(args.vocab),
"--d-model",
str(args.d_model),
"--n-layers",
str(args.n_layers),
"--ple-dim",
str(args.ple_dim),
"--target-core",
str(args.target_core),
"--batch-size",
str(args.batch_size),
"--seq-len",
str(args.seq_len),
"--steps",
str(args.steps),
"--seed",
str(args.seed),
"--tag",
args.tag,
],
)

if not ckpt.exists():
raise FileNotFoundError(f"expected checkpoint not found: {ckpt}")

if not args.skip_export:
run_step("export model artifact", [sys.executable, str(SRC / "export.py"), str(ckpt)])

if not args.skip_assets:
run_step("generate firmware vocab assets", [sys.executable, str(SRC / "gen_assets.py")])

if not args.skip_verify:
verify_bin = Path("/tmp/esp32-llm-verify")
run_step(
"build host verifier",
["cc", "-O3", "-o", str(verify_bin), str(FIRMWARE / "host_verify" / "verify.c"), "-lm"],
)
run_step(
"verify exported model",
[str(verify_bin), str(FIRMWARE_MODEL / "model.bin"), str(FIRMWARE_MODEL / "golden.txt")],
)

print("\nDeploy flow complete.")


if __name__ == "__main__":
raise SystemExit(main())
50 changes: 45 additions & 5 deletions firmware/esp32_llm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ embedding/output head is staged in PSRAM at boot.

## Build and verify

Export the group-128 ragged-int4 model and verify the portable C runtime first:
If you want the full sequence (prepare/train/export/compile/flash) in one
command, run `uv run python flash.py --full-pipeline --force-train` from the
repo root.

Export the group-128 ragged-int4 model and verify the portable C runtime first.
This repo does not ship `runs/*.pt` checkpoints or the generated `firmware/model/model.bin`, so
you need a trained checkpoint in `runs/` before `src/export.py` can produce the artifact:

```bash
cd src
Expand All @@ -28,24 +34,58 @@ arduino-cli compile \

## Flash and run

Replace the port if the board enumerates under a different device name:
On Linux the board usually shows up as `/dev/ttyUSB0`; replace the port if your
system uses a different device name:

If you already have a checkpoint/model and just want compile+flash with USB
auto-scan, run `uv run python flash.py` from the repo root.

```bash
arduino-cli upload \
-p /dev/cu.usbmodem2101 \
-p /dev/ttyUSB0 \
--fqbn 'esp32:esp32:esp32s3:UploadSpeed=921600,USBMode=hwcdc,CDCOnBoot=cdc,UploadMode=default,CPUFreq=240,FlashMode=qio,FlashSize=16M,PartitionScheme=custom,PSRAM=opi,DebugLevel=info' \
--input-dir /tmp/esp32-llm-build \
firmware/esp32_llm

esptool.py --chip esp32s3 --port /dev/cu.usbmodem2101 --baud 921600 \
esptool.py --chip esp32s3 --port /dev/ttyUSB0 --baud 921600 \
write_flash 0x110000 firmware/model/model.bin

arduino-cli monitor -p /dev/cu.usbmodem2101 --config baudrate=115200
arduino-cli monitor -p /dev/ttyUSB0 --config baudrate=115200
```

The model payload only needs reflashing after a new export. Firmware-only
changes can be uploaded without rewriting the model partition.

## Current Working Story Mode (2026-07)

The current working firmware stage runs in autonomous storytelling mode:

1. No serial command UI is required.
2. Story text streams continuously on serial output.
3. Context rollover is automatic (keeps generating after internal reset).
4. Startup seed is randomized from a small prompt bank for more variety.
5. Throughput/telemetry lines are suppressed so serial output is story text only.

Typical run command:

```bash
uv run python flash.py --skip-model
stty -F /dev/ttyACM0 115200 raw -echo
cat /dev/ttyACM0
```

Example serial output snippet:

```text
the cat and wanted to go outside.
"Look, I found something big!" the little cat hopped towards it and found some flowers.
Then, a mean dog came running down the stairs to the park.
The dog jumped on a leaf and chased it with its nose.
```

Note: if you use `timeout ... cat`, exit code `124` is expected when timeout
expires; it does not indicate a firmware failure.

The model used for the measurements below has SHA-256:

```text
Expand Down
Loading