A tiny AI storyteller (ESP32-S3)
A real 28.9-million-parameter TinyStories language model (slvDev/esp32-ai, MIT) running entirely on the emulated ESP32-S3: the 15.5 MB app image with the embedded model fills a 16 MB flash chip, the int8-staged weights and KV cache live in the 8 MB octal PSRAM, and both LX7 cores split every matrix row. The story streams to the big 2.8″ color TFT — your prompt in yellow, the tale in white, and a live tokens-per-second footer fed over real hardware SPI (every pixel costs honest bus time). Type a few words into the serial monitor and press Enter: the model tokenizes your prompt on-device and writes you a new story at its true hardware-class rate, ~8 tokens/second with the panel attached, courtesy of the JIT that compiles the hot int8 dot-product loop to WebAssembly on the fly.

What's on the bench
- Battery
- ESP32-S3
- TFT 2.8″ 240×320 (ST7789)
How it's wired
- Battery · pos→ESP32-S3 · vin
- Battery · neg→ESP32-S3 · gnd
- ESP32-S3 · 3v3→TFT 2.8″ 240×320 (ST7789) · vcc
- ESP32-S3 · gnd2→TFT 2.8″ 240×320 (ST7789) · gnd
- ESP32-S3 · g6→TFT 2.8″ 240×320 (ST7789) · clk
- ESP32-S3 · g7→TFT 2.8″ 240×320 (ST7789) · din
- ESP32-S3 · g5→TFT 2.8″ 240×320 (ST7789) · cs
- ESP32-S3 · g3→TFT 2.8″ 240×320 (ST7789) · dc
- ESP32-S3 · g4→TFT 2.8″ 240×320 (ST7789) · rst
The code
The exact ESP-IDF v5.3.5 project the bundled firmware was compiled from — shown in the simulator’s Code tab, where you can edit the sources and press Compile & run to rebuild the board’s firmware in the cloud sandbox.
CMakeLists.txt
cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) # Upstream ships at global -O3 (scripts/deploy.sh; matvec_i8_range carries the # one noinline the runtime needs). -ffp-contract=off on top: the host oracle # (host/build.sh) compiles the same llm.h with the same flag, so neither side # fuses a*b+c into an FMA the other did not — the story diff must compare # arithmetic, not compiler contraction choices. idf_build_set_property(COMPILE_OPTIONS "-O3;-ffp-contract=off" APPEND) project(ple-s3)
sdkconfig.defaults
# ple-s3 — deterministic 28.9M PLE TinyLM inference on the ESP32-S3 N16R8. CONFIG_IDF_TARGET="esp32s3" # 16 MB flash, custom table: one 15.875 MB factory app holding the embedded # 14.9 MB model. (esp32s3-memprobe/sdkconfig.psram is the base profile.) CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" # 8 MB octal PSRAM (DevKitC-1 N16R8). SPIRAM_BOOT_INIT + the boot memtest # stay on: reaching app_main proves the octal bring-up and the full sweep. CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_OCT=y CONFIG_ESP32S3_DATA_CACHE_32KB=y # Full 240 MHz. The project CMakeLists appends -O3 -ffp-contract=off (see the # comment there); COMPILER_OPTIMIZATION_PERF keeps IDF's own default sane. CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y CONFIG_COMPILER_OPTIMIZATION_PERF=y # One console only: the default secondary USB-Serial-JTAG console duplicates # every byte in the emulator's merged serial stream. CONFIG_ESP_CONSOLE_SECONDARY_NONE=y # Token generation is a legitimate long CPU burst on the main task (and the # core-0 matvec worker); do not arm the task watchdog. # CONFIG_ESP_TASK_WDT_INIT is not set CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 # Keep DIRAM headroom: move IRAM-optional code to flash, ROM flash driver. CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH=y CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH=y CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH=y CONFIG_SPI_FLASH_ROM_IMPL=y # Pin the app version string so rebuilt artifacts don't embed the building # worktree's `git describe`. CONFIG_APP_PROJECT_VER_FROM_CONFIG=y CONFIG_APP_PROJECT_VER="bre101-1"
partitions.csv
# Name, Type, SubType, Offset, Size # One 15.875 MB factory app: code + the .incbin-embedded 14.9 MB model. nvs, data, nvs, 0x9000, 0x6000 phy_init, data, phy, 0xf000, 0x1000 factory, app, factory, 0x10000, 0xFE0000
main/CMakeLists.txt
idf_component_register( SRCS "ple_main.c" "model_blobs.S" PRIV_REQUIRES esp_driver_uart esp_driver_spi esp_driver_gpio vfs esp_timer ) # The .S embeds the assets via .incbin: hand it absolute paths, and rebuild # when the blobs change. target_compile_definitions(${COMPONENT_LIB} PRIVATE "MODEL_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/model/model.bin\"" "VOCAB_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/model/vocab.bin\"" "BTK_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/model/tokenizer.btk\"" ) set_property(SOURCE model_blobs.S APPEND PROPERTY OBJECT_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/model/model.bin" "${CMAKE_CURRENT_SOURCE_DIR}/model/vocab.bin" "${CMAKE_CURRENT_SOURCE_DIR}/model/tokenizer.btk" )
main/ple_main.c
/* * ple-s3 — the 28.9M-parameter Per-Layer-Embeddings TinyStories model * (slvDev/esp32-ai, MIT, (c) 2026 Viacheslav Sierbov) on the ESP32-S3, * ported from the upstream Arduino sketch to ESP-IDF for the Breadboard * emulator's N16R8 board profile (16 MB flash, 8 MB octal PSRAM). * * Memory placement follows upstream, by reads-per-token: * * flash PLE table + token embedding ~6 random rows (~450 B) per token, * read in place from the DROM window * PSRAM staged int8 core + head, read once per position * KV cache, logits * SRAM scratch + norm vectors touched many times per token * * Platform seams vs the upstream sketch (everything else, including the * dual-core row-split matvec, is kept): * * - the model is .incbin-embedded in the app image (main/model_blobs.S, * 16-byte aligned) instead of a separate mmap'd flash partition: the * emulator's engine seam loads one app image, and either way the bytes * are read through the same flash-cache window, * - console writes BLOCK (upstream drops story bytes when no USB host * drains the buffer — fatal to a byte-identical golden), * - an interactive UART0 prompt loop encodes typed ASCII prompts on-device * with upstream's runtime/bpe_tokenizer.h (their barista firmware's * encoder; TinyStories upstream is decode-only with a fixed prompt), * - no display, no RGB blink, task watchdog off in sdkconfig instead of * delay(0) feeds (generation is a legitimate long CPU burst), * - the whole generation loop lives in gen_core.h, shared verbatim with * host/ple_story.c: the host-native oracle byte-diffs the story stream. */ #include <stdint.h> #include <stdio.h> #include <string.h> #include "driver/uart.h" #include "driver/uart_vfs.h" #include "esp_heap_caps.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "tft.h" /* int8 activations, as the shipping upstream runtime: the staged int8 head * is the whole reason the model reaches ~10 tok/s on silicon. The host * oracle builds with the same flag. */ #define LLM_INT8_ACT 1 #include "gen_core.h" #define MAX_PROMPT_CHARS 256 /* Blobs embedded by model_blobs.S with 16-byte alignment. */ extern const uint8_t model_start[] asm("ple_model_start"); extern const uint8_t model_end[] asm("ple_model_end"); extern const uint8_t vocab_start[] asm("ple_vocab_start"); extern const uint8_t vocab_end[] asm("ple_vocab_end"); extern const uint8_t btk_start[] asm("ple_btk_start"); extern const uint8_t btk_end[] asm("ple_btk_end"); static PleRuntime rt; static size_t psram_used = 0, sram_used = 0; /* Allocations are strict: memory placement is part of the runtime * configuration, so a miss halts loudly instead of running elsewhere. */ static void *ps_alloc(size_t n) { void *p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM); if (p) psram_used += n; return p; } static void *sram_alloc(size_t n) { void *p = heap_caps_malloc(n, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (p) sram_used += n; return p; } static void fatal(const char *what) { printf("[ple] FATAL: %s\n", what); for (;;) vTaskDelay(pdMS_TO_TICKS(1000)); } /* ---- dual-core int8 matvec, upstream's scheme unchanged: quantize once, * split the rows across both LX7 cores. Row results are independent, so the * split cannot change the numbers, only the wall time. Below 128 rows the * notify round trip costs more than the split saves. */ static TaskHandle_t worker_h, main_h; static const QT *job_t; static const int8_t *job_xq; static float job_xs; static float *job_y; static int job_split; static void worker_main(void *arg) { (void)arg; for (;;) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); matvec_i8_range(job_t, job_xq, job_xs, job_y, 0, job_split); xTaskNotifyGive(main_h); } } static void matvec_par(const QT *t, const float *x, float *y) { static int8_t xq[LLM_Q8_MAX_INPUT]; float xs; if (t->w8 == NULL || t->rows < 128) { MATVEC(t, x, y); return; } quantize_act(x, t->cols, xq, &xs); /* once; both cores read the result */ job_t = t; job_xq = xq; job_xs = xs; job_y = y; job_split = t->rows / 2; xTaskNotifyGive(worker_h); matvec_i8_range(t, xq, xs, y, job_split, t->rows); ulTaskNotifyTake(pdTRUE, portMAX_DELAY); } /* Copy the RMSNorm vectors from mapped flash to internal SRAM (upstream's * copy_norms_to_sram; numerics-neutral, they are read many times a token). */ static void copy_norms_to_sram(void) { Model *m = &rt.m; int D = m->c.dim, L = m->c.n_layers, P = m->c.ple_dim; const float **vecs[3 * LLM_MAX_LAYERS + 2]; int sizes[3 * LLM_MAX_LAYERS + 2], n_vec = 0; vecs[n_vec] = &m->ple_proj_norm; sizes[n_vec++] = P; for (int l = 0; l < L; l++) { vecs[n_vec] = &m->attn_norm[l]; sizes[n_vec++] = D; vecs[n_vec] = &m->ffn_norm[l]; sizes[n_vec++] = D; vecs[n_vec] = &m->ple_norm[l]; sizes[n_vec++] = D; } vecs[n_vec] = &m->out_norm; sizes[n_vec++] = D; for (int i = 0; i < n_vec; i++) { size_t bytes = (size_t)sizes[i] * sizeof(float); void *dst = sram_alloc(bytes); if (!dst) fatal("norm vector SRAM allocation"); memcpy(dst, *vecs[i], bytes); *vecs[i] = (const float *)dst; } printf("[ple] norms -> SRAM: %d vectors\n", n_vec); } /* Story bytes go straight to the UART0 console and always block: every byte * is emitted, which is what the byte-identical oracle diff needs. The TFT * console (tft.h) only OBSERVES the same bytes — it must never alter, * reorder or gate the UART stream. */ static void emit_console(const void *bytes, int len) { fwrite(bytes, 1, (size_t)len, stdout); tft_story_bytes(bytes, len); } static void console_init(void) { uart_driver_install(UART_NUM_0, 256, 0, 0, NULL, 0); uart_vfs_dev_use_driver(UART_NUM_0); uart_vfs_dev_port_set_rx_line_endings(UART_NUM_0, ESP_LINE_ENDINGS_CR); } static void read_prompt(char *buf, size_t cap) { if (fgets(buf, (int)cap, stdin) == NULL) { buf[0] = '\0'; return; } buf[strcspn(buf, "\r\n")] = '\0'; } /* One full story: encode, echo the region markers, generate, report. The * region between "[ple] story:" and "[ple] story done" is what the host * oracle reproduces byte-for-byte; the timing line stays outside it. */ static void run_story(const char *prompt) { static int ids[PLE_MAX_PROMPT_IDS]; int n = ple_encode(&rt, prompt, ids, PLE_MAX_PROMPT_IDS); if (n < 0) { printf("[ple] prompt rejected (ASCII only, max %d bytes)\n", BTK_MAX_INPUT_BYTES); return; } if (n == 0) { printf("[ple] prompt produced no tokens\n"); return; } printf("[ple] story (%d prompt tokens):\n\n", n); tft_story_begin(prompt); int64_t t0 = esp_timer_get_time(); int generated = ple_generate(&rt, ids, n); int64_t us = esp_timer_get_time() - t0; tft_story_end(); printf("\n\n[ple] story done: %d prompt + %d generated tokens\n", n, generated); printf("[ple] %.2f s, %.2f tok/s\n", us / 1e6, generated * 1e6 / (double)us); } void app_main(void) { setvbuf(stdout, NULL, _IONBF, 0); printf("\n[ple] PLE TinyLM 28.9M (slvDev/esp32-ai) on ESP32-S3\n"); tft_init(); /* bench TFT (BRE-100 example); console output is unaffected */ size_t model_size = (size_t)(model_end - model_start); const char *err = ple_bind_assets(&rt, model_start, model_size, vocab_start, (size_t)(vocab_end - vocab_start), btk_start, (size_t)(btk_end - btk_start)); if (err) fatal(err); Cfg *c = &rt.m.c; printf("[ple] model: Vin=%d Vout=%d D=%d L=%d H=%d F=%d P=%d S=%d group=%d (image %u B)\n", c->vocab, rt.m.out_vocab, c->dim, c->n_layers, c->n_heads, c->ffn, c->ple_dim, c->seq_len, c->group, (unsigned)rt.m.image_bytes); err = ple_alloc_scratch(&rt, sram_alloc, ps_alloc); if (err) fatal(err); copy_norms_to_sram(); int want = ple_stage_want(&rt); int staged = ple_stage(&rt, ps_alloc); if (staged != want) fatal("int8 staging PSRAM allocation"); printf("[ple] staged -> PSRAM: %d tensors int8, %.2f MB (sram %u B)\n", staged, psram_used / 1048576.0, (unsigned)sram_used); /* FNV-1a over the mapped image; upstream's deploy.sh prints the same value * for the file it flashes. The goldens pin it. */ printf("[ple] image fp=%08x\n", (unsigned)ple_fingerprint(&rt, model_start)); printf("[ple] free: sram %u KB | psram %.2f MB\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024), heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1048576.0); main_h = xTaskGetCurrentTaskHandle(); if (xTaskCreatePinnedToCore(worker_main, "mv", 4096, NULL, 2, &worker_h, 0) == pdPASS) { rt.m.layer_matvec = matvec_par; rt.m.head_matvec = matvec_par; printf("[ple] dual-core: matvec worker on core 0\n"); } else { printf("[ple] dual-core worker failed; running single core\n"); } rt.emit_bytes = emit_console; printf("[ple] boot story (greedy, prompt \"Once upon a time\"):\n"); run_story("Once upon a time"); printf("[ple] boot story done\n"); /* The boot story above is greedy — the cross-platform oracle probe. * Interactive prompts sample (Gumbel-max, seeded per story): livelier * stories, still a pure function of the typed inputs. */ rt.sampled = 1; console_init(); static char line[MAX_PROMPT_CHARS]; for (;;) { printf("\nprompt> "); read_prompt(line, sizeof(line)); printf("[ple prompt: %s]\n", line); run_story(line[0] != '\0' ? line : "Once upon a time"); } }
main/llm.h
// Portable single-header inference for the PLE TinyLM. Same code runs on the // host (verify against PyTorch golden) and on the ESP32-S3 (mmap'd flash). No // dynamic arch: dims come from the model.bin header. Weights are bound in place // from `base` by default; platforms may relocate hot tensors and override the // output-head matvec. // // Matches src/model.py op-for-op: split-half RoPE, erf-GELU, SiLU-SwiGLU, // RMSNorm(weight * x * rsqrt(mean(x^2)+eps)), tied input/output embedding, and // the PLE input (RMSNorm(proj(x)/sqrt(D)) + table[tok]*sqrt(P)) / sqrt(2) // gated per layer as x += RMSNorm(ple_proj(gelu(ple_gate(x)) * ple_l)). #ifndef LLM_H #define LLM_H #include <stdint.h> #include <math.h> #include <string.h> #define LLM_MAGIC 0x00454C50u /* "PLE\0" */ #define LLM_FORMAT_VERSION 1u #define LLM_FLAG_TIED_HEAD 1u /* head is the token embedding */ #define LLM_FLAGS_KNOWN LLM_FLAG_TIED_HEAD #define LLM_HEADER_BYTES 56u /* version 1 */ #define LLM_HEADER_MAX 4096u /* sanity bound on a future header */ #define LLM_MAX_LAYERS 32 /* fixed per-layer arrays below */ #define RMS_EPS 1e-6f #define LLM_Q8_MAX_INPUT 4096 typedef struct { int vocab, dim, n_layers, n_heads, ffn, ple_dim, seq_len, group; float rope_theta; } Cfg; // A group-wise int4 tensor viewed in place: ragged packed nibbles (row-aligned // to a byte) + fp16 group scales. Per-tensor group. No padding. typedef struct { const uint8_t *codes; // rows*row_bytes, nibble = value+8, row_bytes=ceil(cols/2) const uint16_t *scales; // rows*n_groups fp16 int rows, cols, group, n_groups, row_bytes; /* Optional int8 staging: nibbles unpacked and group scales converted once, so * each matvec skips both. Numerics are unchanged - same codes, same scales, * same group sums. NULL selects the int4 path. */ const int8_t *w8; // rows*cols, values -7..7 const float *scale8; // rows*n_groups, half2float'd once } QT; // IEEE half -> float. static inline float half2float(uint16_t h) { uint32_t sign = (uint32_t)(h & 0x8000) << 16; uint32_t exp = (h >> 10) & 0x1F, man = h & 0x3FF, f; if (exp == 0) { if (man == 0) f = sign; else { exp = 127 - 15 + 1; while (!(man & 0x400)) { man <<= 1; exp--; } man &= 0x3FF; f = sign | (exp << 23) | (man << 13); } } else if (exp == 0x1F) { f = sign | 0x7F800000u | (man << 13); } else { f = sign | ((exp - 15 + 127) << 23) | (man << 13); } float out; memcpy(&out, &f, 4); return out; } typedef struct { Cfg c; QT tok_emb; // [V, D] input embedding QT out_head; // [Vout, D]; first Vout rows of tok_emb when tied int out_vocab; // logits produced per step QT ple_model_proj; // [L*P, D] const float *ple_proj_norm; // [P] QT ple_table; // [V, L*P] const float *attn_norm[LLM_MAX_LAYERS]; // [D] QT qkv[LLM_MAX_LAYERS]; // [3D, D] QT attn_proj[LLM_MAX_LAYERS]; // [D, D] const float *ffn_norm[LLM_MAX_LAYERS]; // [D] QT gate[LLM_MAX_LAYERS], up[LLM_MAX_LAYERS], down[LLM_MAX_LAYERS]; QT ple_gate[LLM_MAX_LAYERS]; // [P, D] QT ple_proj[LLM_MAX_LAYERS]; // [D, P] const float *ple_norm[LLM_MAX_LAYERS]; // [D] const float *out_norm; // [D] size_t image_bytes; // bytes consumed by the image, which is smaller than // the flash partition holding it // Optional platform overrides; NULL uses MATVEC. Separate hooks because a // tied 32,768-row head and a 66-row FFN matrix have different cost profiles. void (*head_matvec)(const QT *, const float *, float *); void (*layer_matvec)(const QT *, const float *, float *); } Model; // Advance a cursor over the file, binding one quant tensor. Reads the per-tensor // group prefix, then ragged codes + fp16 scales. static const uint8_t *bind_q(const uint8_t *p, QT *t, int rows, int cols) { int32_t group; memcpy(&group, p, 4); p += 4; t->rows = rows; t->cols = cols; t->group = group; t->n_groups = (cols + group - 1) / group; t->row_bytes = (cols + 1) / 2; t->codes = p; p += (size_t)rows * t->row_bytes; t->scales = (const uint16_t *)p; p += (size_t)rows * t->n_groups * 2; t->w8 = NULL; t->scale8 = NULL; // int4 path until staged return p; } static const uint8_t *bind_f(const uint8_t *p, const float **t, int n) { *t = (const float *)p; return p + (size_t)n * sizeof(float); } // Dequantize row r of a quant tensor into out[cols]. static inline void deq_row(const QT *t, int r, float *out) { const uint8_t *row = t->codes + (size_t)r * t->row_bytes; const uint16_t *sc = t->scales + (size_t)r * t->n_groups; for (int gi = 0; gi < t->n_groups; gi++) { int begin = gi * t->group; int end = begin + t->group; if (end > t->cols) end = t->cols; float scale = half2float(sc[gi]); int j = begin; if ((j & 1) && j < end) { out[j] = (float)((row[j >> 1] >> 4) - 8) * scale; j++; } for (; j + 1 < end; j += 2) { uint8_t byte = row[j >> 1]; out[j] = (float)((byte & 0xF) - 8) * scale; out[j + 1] = (float)((byte >> 4) - 8) * scale; } if (j < end) { uint8_t byte = row[j >> 1]; int code = (j & 1) ? (byte >> 4) : (byte & 0xF); out[j] = (float)(code - 8) * scale; } } } // y[row_begin:row_end] = W * x for a quant tensor W. Keeping the row range // explicit lets platforms parallelize the large output head without changing // any individual dot product. static inline void matvec_q_range(const QT *t, const float *x, float *y, int row_begin, int row_end) { for (int r = row_begin; r < row_end; r++) { const uint8_t *row = t->codes + (size_t)r * t->row_bytes; const uint16_t *sc = t->scales + (size_t)r * t->n_groups; float acc = 0.f; for (int gi = 0; gi < t->n_groups; gi++) { int begin = gi * t->group; int end = begin + t->group; if (end > t->cols) end = t->cols; float scale = half2float(sc[gi]); float group_acc = 0.f; int j = begin; if ((j & 1) && j < end) { group_acc += (float)((row[j >> 1] >> 4) - 8) * x[j]; j++; } for (; j + 1 < end; j += 2) { uint8_t byte = row[j >> 1]; group_acc += (float)((byte & 0xF) - 8) * x[j]; group_acc += (float)((byte >> 4) - 8) * x[j + 1]; } if (j < end) { uint8_t byte = row[j >> 1]; int code = (j & 1) ? (byte >> 4) : (byte & 0xF); group_acc += (float)(code - 8) * x[j]; } acc += group_acc * scale; } y[r] = acc; } } // y[rows] = W * x[cols], W a quant tensor [rows, cols]. static inline void matvec_q(const QT *t, const float *x, float *y) { matvec_q_range(t, x, y, 0, t->rows); } // ---- int8-activation path --------------------------------------------------- // Quantize x to int8 once per call, then per output row do int8*int8 -> int32 // group dot products, scaled by (x_scale * group_scale). The only approximation // against matvec_q is the int8 activations. Split into quantize + ranged-dot so // a parallel caller can quantize once and let both cores read the result. static inline void quantize_act(const float *x, int n, int8_t *xq, float *x_scale) { float xmax = 1e-8f; for (int j = 0; j < n; j++) { float a = fabsf(x[j]); if (a > xmax) xmax = a; } float inv = 127.f / xmax; for (int j = 0; j < n; j++) { int q = (int)lrintf(x[j] * inv); xq[j] = (int8_t)(q > 127 ? 127 : (q < -127 ? -127 : q)); } *x_scale = xmax / 127.f; } static inline void matvec_q8_range(const QT *t, const int8_t *xq, float x_scale, float *y, int row_begin, int row_end) { for (int r = row_begin; r < row_end; r++) { const uint8_t *row = t->codes + (size_t)r * t->row_bytes; const uint16_t *sc = t->scales + (size_t)r * t->n_groups; float acc = 0.f; for (int gi = 0; gi < t->n_groups; gi++) { int begin = gi * t->group, end = begin + t->group; if (end > t->cols) end = t->cols; int32_t g = 0; // group accumulator for (int j = begin; j < end; j++) { uint8_t byte = row[j >> 1]; int code = (j & 1) ? (byte >> 4) : (byte & 0xF); g += (code - 8) * (int)xq[j]; } acc += (float)g * half2float(sc[gi]); } y[r] = acc * x_scale; } } /* Scalar int8 dot product. */ static inline int32_t llm_dot_i8(const int8_t *a, const int8_t *b, int n) { int32_t acc = 0; for (int i = 0; i < n; i++) acc += (int32_t)a[i] * (int32_t)b[i]; return acc; } /* Same arithmetic as matvec_q8_range, reading pre-unpacked int8 weights. * * Keep out of line on ESP32-S3: with Arduino ESP32 3.3.10 at -O3, inlining * regressed inference from 95.0 to 155.2 ms/token. */ #if defined(__GNUC__) __attribute__((noinline)) #endif static void matvec_i8_range(const QT *t, const int8_t *xq, float x_scale, float *y, int row_begin, int row_end) { int g = t->group, ng = t->n_groups, cols = t->cols; for (int r = row_begin; r < row_end; r++) { const int8_t *w = t->w8 + (size_t)r * cols; const float *sc = t->scale8 + (size_t)r * ng; float acc = 0.f; for (int gi = 0; gi < ng; gi++) { int begin = gi * g, end = begin + g; if (end > cols) end = cols; acc += (float)llm_dot_i8(w + begin, xq + begin, end - begin) * sc[gi]; } y[r] = acc * x_scale; } } static inline void matvec_q8(const QT *t, const float *x, float *y) { static int8_t xq[LLM_Q8_MAX_INPUT]; float xs; quantize_act(x, t->cols, xq, &xs); if (t->w8 != NULL) { matvec_i8_range(t, xq, xs, y, 0, t->rows); return; } matvec_q8_range(t, xq, xs, y, 0, t->rows); } // llm_forward dispatches through MATVEC so one flag flips the whole model. #ifdef LLM_INT8_ACT #define MATVEC matvec_q8 #else #define MATVEC matvec_q #endif // w is bound straight into the model image, where fp32 tensors follow // byte-packed quantized ones and so need not be 4-byte aligned. x and out are // caller scratch and always are. static inline void rmsnorm(const float *x, const float *w, int n, float *out) { float ss = 0.f; for (int i = 0; i < n; i++) ss += x[i] * x[i]; float inv = 1.f / sqrtf(ss / n + RMS_EPS); if (((uintptr_t)w & (sizeof(float) - 1)) == 0) { for (int i = 0; i < n; i++) out[i] = w[i] * x[i] * inv; return; } const uint8_t *wb = (const uint8_t *)w; for (int i = 0; i < n; i++) { float wi; memcpy(&wi, wb + (size_t)i * sizeof(float), sizeof wi); out[i] = wi * x[i] * inv; } } static inline float gelu(float x) { return 0.5f * x * (1.f + erff(x * 0.70710678f)); } static inline float silu(float x) { return x / (1.f + expf(-x)); } // Parse header + bind all tensors. Returns 0 on ok, -1 on bad magic. static int llm_load(const uint8_t *base, Model *m) { const uint8_t *p = base; uint32_t h[4]; memcpy(h, p, 16); p += 16; /* magic, version, header_bytes, flags */ if (h[0] != LLM_MAGIC) return -1; /* Version 0 is not a version. A newer one may have changed tensor order, so * refuse rather than mis-bind. An unknown flag bit means the writer asked for * behaviour this reader does not implement, which is equally unsafe. */ if (h[1] == 0 || h[1] > LLM_FORMAT_VERSION) return -3; if (h[2] < LLM_HEADER_BYTES || h[2] > LLM_HEADER_MAX) return -2; if (h[3] & ~(uint32_t)LLM_FLAGS_KNOWN) return -3; uint32_t flags = h[3]; uint32_t vio[2]; memcpy(vio, p, 8); p += 8; /* input_vocab, output_vocab */ m->c.vocab = (int)vio[0]; m->out_vocab = (int)vio[1]; int32_t hv[7]; memcpy(hv, p, 28); p += 28; m->c.dim = hv[0]; m->c.n_layers = hv[1]; m->c.n_heads = hv[2]; m->c.ffn = hv[3]; m->c.ple_dim = hv[4]; m->c.seq_len = hv[5]; m->c.group = hv[6]; memcpy(&m->c.rope_theta, p, 4); p += 4; /* Skip any fields a later version appended. */ p = base + h[2]; /* Every dimension below indexes a fixed array or sizes an allocation, so a * malformed header must be rejected before any tensor is bound. */ if (m->c.vocab <= 0 || m->out_vocab <= 0 || m->c.dim <= 0 || m->c.ffn <= 0 || m->c.ple_dim <= 0 || m->c.seq_len <= 0 || m->c.group <= 0 || m->c.n_layers <= 0 || m->c.n_layers > LLM_MAX_LAYERS || m->c.n_heads <= 0 || m->c.dim % m->c.n_heads != 0 || (m->c.dim / m->c.n_heads) % 2 != 0) return -2; /* A tied head is a view of the first out_vocab embedding rows, so it cannot * be longer than the embedding. */ if ((flags & LLM_FLAG_TIED_HEAD) && m->out_vocab > m->c.vocab) return -2; #ifdef LLM_INT8_ACT /* quantize_act writes into a fixed LLM_Q8_MAX_INPUT buffer, sized by the * widest matvec input: dim, ffn or ple_dim. */ if (m->c.dim > LLM_Q8_MAX_INPUT || m->c.ffn > LLM_Q8_MAX_INPUT || m->c.ple_dim > LLM_Q8_MAX_INPUT) return -2; #endif m->head_matvec = NULL; m->layer_matvec = NULL; int D = m->c.dim, L = m->c.n_layers, P = m->c.ple_dim, F = m->c.ffn, V = m->c.vocab; p = bind_q(p, &m->tok_emb, V, D); p = bind_q(p, &m->ple_model_proj, L * P, D); p = bind_f(p, &m->ple_proj_norm, P); p = bind_q(p, &m->ple_table, V, L * P); for (int i = 0; i < L; i++) { p = bind_f(p, &m->attn_norm[i], D); p = bind_q(p, &m->qkv[i], 3 * D, D); p = bind_q(p, &m->attn_proj[i], D, D); p = bind_f(p, &m->ffn_norm[i], D); p = bind_q(p, &m->gate[i], F, D); p = bind_q(p, &m->up[i], F, D); p = bind_q(p, &m->down[i], D, F); p = bind_q(p, &m->ple_gate[i], P, D); p = bind_q(p, &m->ple_proj[i], D, P); p = bind_f(p, &m->ple_norm[i], D); } p = bind_f(p, &m->out_norm, D); /* Tied: the head is the FIRST out_vocab rows of the token embedding. The * embedding may store more rows than the model can ever emit (padding above * the tokenizer size), so tying does not imply equal row counts. * Untied: the head is appended as the final tensor. */ if (flags & LLM_FLAG_TIED_HEAD) { m->out_head = m->tok_emb; m->out_head.rows = m->out_vocab; } else { p = bind_q(p, &m->out_head, m->out_vocab, D); } m->image_bytes = (size_t)(p - base); return 0; } /* ---- int8 staging ------------------------------------------------------- * Costs 2x the bytes of the int4 form. The caller supplies the buffer, so * placement is the platform's choice. */ /* Byte offset of the float scale array in a staged buffer. rows*cols need not * be a multiple of 4, so the offset is rounded up to keep the float* aligned. */ static inline size_t llm_stage_scale_offset(const QT *t) { size_t w_bytes = (size_t)t->rows * t->cols * sizeof(int8_t); return (w_bytes + sizeof(float) - 1) & ~(size_t)(sizeof(float) - 1); } static inline size_t llm_stage_int8_bytes(const QT *t) { return llm_stage_scale_offset(t) + (size_t)t->rows * t->n_groups * sizeof(float); } static inline void llm_stage_int8(QT *t, void *buffer) { int8_t *w = (int8_t *)buffer; float *sc = (float *)((uint8_t *)buffer + llm_stage_scale_offset(t)); for (int r = 0; r < t->rows; r++) { const uint8_t *row = t->codes + (size_t)r * t->row_bytes; int8_t *dst = w + (size_t)r * t->cols; for (int j = 0; j < t->cols; j++) { uint8_t byte = row[j >> 1]; int code = (j & 1) ? (byte >> 4) : (byte & 0xF); dst[j] = (int8_t)(code - 8); } for (int gi = 0; gi < t->n_groups; gi++) sc[(size_t)r * t->n_groups + gi] = half2float(t->scales[(size_t)r * t->n_groups + gi]); } t->w8 = w; t->scale8 = sc; } /* Stage every per-position tensor, one allocation per tensor: a multi-megabyte * contiguous request fails on a fragmented heap even when total free is larger. * Returns the number staged; a tensor whose allocation fails keeps the int4 * path. Callers requiring a guaranteed placement compare this against * llm_core_stage_count(). */ static inline int llm_stage_core_int8_alloc(Model *m, void *(*alloc)(size_t)) { int staged = 0; QT *tensors[7]; void *buf = alloc(llm_stage_int8_bytes(&m->ple_model_proj)); if (buf) { llm_stage_int8(&m->ple_model_proj, buf); ++staged; } for (int l = 0; l < m->c.n_layers; l++) { tensors[0] = &m->qkv[l]; tensors[1] = &m->attn_proj[l]; tensors[2] = &m->gate[l]; tensors[3] = &m->up[l]; tensors[4] = &m->down[l]; tensors[5] = &m->ple_gate[l]; tensors[6] = &m->ple_proj[l]; for (int i = 0; i < 7; i++) { void *b = alloc(llm_stage_int8_bytes(tensors[i])); if (b) { llm_stage_int8(tensors[i], b); ++staged; } } } return staged; } /* Number of tensors llm_stage_core_int8_alloc attempts. */ static inline int llm_core_stage_count(const Model *m) { return 1 + 7 * m->c.n_layers; } // Scratch buffers, caller-allocated (host: malloc; device: PSRAM). typedef struct { float *x, *h, *qkv, *att, *g1, *g2, *ple, *tmpP, *trow, *logits; float *scores; // [seq_len], reused by each attention head float *kcache, *vcache; // [L * seq_len * D] #ifdef LLM_PROFILE struct { uint64_t input_us, attn_us, ffn_us, ple_us, head_us; uint32_t calls; } profile; #endif } Scratch; #ifdef LLM_PROFILE static void llm_profile_reset(Scratch *s) { memset(&s->profile, 0, sizeof(s->profile)); } #endif // One decode step: token at position pos -> logits[V]. KV cache persists across calls. /* Per-layer matvec dispatch: platform hook if set, otherwise MATVEC. */ #define LLM_LMV(m, t, x, y) \ do { if ((m)->layer_matvec) (m)->layer_matvec((t), (x), (y)); \ else MATVEC((t), (x), (y)); } while (0) static void llm_forward(Model *m, int token, int pos, Scratch *s) { int D = m->c.dim, L = m->c.n_layers, P = m->c.ple_dim, F = m->c.ffn; int H = m->c.n_heads, Dh = D / H, S = m->c.seq_len; #ifdef LLM_PROFILE uint64_t profile_t0 = (uint64_t)LLM_PROFILE_NOW(); #endif deq_row(&m->tok_emb, token, s->x); // embedding // ---- per-layer input: (RMSNorm(proj(x)/sqrt(D)) + table[tok]*sqrt(P)) / sqrt(2) LLM_LMV(m, &m->ple_model_proj, s->x, s->tmpP); // [L*P] float dscale = 1.f / sqrtf((float)D); for (int i = 0; i < L * P; i++) s->tmpP[i] *= dscale; for (int l = 0; l < L; l++) rmsnorm(s->tmpP + l * P, m->ple_proj_norm, P, s->tmpP + l * P); deq_row(&m->ple_table, token, s->trow); // [L*P] float sp = sqrtf((float)P), inv2 = 0.70710678f; for (int i = 0; i < L * P; i++) s->ple[i] = (s->tmpP[i] + s->trow[i] * sp) * inv2; // RoPE frequencies are identical across every head and layer at a position. // Reuse trow (dead after constructing s->ple) instead of recomputing the same // pow/cos/sin values L*H times. float *rope_c = s->trow, *rope_s = s->trow + Dh / 2; for (int i = 0; i < Dh / 2; i++) { float freq = powf(m->c.rope_theta, -2.f * i / Dh); rope_c[i] = cosf(pos * freq); rope_s[i] = sinf(pos * freq); } #ifdef LLM_PROFILE uint64_t profile_t1 = (uint64_t)LLM_PROFILE_NOW(); s->profile.input_us += profile_t1 - profile_t0; #endif for (int l = 0; l < L; l++) { // ---- attention rmsnorm(s->x, m->attn_norm[l], D, s->h); LLM_LMV(m, &m->qkv[l], s->h, s->qkv); // [3D] float *q = s->qkv, *k = s->qkv + D, *v = s->qkv + 2 * D; // split-half RoPE at position pos, per head for (int hh = 0; hh < H; hh++) { float *qh = q + hh * Dh, *kh = k + hh * Dh; for (int i = 0; i < Dh / 2; i++) { float c = rope_c[i], sn = rope_s[i]; float q1 = qh[i], q2 = qh[i + Dh / 2]; qh[i] = q1 * c - q2 * sn; qh[i + Dh / 2] = q2 * c + q1 * sn; float k1 = kh[i], k2 = kh[i + Dh / 2]; kh[i] = k1 * c - k2 * sn; kh[i + Dh / 2] = k2 * c + k1 * sn; } } float *kc = s->kcache + (size_t)l * S * D, *vc = s->vcache + (size_t)l * S * D; memcpy(kc + (size_t)pos * D, k, D * sizeof(float)); memcpy(vc + (size_t)pos * D, v, D * sizeof(float)); // causal attention over 0..pos float scale = 1.f / sqrtf((float)Dh); for (int hh = 0; hh < H; hh++) { float *qh = q + hh * Dh; float *ao = s->att + hh * Dh; for (int i = 0; i < Dh; i++) ao[i] = 0.f; float maxs = -1e30f; // pass 1: max for stable softmax for (int t = 0; t <= pos; t++) { float *kt = kc + (size_t)t * D + hh * Dh, dot = 0.f; for (int i = 0; i < Dh; i++) dot += qh[i] * kt[i]; dot *= scale; s->scores[t] = dot; if (dot > maxs) maxs = dot; } float denom = 0.f; for (int t = 0; t <= pos; t++) { float w = expf(s->scores[t] - maxs); denom += w; float *vt = vc + (size_t)t * D + hh * Dh; for (int i = 0; i < Dh; i++) ao[i] += w * vt[i]; } for (int i = 0; i < Dh; i++) ao[i] /= denom; } LLM_LMV(m, &m->attn_proj[l], s->att, s->h); for (int i = 0; i < D; i++) s->x[i] += s->h[i]; #ifdef LLM_PROFILE uint64_t profile_t2 = (uint64_t)LLM_PROFILE_NOW(); s->profile.attn_us += profile_t2 - profile_t1; #endif // ---- SwiGLU FFN rmsnorm(s->x, m->ffn_norm[l], D, s->h); LLM_LMV(m, &m->gate[l], s->h, s->g1); LLM_LMV(m, &m->up[l], s->h, s->g2); for (int i = 0; i < F; i++) s->g1[i] = silu(s->g1[i]) * s->g2[i]; LLM_LMV(m, &m->down[l], s->g1, s->h); for (int i = 0; i < D; i++) s->x[i] += s->h[i]; #ifdef LLM_PROFILE uint64_t profile_t3 = (uint64_t)LLM_PROFILE_NOW(); s->profile.ffn_us += profile_t3 - profile_t2; #endif // ---- PLE gate: x += RMSNorm(ple_proj(gelu(ple_gate(x)) * ple_l)) LLM_LMV(m, &m->ple_gate[l], s->x, s->g2); // [P] for (int i = 0; i < P; i++) s->g2[i] = gelu(s->g2[i]) * s->ple[l * P + i]; LLM_LMV(m, &m->ple_proj[l], s->g2, s->h); // [D] rmsnorm(s->h, m->ple_norm[l], D, s->h); for (int i = 0; i < D; i++) s->x[i] += s->h[i]; #ifdef LLM_PROFILE profile_t1 = (uint64_t)LLM_PROFILE_NOW(); s->profile.ple_us += profile_t1 - profile_t3; #endif } rmsnorm(s->x, m->out_norm, D, s->x); // output head: logits[v] = dot(out_head_row[v], x). out_head is tok_emb when // the model ties them, and a separate tensor when it does not. if (m->head_matvec) m->head_matvec(&m->out_head, s->x, s->logits); else MATVEC(&m->out_head, s->x, s->logits); #ifdef LLM_PROFILE s->profile.head_us += (uint64_t)LLM_PROFILE_NOW() - profile_t1; s->profile.calls++; #endif } #endif
main/gen_core.h
/* * gen_core.h — the platform-independent half of the PLE TinyLM port: asset * binding, scratch allocation, int8 staging, and the greedy generation loop. * * This ONE header is compiled into both the ESP32-S3 firmware (ple_main.c) * and the host oracle (host/ple_story.c). The token stream the two produce * must be byte-identical; sharing the loop makes that true by construction, * so anything that could diverge (allocators, matvec hooks, timing, console) * stays outside, injected by the caller. * * The inference engine itself is upstream's runtime/llm.h (slvDev/esp32-ai, * MIT, (c) 2026 Viacheslav Sierbov), vendored unmodified; this file mirrors * the setup sequence of upstream's firmware/esp32_tinystories/esp32_tinystories.ino * (allocation placement, norm copies, staging order) minus the display and * the non-blocking serial writes (determinism requires emitting every byte). * * Decoding runs in TWO modes, split by what each must prove: * * - `sampled == 0` (the BOOT story, and everything the host oracle runs): * temperature-0 argmax, exactly as upstream. This is the cross-platform * model-faithfulness probe: the two platforms' logits differ by a few * ulps (libm inside the vendored inference), and argmax is the decision * most robust to that — the byte-identical oracle diff rides on it. * - `sampled == 1` (interactive prompts on the device): Gumbel-max top-40 * at temperature 0.8 — argmax(l/T + Gumbel), distributionally identical * to softmax sampling — plus a 1.2x repetition penalty over a 64-token * window, so stories stop looping. Both were MEASURED before shipping * (20 stories per setting, host-native): repeated 4-word phrases fell * 3.13% -> 0.40%, and reading quality held or improved; the grammar * damage a penalty can cause only appeared at 1.5x ("Tom looked at each * other"), far above 1.2. On-device cost is 1.1% of generation time. The xorshift32 seeds * from the prompt ids plus a session story counter: the stream is still * a pure function of the session's typed inputs, so replay and the * EMULATOR goldens pin it bit-exactly. It is NOT host-comparable — at * ~460 sampled tokens per session the platform ulp noise flips a * near-tie more often than not (measured), which is exactly why the * oracle only ever diffs greedy regions. The Gumbel math uses a local * log2 polynomial (plain IEEE f32, -ffp-contract=off) so the rng * perturbations themselves are platform-exact. */ #ifndef GEN_CORE_H #define GEN_CORE_H #include "llm.h" #include "bpe_tokenizer.h" #define PLE_N_GENERATE 200 /* upstream N_GENERATE; seq_len (256) still caps */ #define PLE_MAX_PROMPT_IDS 128 #define PLE_TOP_K 40 /* 1 / temperature (T = 0.8): the Gumbel-max score is l/T + G. */ #define PLE_INV_T 1.25f /* VOC1 decode table (built by tools/gen_assets.py): id -> raw UTF-8 bytes. */ typedef struct { uint32_t n; /* token count */ const uint32_t *off; /* n+1 byte offsets into blob */ const uint8_t *blob; } PleVocab; typedef struct { Model m; Scratch s; PleVocab vocab; BpeTokenizer btk; /* Every emitted story byte goes through this sink (UART0 on device, stdout * on the host). Must accept every byte: dropping on backpressure, as the * upstream sketch does for a detached USB host, would break determinism. */ void (*emit_bytes)(const void *bytes, int len); /* Sampler state: stories completed this session (the seed mixer), the * current story's xorshift32 state, and the decode mode (0 = greedy — * the boot/oracle mode; 1 = Gumbel-max sampling for interactive * prompts). Zero-initialized on both platforms. */ uint32_t stories_run; uint32_t rng; int sampled; } PleRuntime; /* xorshift32 (Marsaglia): tiny, deterministic, never sticks at 0. */ static uint32_t ple_rng_next(uint32_t *state) { uint32_t x = *state; x ^= x << 13; x ^= x >> 17; x ^= x << 5; *state = x ? x : 0x9e3779b9u; return *state; } /* log2(x) for finite positive normal x, plain IEEE f32 ops only (no libm * — see header): split off the exponent through the bits, then a degree-5 * polynomial for log2(m) on m in [1, 2). ~1e-5 accuracy, which only * shades sampling probabilities imperceptibly — and, crucially, the SAME * bits on every platform. */ static float ple_log2f(float x) { union { float f; uint32_t u; } bx; bx.f = x; int e = (int)((bx.u >> 23) & 0xff) - 127; bx.u = (bx.u & 0x007fffffu) | 0x3f800000u; /* mantissa in [1, 2) */ float m = bx.f; /* minimax-ish fit of log2(m) over [1, 2), Horner */ float t = m - 1.0f; float p = -3.5780402e-2f; p = p * t + 1.7059870e-1f; p = p * t - 3.5556380e-1f; p = p * t + 4.8089329e-1f; p = p * t - 7.2055387e-1f; p = p * t + 1.4426951e0f; return (float)e + p * t; } #define PLE_LN2 0.69314718f /* Draw one token: top-PLE_TOP_K selection over the logits, then a * GUMBEL-MAX draw among those — argmax(l/T + Gumbel) is distributionally * identical to temperature softmax sampling, but the decision is an * ARGMAX over values with O(1) typical gaps, not a walk along a * cumulative boundary. That matters because the two platforms' logits * differ by a few ulps (libm inside the vendored inference; the greedy * era absorbed that silently, 400 pinned tokens without a flip): a * cumsum draw crosses a boundary somewhere every few hundred tokens, * while an argmax only flips when the top two scores land within ulps of * each other. The oracle diff still pins the exact stories, so a * once-in-many-repins near-tie would fail loudly there, never silently * (remedy: bump the seed mixer and re-pin). */ /* Repetition penalty over a 64-token window (sampled mode only). The flag * table is byte-per-token over the output vocab (25,353) — 32 KB of the * ~346 KB free internal SRAM, so the lookup stays O(1) on the hot path. */ #define PLE_REP_PENALTY 1.2f #define PLE_REP_WINDOW 64 static uint8_t ple_recent_flag[32768]; static int ple_recent_ring[PLE_REP_WINDOW]; static int ple_recent_n = 0, ple_recent_head = 0; static void ple_recent_reset(void) { for (int i = 0; i < ple_recent_n; i++) ple_recent_flag[ple_recent_ring[i]] = 0; ple_recent_n = 0; ple_recent_head = 0; } static void ple_recent_push(int tok) { if (tok < 0 || tok >= (int)sizeof ple_recent_flag) return; if (ple_recent_n == PLE_REP_WINDOW) { ple_recent_flag[ple_recent_ring[ple_recent_head]] = 0; ple_recent_ring[ple_recent_head] = tok; ple_recent_head = (ple_recent_head + 1) % PLE_REP_WINDOW; } else { ple_recent_ring[ple_recent_n++] = tok; } ple_recent_flag[tok] = 1; } static int ple_sample(PleRuntime *rt, const float *logits, int v) { int idx[PLE_TOP_K] = {0}; float val[PLE_TOP_K] = {0}; int k = 0; for (int i = 0; i < v; i++) { float l = logits[i]; if (ple_recent_flag[i]) l = l > 0.0f ? l / PLE_REP_PENALTY : l * PLE_REP_PENALTY; if (k == PLE_TOP_K && l <= val[PLE_TOP_K - 1]) continue; int j = k < PLE_TOP_K ? k : PLE_TOP_K - 1; while (j > 0 && val[j - 1] < l) { val[j] = val[j - 1]; idx[j] = idx[j - 1]; j--; } val[j] = l; idx[j] = i; if (k < PLE_TOP_K) k++; } int chosen = idx[0]; float best = -1e30f; for (int j = 0; j < k; j++) { /* u in (0, 1]: 24-bit draw + 1 ulp floor keeps log finite. */ float u = ((float)(ple_rng_next(&rt->rng) >> 8) + 1.0f) * (1.0f / 16777216.0f); /* Standard Gumbel: -ln(-ln(u)), via the local log2. */ float g = -(PLE_LN2 * ple_log2f(-(PLE_LN2 * ple_log2f(u)))); float score = val[j] * PLE_INV_T + g; if (score > best) { best = score; chosen = idx[j]; } } return chosen; } /* Parse the VOC1 blob. Returns NULL on success, else a static error string. */ static const char *ple_bind_vocab(PleVocab *v, const uint8_t *base, size_t size) { if (size < 12 || memcmp(base, "VOC1", 4) != 0) return "vocab.bin: bad magic"; uint32_t n, blob_len; memcpy(&n, base + 4, 4); memcpy(&blob_len, base + 8, 4); if (n == 0 || n > 0x10000) return "vocab.bin: bad count"; if (size != 12 + (size_t)(n + 1) * 4 + blob_len) return "vocab.bin: bad size"; const uint32_t *off = (const uint32_t *)(base + 12); if (off[0] != 0 || off[n] != blob_len) return "vocab.bin: bad offsets"; for (uint32_t i = 0; i < n; i++) if (off[i] > off[i + 1]) return "vocab.bin: offsets not monotonic"; v->n = n; v->off = off; v->blob = base + 12 + (size_t)(n + 1) * 4; return NULL; } /* llm_load + vocab + encoder, with the cross-checks the .ino does at boot. */ static const char *ple_bind_assets(PleRuntime *rt, const uint8_t *model_base, size_t model_size, const uint8_t *vocab_base, size_t vocab_size, const uint8_t *btk_base, size_t btk_size) { if (((uintptr_t)model_base & 15) != 0) return "model blob not 16-byte aligned"; int rc = llm_load(model_base, &rt->m); if (rc != 0) return "model header rejected"; if (rt->m.image_bytes != model_size) return "model image size mismatch"; const char *err = ple_bind_vocab(&rt->vocab, vocab_base, vocab_size); if (err) return err; /* The model header states how many logits it produces; the decode table * must agree or every emitted token decodes against the wrong entry. */ if ((int)rt->vocab.n != rt->m.out_vocab) return "vocab/model size mismatch"; if (bpe_tokenizer_load(btk_base, btk_size, &rt->btk) != 0) return "tokenizer.btk rejected"; if (rt->btk.active_vocab != rt->vocab.n) return "encoder/decoder size mismatch"; return NULL; } /* Scratch allocation, mirroring the .ino's placement: hot per-token buffers * from `fast` (internal SRAM on device), the 99 KiB logits array and the KV * cache from `big` (PSRAM on device). The host passes malloc for both. * Returns NULL on success, else the name of the allocation that failed. */ static const char *ple_alloc_scratch(PleRuntime *rt, void *(*fast)(size_t), void *(*big)(size_t)) { Cfg *c = &rt->m.c; int D = c->dim, L = c->n_layers, P = c->ple_dim, F = c->ffn, S = c->seq_len; Scratch *s = &rt->s; struct { float **p; size_t n; const char *what; void *(*a)(size_t); } plan[] = { { &s->x, (size_t)D, "x", fast }, { &s->h, (size_t)(F > D ? F : D), "h", fast }, { &s->qkv, (size_t)3 * D, "qkv", fast }, { &s->att, (size_t)D, "att", fast }, { &s->g1, (size_t)F, "g1", fast }, { &s->g2, (size_t)(P > F ? P : F), "g2", fast }, { &s->ple, (size_t)L * P, "ple", fast }, { &s->tmpP, (size_t)L * P, "tmpP", fast }, { &s->trow, (size_t)L * P, "trow", fast }, { &s->scores, (size_t)S, "scores", fast }, { &s->logits, (size_t)rt->m.out_vocab, "logits", big }, { &s->kcache, (size_t)L * S * D, "kcache", big }, { &s->vcache, (size_t)L * S * D, "vcache", big }, }; for (size_t i = 0; i < sizeof(plan) / sizeof(plan[0]); i++) { void *p = plan[i].a(plan[i].n * sizeof(float)); if (!p) return plan[i].what; *plan[i].p = (float *)p; } return NULL; } /* Stage the per-position core + the tied head to int8, in the .ino's order. * Returns the number staged; the caller checks it equals ple_stage_want(). */ static int ple_stage(PleRuntime *rt, void *(*alloc)(size_t)) { int staged = llm_stage_core_int8_alloc(&rt->m, alloc); void *b = alloc(llm_stage_int8_bytes(&rt->m.out_head)); if (b) { llm_stage_int8(&rt->m.out_head, b); staged++; } return staged; } static int ple_stage_want(const PleRuntime *rt) { return llm_core_stage_count(&rt->m) + 1; /* + the tied head */ } /* FNV-1a over the model image — the fingerprint upstream's deploy.sh prints * and the firmware echoes at boot; goldens pin it. */ static uint32_t ple_fingerprint(const PleRuntime *rt, const uint8_t *model_base) { uint32_t fp = 2166136261u; for (size_t i = 0; i < rt->m.image_bytes; i++) { fp ^= model_base[i]; fp *= 16777619u; } return fp; } static void ple_emit_token(PleRuntime *rt, int tok) { if (tok < 0 || (uint32_t)tok >= rt->vocab.n) return; const uint8_t *bytes = rt->vocab.blob + rt->vocab.off[tok]; int len = (int)(rt->vocab.off[tok + 1] - rt->vocab.off[tok]); if (len > 0) rt->emit_bytes(bytes, len); } /* Encode an ASCII prompt line. Returns id count, or a BTK_ERR_* (< 0). */ static int ple_encode(PleRuntime *rt, const char *text, int *ids, int cap) { uint16_t raw[PLE_MAX_PROMPT_IDS]; int n = bpe_encode_ascii(&rt->btk, text, raw, cap < PLE_MAX_PROMPT_IDS ? cap : PLE_MAX_PROMPT_IDS); if (n < 0) return n; for (int i = 0; i < n; i++) ids[i] = raw[i]; return n; } /* The generation loop, upstream's setup()/loop() order: emit each prompt * token as it primes, then sample up to PLE_N_GENERATE more (top-k, * temperature — see the header), capped by the trained seq_len. The story * seed derives from the prompt ids and the session story counter HERE, so * the firmware and the host oracle agree without either main doing * anything. Emits story bytes only — no timing, no status lines — so the * device and the host print an identical region. Returns the number of * GENERATED (post-prompt) tokens. */ static int ple_generate(PleRuntime *rt, const int *prompt_ids, int n_prompt) { Model *m = &rt->m; Scratch *s = &rt->s; uint32_t seed = 2166136261u; for (int i = 0; i < n_prompt; i++) { seed ^= (uint32_t)prompt_ids[i]; seed *= 16777619u; } seed ^= rt->stories_run++ * 0x9e3779b9u; rt->rng = seed ? seed : 0x9e3779b9u; ple_recent_reset(); int pos = 0, tok = 0; for (int i = 0; i < n_prompt; i++) { tok = prompt_ids[i]; ple_emit_token(rt, tok); llm_forward(m, tok, pos++, s); } int generated = 0; for (int step = 0; step < PLE_N_GENERATE && pos < m->c.seq_len; step++) { if (rt->sampled) { tok = ple_sample(rt, s->logits, m->out_vocab); ple_recent_push(tok); } else { int best = 0; float bv = -1e30f; for (int v = 0; v < m->out_vocab; v++) if (s->logits[v] > bv) { bv = s->logits[v]; best = v; } tok = best; } ple_emit_token(rt, tok); llm_forward(m, tok, pos++, s); generated++; } return generated; } #endif
main/bpe_tokenizer.h
// Portable compact ByteLevel-BPE encoder. The same code runs on the host and on // the ESP32; it reads the BTK1 asset that // firmware/esp32_barista/tools/generate_tokenizer_header.py packs. // // ASCII input only. Bytes at or above 0x80 are rejected rather than encoded, // because tokenizing them differently from Hugging Face would be silent: the // model would simply receive ids it was never trained on. Unicode support is a // later explicit extension, not an accident of permissiveness. // // BTK1 layout, all little endian: // // offset size field // 0 4 magic "BTK1" // 4 4 format version, must be 2 // 8 4 active vocabulary size // 12 4 merge count // 16 4 reserved, ignored // 20 4 merge base, the token id of merge rank 0 // 24 28 truncated sha256 of the source tokenizer, provenance only // 52 512 256 uint16 byte token ids, indexed by raw byte // 564 6*n merge entries: uint32 key (left << 16 | right), uint16 rank, // sorted ascending by key // // Offset 16 is reserved and carries zero. It is not an end-of-sequence id: the // end of a Barista answer is an output class in the word tables, not a token id // here, so this encoder does not expose it. // // The merge table must be sorted, because btk_merge_rank binary searches it. // The generator guarantees that; this loader does not re-verify it, since the // check belongs to the conformance gate rather than to every boot. It does // verify that the table is present in full, which is a bounds question rather // than a content one. #ifndef BPE_TOKENIZER_H #define BPE_TOKENIZER_H #include <stdint.h> #include <stddef.h> #include <string.h> #define BTK_FORMAT_VERSION 2 #define BTK_HEADER_BYTES 52 #define BTK_BYTE_TABLE_BYTES 512 #define BTK_MERGE_ENTRY_BYTES 6 #define BTK_MAX_INPUT_BYTES 512 /* Token ids and merge ranks are uint16 in the asset, so neither count can * exceed this. A corrupt header would otherwise send the binary search walking * past the end of the image. */ #define BTK_MAX_TABLE_ENTRIES 0x10000 /* bpe_encode_ascii failures. Negative so a caller can test for < 0 in bulk. */ #define BTK_ERR_TOO_LONG (-2) #define BTK_ERR_NOT_ASCII (-3) #define BTK_ERR_CAPACITY (-4) typedef struct { const uint8_t *asset; uint32_t active_vocab; uint32_t merge_count; /* Token id of merge rank 0, so rank r yields merge_base + r. Stored rather * than assumed, because it follows from where the vocabulary happens to place * its 256 single-byte symbols. */ uint32_t merge_base; const uint8_t *byte_ids; const uint8_t *merges; } BpeTokenizer; static uint16_t btk_u16(const uint8_t *p) { return (uint16_t)p[0] | ((uint16_t)p[1] << 8); } static uint32_t btk_u32(const uint8_t *p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); } // Returns 0 on success, -1 on an asset this encoder cannot read. // // asset_size is required, not advisory: the merge count is a field of the asset // itself, so without a length there is nothing to check it against and a // truncated table sends btk_merge_rank reading past the end of the image. // The generated header supplies TOKENIZER_ENCODER_ASSET_SIZE for this argument. // // Nothing is written to tok until every check has passed, so a rejected asset // cannot leave a half-populated tokenizer behind. static int bpe_tokenizer_load( const uint8_t *asset, size_t asset_size, BpeTokenizer *tok) { if (asset == NULL || tok == NULL) return -1; /* Enough bytes to read the header and byte table before trusting any field. */ if (asset_size < BTK_HEADER_BYTES + BTK_BYTE_TABLE_BYTES) return -1; if (memcmp(asset, "BTK1", 4) != 0) return -1; if (btk_u32(asset + 4) != BTK_FORMAT_VERSION) return -1; uint32_t active_vocab = btk_u32(asset + 8); uint32_t merge_count = btk_u32(asset + 12); /* asset + 16 is reserved and deliberately not read. */ uint32_t merge_base = btk_u32(asset + 20); /* The 256 single-byte symbols are always present, so the vocabulary cannot be * smaller than that. The upper bound is what a uint16 token id can address. */ if (active_vocab < 256 || active_vocab > BTK_MAX_TABLE_ENTRIES) return -1; if (merge_count == 0 || merge_count > BTK_MAX_TABLE_ENTRIES) return -1; /* Rank 0 must name a real token, and rank merge_count-1 must too. Both bounds * matter: btk_encode_piece emits merge_base + rank as a uint16, so an id at or * past active_vocab is either outside the vocabulary or a wrapped value. * active_vocab is already capped above, so this also keeps the largest emitted * id, merge_base + merge_count - 1, inside uint16. */ if (merge_base == 0 || merge_base >= active_vocab) return -1; if (merge_base + merge_count > active_vocab) return -1; /* Only now is merge_count trustworthy enough to size the table with. */ size_t required = (size_t)BTK_HEADER_BYTES + BTK_BYTE_TABLE_BYTES + (size_t)merge_count * BTK_MERGE_ENTRY_BYTES; if (asset_size < required) return -1; tok->asset = asset; tok->active_vocab = active_vocab; tok->merge_count = merge_count; tok->merge_base = merge_base; tok->byte_ids = asset + BTK_HEADER_BYTES; tok->merges = tok->byte_ids + BTK_BYTE_TABLE_BYTES; return 0; } static uint16_t btk_byte_id(const BpeTokenizer *tok, uint8_t byte) { return btk_u16(tok->byte_ids + (size_t)byte * 2); } // Rank of the pair, or -1 if it is not a merge. Binary search: the table is // sorted by key, and at 7000-odd entries a linear scan per adjacent pair per // round would dominate encoding. static int btk_merge_rank( const BpeTokenizer *tok, uint16_t left, uint16_t right) { uint32_t key = ((uint32_t)left << 16) | right; uint32_t lo = 0, hi = tok->merge_count; while (lo < hi) { uint32_t mid = lo + (hi - lo) / 2; const uint8_t *entry = tok->merges + (size_t)mid * BTK_MERGE_ENTRY_BYTES; uint32_t candidate = btk_u32(entry); if (candidate < key) lo = mid + 1; else hi = mid; } if (lo == tok->merge_count) return -1; const uint8_t *entry = tok->merges + (size_t)lo * BTK_MERGE_ENTRY_BYTES; return btk_u32(entry) == key ? (int)btk_u16(entry + 4) : -1; } // Ranked BPE over one pre-token: repeatedly apply the lowest-ranked adjacent // merge until none applies. static int btk_encode_piece( const BpeTokenizer *tok, const uint8_t *piece, int piece_len, uint16_t *out, int out_cap) { if (piece_len > BTK_MAX_INPUT_BYTES || piece_len > out_cap) return BTK_ERR_TOO_LONG; int n = piece_len; for (int i = 0; i < n; i++) out[i] = btk_byte_id(tok, piece[i]); for (;;) { int best_rank = -1; uint16_t best_left = 0, best_right = 0; for (int i = 0; i + 1 < n; i++) { int rank = btk_merge_rank(tok, out[i], out[i + 1]); if (rank >= 0 && (best_rank < 0 || rank < best_rank)) { best_rank = rank; best_left = out[i]; best_right = out[i + 1]; } } if (best_rank < 0) break; int write = 0; for (int read = 0; read < n;) { if (read + 1 < n && out[read] == best_left && out[read + 1] == best_right) { out[write++] = (uint16_t)(tok->merge_base + best_rank); read += 2; } else { out[write++] = out[read++]; } } n = write; } return n; } static int btk_ascii_letter(uint8_t c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } static int btk_ascii_digit(uint8_t c) { return c >= '0' && c <= '9'; } static int btk_ascii_space(uint8_t c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; } static int btk_contraction_len(const uint8_t *s, int n) { static const char *suffixes[] = {"'s", "'t", "'re", "'ve", "'m", "'ll", "'d"}; if (n < 2 || s[0] != '\'') return 0; for (unsigned i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { int len = (int)strlen(suffixes[i]); if (len <= n && memcmp(s, suffixes[i], (size_t)len) == 0) return len; } return 0; } // Mirrors the Hugging Face ByteLevel pre-tokenizer over the supported ASCII // domain, then applies ranked BPE independently to each pre-token. Returns the // number of ids written, or one of the BTK_ERR_* codes. static int bpe_encode_ascii( const BpeTokenizer *tok, const char *text, uint16_t *out, int out_cap) { int len = (int)strlen(text); if (len > BTK_MAX_INPUT_BYTES) return BTK_ERR_TOO_LONG; for (int i = 0; i < len; i++) if ((uint8_t)text[i] >= 0x80) return BTK_ERR_NOT_ASCII; uint16_t piece_ids[BTK_MAX_INPUT_BYTES]; int pos = 0, written = 0; while (pos < len) { const uint8_t *s = (const uint8_t *)text; int start = pos, contraction = btk_contraction_len(s + pos, len - pos); if (contraction) { pos += contraction; } else if ( s[pos] == ' ' && pos + 1 < len && btk_ascii_letter(s[pos + 1])) { pos += 2; while (pos < len && btk_ascii_letter(s[pos])) pos++; } else if (btk_ascii_letter(s[pos])) { while (pos < len && btk_ascii_letter(s[pos])) pos++; } else if ( s[pos] == ' ' && pos + 1 < len && btk_ascii_digit(s[pos + 1])) { pos += 2; while (pos < len && btk_ascii_digit(s[pos])) pos++; } else if (btk_ascii_digit(s[pos])) { while (pos < len && btk_ascii_digit(s[pos])) pos++; } else if ( s[pos] == ' ' && pos + 1 < len && !btk_ascii_space(s[pos + 1]) && !btk_ascii_letter(s[pos + 1]) && !btk_ascii_digit(s[pos + 1])) { pos += 2; while (pos < len && !btk_ascii_space(s[pos]) && !btk_ascii_letter(s[pos]) && !btk_ascii_digit(s[pos])) pos++; } else if (!btk_ascii_space(s[pos]) && !btk_ascii_letter(s[pos]) && !btk_ascii_digit(s[pos])) { while (pos < len && !btk_ascii_space(s[pos]) && !btk_ascii_letter(s[pos]) && !btk_ascii_digit(s[pos])) pos++; } else { while (pos < len && btk_ascii_space(s[pos])) pos++; // Before non-whitespace, the regex leaves the last whitespace byte to be // consumed as the optional prefix of the next token class. if (pos < len && pos - start > 1) pos--; } int n = btk_encode_piece(tok, s + start, pos - start, piece_ids, BTK_MAX_INPUT_BYTES); if (n < 0 || written + n > out_cap) return BTK_ERR_CAPACITY; memcpy(out + written, piece_ids, (size_t)n * sizeof(uint16_t)); written += n; } return written; } #endif
main/tft.h
/* * Minimal ST7789 2.8" 240x320 text console for the PLE storyteller — * first-party (MIT), written for the Breadboard bench wiring: * * din=GPIO7 (GPSPI2 MOSI) clk=GPIO6 (GPSPI2 SCK) [the S3 SPI seam] * cs=GPIO5 dc=GPIO3 rst=GPIO4 (the bench's TFT wiring convention, * as the microSD photo frame example wires it; CS driven manually) * * SPI goes through the IDF spi_master driver with DMA and ROW-SIZED * transactions (one 240x16 text row = one 7,680-byte transfer): the * emulator's SPI seam completes one parked transaction per host service, * so many tiny FIFO chunks would be latency-bound where a few large * transfers stream at wire speed — the same shape MicroPython's * machine.SPI big-buffer writes take, which the GPSPI2 model is validated * against. The emulator charges honest wire time (BRE-91): every pixel * painted here costs the sim real bus nanoseconds, so the on-screen tok/s * is the true effective rate WITH the display attached, not a free * overlay. * * Layout (portrait 240x320, 5x7 font at 2x = 10x16 cells, 24 cols): * rows 0-1 prompt (yellow on navy) * rows 2-18 story text, word-wrapped, scrolls (white) * row 19 live stats: tok/s + token count (cyan) * * The story text arrives through the same emit seam as the console; this * file only ever OBSERVES those bytes — the UART stream stays byte-exact * (the host-oracle contract). */ #pragma once #include <string.h> #include "driver/gpio.h" #include "esp_heap_caps.h" #include "driver/spi_master.h" #include "esp_timer.h" #include "font5x7.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #define TFT_PIN_MOSI 7 #define TFT_PIN_SCK 6 #define TFT_PIN_CS 5 #define TFT_PIN_DC 3 #define TFT_PIN_RST 4 #define TFT_HZ 20000000 #define TFT_W 240 #define TFT_H 320 #define CELL_W 10 /* 5x7 font at 2x + no gap (glyph carries its own) */ #define CELL_H 16 #define TFT_COLS (TFT_W / CELL_W) /* 24 */ #define TFT_ROWS (TFT_H / CELL_H) /* 20 */ #define BODY_TOP 2 /* rows 0-1 = prompt */ #define BODY_ROWS (TFT_ROWS - BODY_TOP - 1) /* 17; last row = stats */ #define RGB(r, g, b) ((uint16_t)((((r) & 0xf8) << 8) | (((g) & 0xfc) << 3) | ((b) >> 3))) #define C_BG RGB(10, 14, 26) /* deep navy */ #define C_PROMPT RGB(255, 214, 64) #define C_STORY RGB(240, 240, 235) #define C_STATS RGB(64, 220, 255) #define C_TITLE RGB(255, 122, 26) /* the bench's industrial orange */ static spi_device_handle_t tft_spi; /* One text row of RGB565 pixels, DMA-capable (internal RAM). */ #define ROW_BYTES (TFT_W * CELL_H * 2) static uint8_t *tft_row; static void tft_cs(int level) { gpio_set_level(TFT_PIN_CS, level); } static void tft_dc(int level) { gpio_set_level(TFT_PIN_DC, level); } /* One transfer (DMA path; up to ROW_BYTES). */ static void tft_xfer(const uint8_t *bytes, int len) { spi_transaction_t t = {0}; t.length = (size_t)len * 8; t.tx_buffer = bytes; spi_device_polling_transmit(tft_spi, &t); } static void tft_cmd(uint8_t cmd, const uint8_t *data, int len) { tft_cs(0); tft_dc(0); tft_xfer(&cmd, 1); if (len > 0) { tft_dc(1); tft_xfer(data, len); } tft_cs(1); } /* Open an address window and leave CS low with DC=data for a RAMWR stream. */ static void tft_window(int x, int y, int w, int h) { uint8_t ca[4] = {(uint8_t)(x >> 8), (uint8_t)x, (uint8_t)((x + w - 1) >> 8), (uint8_t)(x + w - 1)}; uint8_t ra[4] = {(uint8_t)(y >> 8), (uint8_t)y, (uint8_t)((y + h - 1) >> 8), (uint8_t)(y + h - 1)}; tft_cmd(0x2a, ca, 4); /* CASET */ tft_cmd(0x2b, ra, 4); /* RASET */ tft_cs(0); tft_dc(0); uint8_t ramwr = 0x2c; tft_xfer(&ramwr, 1); tft_dc(1); } /* Render one full text row (24 cells) into tft_row and send it as ONE * transaction. All drawing goes through this: fewest, largest transfers. */ static void tft_draw_row(int row, const char *text, uint16_t fg) { for (int y = 0; y < CELL_H; y++) { int fy = (y - 1) / 2; /* 1px top leading, 7 font rows at 2x, 1px bottom */ uint8_t *line = tft_row + (size_t)y * TFT_W * 2; for (int col = 0; col < TFT_COLS; col++) { char ch = text ? text[col] : ' '; if (ch == '\0') text = NULL, ch = ' '; if (ch < FONT_FIRST || ch > FONT_LAST) ch = '?'; const uint8_t *g = FONT5X7[ch - FONT_FIRST]; for (int x = 0; x < CELL_W; x++) { int fx = x / 2; int on = y >= 1 && y <= 14 && fx < FONT_W && (g[fx] >> fy) & 1; uint16_t c = on ? fg : C_BG; int o = (col * CELL_W + x) * 2; line[o] = (uint8_t)(c >> 8); line[o + 1] = (uint8_t)c; } } } tft_window(0, row * CELL_H, TFT_W, CELL_H); tft_xfer(tft_row, ROW_BYTES); tft_cs(1); } static void tft_text(int col, int row, const char *s, uint16_t fg) { char line[TFT_COLS + 1]; memset(line, ' ', sizeof line - 1); line[TFT_COLS] = '\0'; int n = (int)strlen(s); if (n > TFT_COLS - col) n = TFT_COLS - col; if (n > 0) memcpy(line + col, s, (size_t)n); tft_draw_row(row, line, fg); } /* ---- story console state ---- */ static char body[BODY_ROWS][TFT_COLS + 1]; static int body_row, body_col; static char word[TFT_COLS + 1]; static int word_len; static int64_t rate_t0; static int rate_tokens; static char stats_prev[TFT_COLS + 1]; static void tft_redraw_body(void) { for (int r = 0; r < BODY_ROWS; r++) tft_text(0, BODY_TOP + r, body[r], C_STORY); } static void tft_newline(void) { if (body_row < BODY_ROWS - 1) { body_row++; } else { memmove(body[0], body[1], sizeof(body) - sizeof(body[0])); body[BODY_ROWS - 1][0] = '\0'; body_col = 0; tft_redraw_body(); body_col = 0; body_row = BODY_ROWS - 1; return; } body_col = 0; } static void tft_put_word(void) { if (word_len == 0) return; if (body_col + word_len > TFT_COLS) tft_newline(); for (int i = 0; i < word_len && body_col < TFT_COLS; i++) { body[body_row][body_col] = word[i]; body[body_row][body_col + 1] = '\0'; body_col++; } word_len = 0; tft_text(0, BODY_TOP + body_row, body[body_row], C_STORY); } static void tft_stats_update(int force) { int64_t now = esp_timer_get_time(); if (!force && now - rate_t0 < 500000) return; char line[48]; /* roomy scratch; hard-cut to the 24-cell row below */ if (rate_tokens > 0 && now > rate_t0) { double rate = rate_tokens * 1e6 / (double)(now - rate_t0); snprintf(line, sizeof line, " %d tok @ %.1f tok/s", rate_tokens, rate); } else { snprintf(line, sizeof line, " thinking..."); } line[TFT_COLS] = '\0'; int n = (int)strlen(line); memset(line + n, ' ', (size_t)(TFT_COLS - n)); line[TFT_COLS] = '\0'; if (strcmp(line, stats_prev) != 0) { strcpy(stats_prev, line); tft_text(0, TFT_ROWS - 1, line, C_STATS); } } /* Observe one emit_bytes call (= one generated token's text). */ static void tft_story_bytes(const void *bytes, int len) { const char *p = (const char *)bytes; rate_tokens++; for (int i = 0; i < len; i++) { char c = p[i]; if (c == '\n') { tft_put_word(); tft_newline(); } else if (c == ' ') { tft_put_word(); if (body_col > 0) { if (body_col >= TFT_COLS) { tft_newline(); } else { body[body_row][body_col] = ' '; body[body_row][body_col + 1] = '\0'; body_col++; } } } else if (c >= 0x20 && c <= 0x7e) { if (word_len < TFT_COLS) word[word_len++] = c; if (word_len == TFT_COLS) tft_put_word(); /* unbreakable overlong run */ } } tft_stats_update(0); } /* New story: show the prompt, clear the body, restart the rate clock. */ static void tft_story_begin(const char *prompt) { tft_put_word(); memset(body, 0, sizeof body); body_row = 0; body_col = 0; word_len = 0; char line[TFT_COLS + 1]; snprintf(line, sizeof line, "> %.*s", TFT_COLS - 2, prompt); tft_text(0, 0, line, C_PROMPT); if (strlen(prompt) > (size_t)(TFT_COLS - 2)) { snprintf(line, sizeof line, " %.*s", TFT_COLS - 2, prompt + (TFT_COLS - 2)); tft_text(0, 1, line, C_PROMPT); } else { tft_draw_row(1, NULL, C_PROMPT); } tft_redraw_body(); rate_t0 = esp_timer_get_time(); rate_tokens = 0; stats_prev[0] = '\0'; tft_stats_update(1); } static void tft_story_end(void) { tft_put_word(); tft_stats_update(1); } static void tft_init(void) { gpio_config_t io = { .pin_bit_mask = (1ULL << TFT_PIN_CS) | (1ULL << TFT_PIN_DC) | (1ULL << TFT_PIN_RST), .mode = GPIO_MODE_OUTPUT, }; gpio_config(&io); tft_cs(1); tft_dc(1); tft_row = heap_caps_malloc(ROW_BYTES, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); if (!tft_row) return; /* no display worth crashing the model over */ spi_bus_config_t bus = { .mosi_io_num = TFT_PIN_MOSI, .miso_io_num = -1, .sclk_io_num = TFT_PIN_SCK, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = ROW_BYTES, }; spi_bus_initialize(SPI2_HOST, &bus, SPI_DMA_CH_AUTO); spi_device_interface_config_t dev = { .clock_speed_hz = TFT_HZ, .mode = 0, .spics_io_num = -1, /* CS is driven manually, like the seam contract */ .queue_size = 1, }; spi_bus_add_device(SPI2_HOST, &dev, &tft_spi); gpio_set_level(TFT_PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(10)); gpio_set_level(TFT_PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(120)); tft_cmd(0x01, NULL, 0); /* SWRESET */ vTaskDelay(pdMS_TO_TICKS(120)); tft_cmd(0x11, NULL, 0); /* SLPOUT */ vTaskDelay(pdMS_TO_TICKS(10)); uint8_t colmod = 0x05; /* RGB565 */ tft_cmd(0x3a, &colmod, 1); uint8_t madctl = 0x00; /* portrait, canvas-upright on the bench */ tft_cmd(0x36, &madctl, 1); tft_cmd(0x29, NULL, 0); /* DISPON */ for (int r = 0; r < TFT_ROWS; r++) tft_draw_row(r, NULL, C_BG); tft_text(0, 0, " PLE TinyLM 28.9M", C_TITLE); tft_text(0, 1, " staging model...", C_STORY); }
main/font5x7.h
/* * Classic 5x7 dot-matrix font, printable ASCII 0x20..0x7E: the ubiquitous * public-domain glyph set every character LCD/OLED library ships (column- * major, 5 bytes per glyph, bit 0 = top row). Rendered at 2x by tft.h. * Verified glyph-by-glyph against a host-side ASCII render * (tools/font_check.c) before committing — not trusted from memory. */ #pragma once #include <stdint.h> #define FONT_W 5 #define FONT_H 7 #define FONT_FIRST 0x20 #define FONT_LAST 0x7e static const uint8_t FONT5X7[95][5] = { {0x00, 0x00, 0x00, 0x00, 0x00}, /* ' ' */ {0x00, 0x00, 0x5f, 0x00, 0x00}, /* ! */ {0x00, 0x07, 0x00, 0x07, 0x00}, /* " */ {0x14, 0x7f, 0x14, 0x7f, 0x14}, /* # */ {0x24, 0x2a, 0x7f, 0x2a, 0x12}, /* $ */ {0x23, 0x13, 0x08, 0x64, 0x62}, /* % */ {0x36, 0x49, 0x55, 0x22, 0x50}, /* & */ {0x00, 0x05, 0x03, 0x00, 0x00}, /* ' */ {0x00, 0x1c, 0x22, 0x41, 0x00}, /* ( */ {0x00, 0x41, 0x22, 0x1c, 0x00}, /* ) */ {0x14, 0x08, 0x3e, 0x08, 0x14}, /* * */ {0x08, 0x08, 0x3e, 0x08, 0x08}, /* + */ {0x00, 0x50, 0x30, 0x00, 0x00}, /* , */ {0x08, 0x08, 0x08, 0x08, 0x08}, /* - */ {0x00, 0x60, 0x60, 0x00, 0x00}, /* . */ {0x20, 0x10, 0x08, 0x04, 0x02}, /* / */ {0x3e, 0x51, 0x49, 0x45, 0x3e}, /* 0 */ {0x00, 0x42, 0x7f, 0x40, 0x00}, /* 1 */ {0x42, 0x61, 0x51, 0x49, 0x46}, /* 2 */ {0x21, 0x41, 0x45, 0x4b, 0x31}, /* 3 */ {0x18, 0x14, 0x12, 0x7f, 0x10}, /* 4 */ {0x27, 0x45, 0x45, 0x45, 0x39}, /* 5 */ {0x3c, 0x4a, 0x49, 0x49, 0x30}, /* 6 */ {0x01, 0x71, 0x09, 0x05, 0x03}, /* 7 */ {0x36, 0x49, 0x49, 0x49, 0x36}, /* 8 */ {0x06, 0x49, 0x49, 0x29, 0x1e}, /* 9 */ {0x00, 0x36, 0x36, 0x00, 0x00}, /* : */ {0x00, 0x56, 0x36, 0x00, 0x00}, /* ; */ {0x08, 0x14, 0x22, 0x41, 0x00}, /* < */ {0x14, 0x14, 0x14, 0x14, 0x14}, /* = */ {0x00, 0x41, 0x22, 0x14, 0x08}, /* > */ {0x02, 0x01, 0x51, 0x09, 0x06}, /* ? */ {0x32, 0x49, 0x79, 0x41, 0x3e}, /* @ */ {0x7e, 0x11, 0x11, 0x11, 0x7e}, /* A */ {0x7f, 0x49, 0x49, 0x49, 0x36}, /* B */ {0x3e, 0x41, 0x41, 0x41, 0x22}, /* C */ {0x7f, 0x41, 0x41, 0x22, 0x1c}, /* D */ {0x7f, 0x49, 0x49, 0x49, 0x41}, /* E */ {0x7f, 0x09, 0x09, 0x09, 0x01}, /* F */ {0x3e, 0x41, 0x49, 0x49, 0x7a}, /* G */ {0x7f, 0x08, 0x08, 0x08, 0x7f}, /* H */ {0x00, 0x41, 0x7f, 0x41, 0x00}, /* I */ {0x20, 0x40, 0x41, 0x3f, 0x01}, /* J */ {0x7f, 0x08, 0x14, 0x22, 0x41}, /* K */ {0x7f, 0x40, 0x40, 0x40, 0x40}, /* L */ {0x7f, 0x02, 0x0c, 0x02, 0x7f}, /* M */ {0x7f, 0x04, 0x08, 0x10, 0x7f}, /* N */ {0x3e, 0x41, 0x41, 0x41, 0x3e}, /* O */ {0x7f, 0x09, 0x09, 0x09, 0x06}, /* P */ {0x3e, 0x41, 0x51, 0x21, 0x5e}, /* Q */ {0x7f, 0x09, 0x19, 0x29, 0x46}, /* R */ {0x46, 0x49, 0x49, 0x49, 0x31}, /* S */ {0x01, 0x01, 0x7f, 0x01, 0x01}, /* T */ {0x3f, 0x40, 0x40, 0x40, 0x3f}, /* U */ {0x1f, 0x20, 0x40, 0x20, 0x1f}, /* V */ {0x3f, 0x40, 0x38, 0x40, 0x3f}, /* W */ {0x63, 0x14, 0x08, 0x14, 0x63}, /* X */ {0x07, 0x08, 0x70, 0x08, 0x07}, /* Y */ {0x61, 0x51, 0x49, 0x45, 0x43}, /* Z */ {0x00, 0x7f, 0x41, 0x41, 0x00}, /* [ */ {0x02, 0x04, 0x08, 0x10, 0x20}, /* \ */ {0x00, 0x41, 0x41, 0x7f, 0x00}, /* ] */ {0x04, 0x02, 0x01, 0x02, 0x04}, /* ^ */ {0x40, 0x40, 0x40, 0x40, 0x40}, /* _ */ {0x00, 0x01, 0x02, 0x04, 0x00}, /* ` */ {0x20, 0x54, 0x54, 0x54, 0x78}, /* a */ {0x7f, 0x48, 0x44, 0x44, 0x38}, /* b */ {0x38, 0x44, 0x44, 0x44, 0x20}, /* c */ {0x38, 0x44, 0x44, 0x48, 0x7f}, /* d */ {0x38, 0x54, 0x54, 0x54, 0x18}, /* e */ {0x08, 0x7e, 0x09, 0x01, 0x02}, /* f */ {0x0c, 0x52, 0x52, 0x52, 0x3e}, /* g */ {0x7f, 0x08, 0x04, 0x04, 0x78}, /* h */ {0x00, 0x44, 0x7d, 0x40, 0x00}, /* i */ {0x20, 0x40, 0x44, 0x3d, 0x00}, /* j */ {0x7f, 0x10, 0x28, 0x44, 0x00}, /* k */ {0x00, 0x41, 0x7f, 0x40, 0x00}, /* l */ {0x7c, 0x04, 0x18, 0x04, 0x78}, /* m */ {0x7c, 0x08, 0x04, 0x04, 0x78}, /* n */ {0x38, 0x44, 0x44, 0x44, 0x38}, /* o */ {0x7c, 0x14, 0x14, 0x14, 0x08}, /* p */ {0x08, 0x14, 0x14, 0x18, 0x7c}, /* q */ {0x7c, 0x08, 0x04, 0x04, 0x08}, /* r */ {0x48, 0x54, 0x54, 0x54, 0x20}, /* s */ {0x04, 0x3f, 0x44, 0x40, 0x20}, /* t */ {0x3c, 0x40, 0x40, 0x20, 0x7c}, /* u */ {0x1c, 0x20, 0x40, 0x20, 0x1c}, /* v */ {0x3c, 0x40, 0x30, 0x40, 0x3c}, /* w */ {0x44, 0x28, 0x10, 0x28, 0x44}, /* x */ {0x0c, 0x50, 0x50, 0x50, 0x3c}, /* y */ {0x44, 0x64, 0x54, 0x4c, 0x44}, /* z */ {0x00, 0x08, 0x36, 0x41, 0x00}, /* { */ {0x00, 0x00, 0x7f, 0x00, 0x00}, /* | */ {0x00, 0x41, 0x36, 0x08, 0x00}, /* } */ {0x08, 0x08, 0x2a, 0x1c, 0x08}, /* ~ */ };



