Breadboard
docs
Example project · Builds & projects

Storyteller from source (ESP-IDF)

A real multi-file ESP-IDF C project you can read, edit and rebuild: a line-faithful port of Andrej Karpathy’s llama2.c running the 260K-parameter TinyStories model on the emulated ESP32-S3. The board boots and writes a deterministic story to the serial monitor, then takes your typed prompt and writes another. The Code tab holds the exact C source, CMake files, sdkconfig and partition table the firmware was built from — change the boot banner or the sampling loop, press Compile & run, and the cloud sandbox rebuilds the firmware with ESP-IDF v5.3.5 and boots your build on the bench.

The Storyteller from source (ESP-IDF) circuit as rendered by the simulator

What's on the bench

  • Battery
  • ESP32-S3

How it's wired

  • Battery · posESP32-S3 · vin
  • Battery · negESP32-S3 · gnd

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)
project(tinyllm-s3)

sdkconfig.defaults

# tinyllm-s3 — deterministic llama2.c inference on the ESP32-S3.
CONFIG_IDF_TARGET="esp32s3"

# 4 MB flash, custom table: one 3 MB factory app (code + embedded weights).
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"

# Full 240 MHz, -O2. No PSRAM on the target board profile.
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
CONFIG_COMPILER_OPTIMIZATION_PERF=y

# Data cache stays at the default 32 KB. Do NOT set DATA_CACHE_16KB: with a
# 16 KB dcache IDF exposes the unused half of the dcache SRAM as a heap block
# aliased at 0x3C000000 (heap/port/esp32s3/memory_layout.c "Level 10"), and
# the Breadboard emulator does not model that alias — heap_init then panics
# storing into what it maps as flash DROM.
CONFIG_ESP32S3_DATA_CACHE_32KB=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 long CPU burst on the main task; the idle task on
# core 0 legitimately starves during it. Do not arm the task watchdog.
# CONFIG_ESP_TASK_WDT_INIT is not set

# The whole model state is static; the main task only needs stack for the
# tokenizer scratch and printf.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192

# The 240 KB static KV cache leaves DIRAM tight: move IRAM-optional code to
# flash and use the ROM flash driver so the runtime heap keeps a real margin
# (~11 KB remained without these; boot needs ~20 KB+).
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

partitions.csv

# Name,   Type, SubType, Offset,  Size
nvs,      data, nvs,     0x9000,  0x6000
phy_init, data, phy,     0xf000,  0x1000
factory,  app,  factory, 0x10000, 0x300000

main/CMakeLists.txt

idf_component_register(
    SRCS "tinyllm_main.c" "model_blobs.S"
)

# The .S embeds the model via .incbin: hand it absolute paths, and rebuild it
# when the blobs change.
target_compile_definitions(${COMPONENT_LIB} PRIVATE
    "CKPT_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/model/stories260K.bin\""
    "TOK_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/model/tok512.bin\""
)
set_property(SOURCE model_blobs.S APPEND PROPERTY OBJECT_DEPENDS
    "${CMAKE_CURRENT_SOURCE_DIR}/model/stories260K.bin"
    "${CMAKE_CURRENT_SOURCE_DIR}/model/tok512.bin"
)

main/tinyllm_main.c

/*
 * tinyllm-s3 — Llama-2 Transformer inference on the ESP32-S3, ported from
 * Andrej Karpathy's llama2.c `run.c` (MIT License, Copyright (c) 2023 Andrej
 * Karpathy; https://github.com/karpathy/llama2.c). The neural-net blocks,
 * tokenizer, and generation loop below are kept line-faithful to upstream so
 * the port stays auditable against it. The platform seams differ:
 *
 *   - checkpoint + tokenizer are EMBED_FILES blobs read in place from the
 *     flash DROM window (the embedded analogue of upstream's read-only mmap;
 *     the 1.03 MB of fp32 weights never touch RAM),
 *   - RunState is statically allocated so the linker proves the SRAM fit at
 *     build time (no PSRAM on this board profile),
 *   - the KV cache is sized to SEQ_CAP=256 (the checkpoint allows 512, but
 *     512 needs a 640 KB cache — more than the S3's usable internal SRAM),
 *   - sampling is greedy argmax only (temperature 0), so a given prompt
 *     always yields the same story: deterministic by construction, which is
 *     what the emulator golden tests pin,
 *   - timing uses esp_timer, prompts arrive over the UART0 console.
 *
 * Model: stories260K (karpathy/tinyllamas, MIT) + tok512 tokenizer.
 */

#include <ctype.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "driver/uart.h"
#include "driver/uart_vfs.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"

// ----------------------------------------------------------------------------
// Model constants: stories260K. The checkpoint header is validated against
// these at boot; a mismatched blob aborts loudly instead of running garbage.

#define MODEL_DIM 64
#define MODEL_HIDDEN 172
#define MODEL_LAYERS 5
#define MODEL_HEADS 8
#define MODEL_KV_HEADS 4
#define MODEL_VOCAB 512
#define MODEL_HEAD_SIZE (MODEL_DIM / MODEL_HEADS)
#define MODEL_KV_DIM ((MODEL_DIM * MODEL_KV_HEADS) / MODEL_HEADS)

// The checkpoint was trained with seq_len 512, but a 512-position KV cache is
// 640 KB of fp32 — beyond the S3's usable SRAM. 192 positions (240 KB) is the
// most that links alongside ESP-IDF's own DRAM use while leaving real heap
// headroom (256 overflowed dram0_0_seg by 71 KB), and greedy TinyStories
// generations hit their EOS well inside it.
#define SEQ_CAP 192

#define MAX_PROMPT_CHARS 256
#define MAX_TOKEN_LEN 64  // checked against the tokenizer header at boot

// Blobs embedded by model_blobs.S with 16-byte alignment (still asserted at
// boot before use).
extern const uint8_t ckpt_start[] asm("tinyllm_ckpt_start");
extern const uint8_t ckpt_end[] asm("tinyllm_ckpt_end");
extern const uint8_t tok_start[] asm("tinyllm_tok_start");
extern const uint8_t tok_end[] asm("tinyllm_tok_end");

// ----------------------------------------------------------------------------
// Transformer model (faithful to run.c; pointers are const because the
// weights live in flash)

typedef struct {
    int dim;         // transformer dimension
    int hidden_dim;  // for ffn layers
    int n_layers;    // number of layers
    int n_heads;     // number of query heads
    int n_kv_heads;  // number of key/value heads (can be < query heads because of multiquery)
    int vocab_size;  // vocabulary size, usually 256 (byte-level)
    int seq_len;     // max sequence length
} Config;

typedef struct {
    const float* token_embedding_table;  // (vocab_size, dim)
    const float* rms_att_weight;         // (layer, dim) rmsnorm weights
    const float* rms_ffn_weight;         // (layer, dim)
    const float* wq;                     // (layer, dim, n_heads * head_size)
    const float* wk;                     // (layer, dim, n_kv_heads * head_size)
    const float* wv;                     // (layer, dim, n_kv_heads * head_size)
    const float* wo;                     // (layer, n_heads * head_size, dim)
    const float* w1;                     // (layer, hidden_dim, dim)
    const float* w2;                     // (layer, dim, hidden_dim)
    const float* w3;                     // (layer, hidden_dim, dim)
    const float* rms_final_weight;       // (dim,)
    const float* wcls;                   // (optional) classifier weights for the logits
} TransformerWeights;

// RunState, statically allocated (upstream callocs these). Sizes are the
// exact upstream formulas with seq_len = SEQ_CAP.
static float s_x[MODEL_DIM];
static float s_xb[MODEL_DIM];
static float s_xb2[MODEL_DIM];
static float s_hb[MODEL_HIDDEN];
static float s_hb2[MODEL_HIDDEN];
static float s_q[MODEL_DIM];
static float s_att[MODEL_HEADS * SEQ_CAP];
static float s_logits[MODEL_VOCAB];
static float s_key_cache[MODEL_LAYERS * SEQ_CAP * MODEL_KV_DIM];
static float s_value_cache[MODEL_LAYERS * SEQ_CAP * MODEL_KV_DIM];

static Config g_config;  // header values from the checkpoint (seq_len = 512)
static TransformerWeights g_weights;

static void memory_map_weights(TransformerWeights* w, Config* p, const float* ptr,
                               int shared_weights) {
    int head_size = p->dim / p->n_heads;
    unsigned long long n_layers = p->n_layers;
    w->token_embedding_table = ptr;
    ptr += p->vocab_size * p->dim;
    w->rms_att_weight = ptr;
    ptr += n_layers * p->dim;
    w->wq = ptr;
    ptr += n_layers * p->dim * (p->n_heads * head_size);
    w->wk = ptr;
    ptr += n_layers * p->dim * (p->n_kv_heads * head_size);
    w->wv = ptr;
    ptr += n_layers * p->dim * (p->n_kv_heads * head_size);
    w->wo = ptr;
    ptr += n_layers * (p->n_heads * head_size) * p->dim;
    w->rms_ffn_weight = ptr;
    ptr += n_layers * p->dim;
    w->w1 = ptr;
    ptr += n_layers * p->dim * p->hidden_dim;
    w->w2 = ptr;
    ptr += n_layers * p->hidden_dim * p->dim;
    w->w3 = ptr;
    ptr += n_layers * p->dim * p->hidden_dim;
    w->rms_final_weight = ptr;
    ptr += p->dim;
    ptr += p->seq_len * head_size / 2;  // skip what used to be freq_cis_real (for RoPE)
    ptr += p->seq_len * head_size / 2;  // skip what used to be freq_cis_imag (for RoPE)
    w->wcls = shared_weights ? w->token_embedding_table : ptr;
}

static void build_transformer(void) {
    if (((uintptr_t)ckpt_start & 3) != 0) {
        printf("[tinyllm] FATAL: checkpoint blob not 4-byte aligned\n");
        abort();
    }
    memcpy(&g_config, ckpt_start, sizeof(Config));
    int shared_weights = g_config.vocab_size > 0 ? 1 : 0;
    g_config.vocab_size = abs(g_config.vocab_size);
    if (g_config.dim != MODEL_DIM || g_config.hidden_dim != MODEL_HIDDEN ||
        g_config.n_layers != MODEL_LAYERS || g_config.n_heads != MODEL_HEADS ||
        g_config.n_kv_heads != MODEL_KV_HEADS || g_config.vocab_size != MODEL_VOCAB) {
        printf("[tinyllm] FATAL: checkpoint header does not match compiled model\n");
        abort();
    }
    // Sanity: the blob must hold exactly header + all weight floats.
    unsigned long long head_size = MODEL_HEAD_SIZE;
    unsigned long long want =
        (unsigned long long)g_config.vocab_size * MODEL_DIM +      // token embedding
        2ull * MODEL_LAYERS * MODEL_DIM +                          // rms att+ffn
        2ull * MODEL_LAYERS * MODEL_DIM * MODEL_DIM +              // wq, wo
        2ull * MODEL_LAYERS * MODEL_DIM * MODEL_KV_DIM +           // wk, wv
        3ull * MODEL_LAYERS * MODEL_DIM * MODEL_HIDDEN +           // w1, w2, w3
        MODEL_DIM +                                                // rms final
        (unsigned long long)g_config.seq_len * head_size +         // legacy freq_cis
        (shared_weights ? 0ull : (unsigned long long)MODEL_VOCAB * MODEL_DIM);
    unsigned long long have = (unsigned long long)(ckpt_end - ckpt_start);
    if (have != sizeof(Config) + want * sizeof(float)) {
        printf("[tinyllm] FATAL: checkpoint size %llu != expected %llu\n", have,
               sizeof(Config) + want * sizeof(float));
        abort();
    }
    const float* weights_ptr = (const float*)(ckpt_start + sizeof(Config));
    memory_map_weights(&g_weights, &g_config, weights_ptr, shared_weights);
}

// ----------------------------------------------------------------------------
// neural net blocks; the dynamics of the Transformer (verbatim from run.c,
// modulo const)

static void rmsnorm(float* o, const float* x, const float* weight, int size) {
    float ss = 0.0f;
    for (int j = 0; j < size; j++) {
        ss += x[j] * x[j];
    }
    ss /= size;
    ss += 1e-5f;
    ss = 1.0f / sqrtf(ss);
    for (int j = 0; j < size; j++) {
        o[j] = weight[j] * (ss * x[j]);
    }
}

static void softmax(float* x, int size) {
    float max_val = x[0];
    for (int i = 1; i < size; i++) {
        if (x[i] > max_val) {
            max_val = x[i];
        }
    }
    float sum = 0.0f;
    for (int i = 0; i < size; i++) {
        x[i] = expf(x[i] - max_val);
        sum += x[i];
    }
    for (int i = 0; i < size; i++) {
        x[i] /= sum;
    }
}

static void matmul(float* xout, const float* x, const float* w, int n, int d) {
    // W (d,n) @ x (n,) -> xout (d,)
    // by far the most amount of time is spent inside this little function
    for (int i = 0; i < d; i++) {
        float val = 0.0f;
        for (int j = 0; j < n; j++) {
            val += w[i * n + j] * x[j];
        }
        xout[i] = val;
    }
}

static float* forward(int token, int pos) {
    Config* p = &g_config;
    TransformerWeights* w = &g_weights;
    float* x = s_x;
    int dim = p->dim;
    int kv_dim = (p->dim * p->n_kv_heads) / p->n_heads;
    int kv_mul = p->n_heads / p->n_kv_heads;
    int hidden_dim = p->hidden_dim;
    int head_size = dim / p->n_heads;

    const float* content_row = w->token_embedding_table + token * dim;
    memcpy(x, content_row, dim * sizeof(*x));

    for (unsigned long long l = 0; l < p->n_layers; l++) {
        rmsnorm(s_xb, x, w->rms_att_weight + l * dim, dim);

        // key and value point to the kv cache (seq stride is SEQ_CAP here)
        int loff = l * SEQ_CAP * kv_dim;
        float* k = s_key_cache + loff + pos * kv_dim;
        float* v = s_value_cache + loff + pos * kv_dim;

        matmul(s_q, s_xb, w->wq + l * dim * dim, dim, dim);
        matmul(k, s_xb, w->wk + l * dim * kv_dim, dim, kv_dim);
        matmul(v, s_xb, w->wv + l * dim * kv_dim, dim, kv_dim);

        // RoPE relative positional encoding: complex-valued rotate q and k in each head
        for (int i = 0; i < dim; i += 2) {
            int head_dim = i % head_size;
            float freq = 1.0f / powf(10000.0f, head_dim / (float)head_size);
            float val = pos * freq;
            float fcr = cosf(val);
            float fci = sinf(val);
            int rotn = i < kv_dim ? 2 : 1;  // how many vectors? 2 = q & k, 1 = q only
            for (int vi = 0; vi < rotn; vi++) {
                float* vec = vi == 0 ? s_q : k;
                float v0 = vec[i];
                float v1 = vec[i + 1];
                vec[i] = v0 * fcr - v1 * fci;
                vec[i + 1] = v0 * fci + v1 * fcr;
            }
        }

        // multihead attention. iterate over all heads
        for (int h = 0; h < p->n_heads; h++) {
            float* q = s_q + h * head_size;
            float* att = s_att + h * SEQ_CAP;
            for (int t = 0; t <= pos; t++) {
                const float* kt = s_key_cache + loff + t * kv_dim + (h / kv_mul) * head_size;
                float score = 0.0f;
                for (int i = 0; i < head_size; i++) {
                    score += q[i] * kt[i];
                }
                score /= sqrtf(head_size);
                att[t] = score;
            }

            softmax(att, pos + 1);

            float* xb = s_xb + h * head_size;
            memset(xb, 0, head_size * sizeof(float));
            for (int t = 0; t <= pos; t++) {
                const float* vt = s_value_cache + loff + t * kv_dim + (h / kv_mul) * head_size;
                float a = att[t];
                for (int i = 0; i < head_size; i++) {
                    xb[i] += a * vt[i];
                }
            }
        }

        matmul(s_xb2, s_xb, w->wo + l * dim * dim, dim, dim);

        for (int i = 0; i < dim; i++) {
            x[i] += s_xb2[i];
        }

        rmsnorm(s_xb, x, w->rms_ffn_weight + l * dim, dim);

        // Now for FFN in PyTorch we have: self.w2(F.silu(self.w1(x)) * self.w3(x))
        matmul(s_hb, s_xb, w->w1 + l * dim * hidden_dim, dim, hidden_dim);
        matmul(s_hb2, s_xb, w->w3 + l * dim * hidden_dim, dim, hidden_dim);

        // SwiGLU non-linearity
        for (int i = 0; i < hidden_dim; i++) {
            float val = s_hb[i];
            val *= (1.0f / (1.0f + expf(-val)));
            val *= s_hb2[i];
            s_hb[i] = val;
        }

        matmul(s_xb, s_hb, w->w2 + l * dim * hidden_dim, hidden_dim, dim);

        for (int i = 0; i < dim; i++) {
            x[i] += s_xb[i];
        }
    }

    rmsnorm(x, x, w->rms_final_weight, dim);
    matmul(s_logits, x, w->wcls, p->dim, p->vocab_size);
    return s_logits;
}

// ----------------------------------------------------------------------------
// The Byte Pair Encoding (BPE) Tokenizer that translates strings <-> tokens
// (faithful to run.c; file IO replaced by an in-place parse of the embedded
// tok512.bin into a static arena)

typedef struct {
    const char* str;
    int id;
} TokenIndex;

static const char* g_vocab[MODEL_VOCAB];
static float g_vocab_scores[MODEL_VOCAB];
static TokenIndex g_sorted_vocab[MODEL_VOCAB];
static unsigned int g_max_token_length;
static unsigned char g_byte_pieces[512];  // stores all single-byte strings
static char g_vocab_arena[8192];

static int compare_tokens(const void* a, const void* b) {
    return strcmp(((const TokenIndex*)a)->str, ((const TokenIndex*)b)->str);
}

static void build_tokenizer(void) {
    for (int i = 0; i < 256; i++) {
        g_byte_pieces[i * 2] = (unsigned char)i;
        g_byte_pieces[i * 2 + 1] = '\0';
    }
    const uint8_t* p = tok_start;
    const uint8_t* end = tok_end;
    memcpy(&g_max_token_length, p, sizeof(int));
    p += sizeof(int);
    if (g_max_token_length > MAX_TOKEN_LEN) {
        printf("[tinyllm] FATAL: tokenizer max_token_length %u > %d\n", g_max_token_length,
               MAX_TOKEN_LEN);
        abort();
    }
    char* arena = g_vocab_arena;
    char* arena_end = g_vocab_arena + sizeof(g_vocab_arena);
    for (int i = 0; i < MODEL_VOCAB; i++) {
        int len;
        if (p + sizeof(float) + sizeof(int) > end) {
            printf("[tinyllm] FATAL: tokenizer blob truncated at token %d\n", i);
            abort();
        }
        memcpy(&g_vocab_scores[i], p, sizeof(float));
        p += sizeof(float);
        memcpy(&len, p, sizeof(int));
        p += sizeof(int);
        if (len < 0 || p + len > end || arena + len + 1 > arena_end) {
            printf("[tinyllm] FATAL: tokenizer blob invalid at token %d\n", i);
            abort();
        }
        memcpy(arena, p, len);
        arena[len] = '\0';
        g_vocab[i] = arena;
        arena += len + 1;
        p += len;
    }
    if (p != end) {
        printf("[tinyllm] FATAL: tokenizer blob has %d trailing bytes\n", (int)(end - p));
        abort();
    }
    for (int i = 0; i < MODEL_VOCAB; i++) {
        g_sorted_vocab[i].str = g_vocab[i];
        g_sorted_vocab[i].id = i;
    }
    qsort(g_sorted_vocab, MODEL_VOCAB, sizeof(TokenIndex), compare_tokens);
}

static const char* decode(int prev_token, int token) {
    const char* piece = g_vocab[token];
    // following BOS (1) token, sentencepiece decoder strips any leading whitespace
    if (prev_token == 1 && piece[0] == ' ') {
        piece++;
    }
    // careful, some tokens designate raw bytes, and look like e.g. '<0x01>'
    unsigned char byte_val;
    if (sscanf(piece, "<0x%02hhX>", &byte_val) == 1) {
        piece = (const char*)g_byte_pieces + byte_val * 2;
    }
    return piece;
}

static void safe_printf(const char* piece) {
    // piece might be a raw byte token, and we only want to print printable
    // chars or whitespace
    if (piece == NULL) {
        return;
    }
    if (piece[0] == '\0') {
        return;
    }
    if (piece[1] == '\0') {
        unsigned char byte_val = piece[0];
        if (!(isprint(byte_val) || isspace(byte_val))) {
            return;  // bad byte, don't print it
        }
    }
    printf("%s", piece);
}

static int str_lookup(const char* str, const TokenIndex* sorted_vocab, int vocab_size) {
    TokenIndex tok = {.str = str};
    const TokenIndex* res =
        bsearch(&tok, sorted_vocab, vocab_size, sizeof(TokenIndex), compare_tokens);
    return res != NULL ? res->id : -1;
}

static void encode(const char* text, int8_t bos, int8_t eos, int* tokens, int* n_tokens) {
    // encode the string text (input) into an upper-bound preallocated tokens[]
    // array; bos != 0 prepends BOS (=1), eos != 0 appends EOS (=2)
    static char str_buffer[MAX_TOKEN_LEN * 2 + 1 + 2];
    size_t str_len = 0;

    *n_tokens = 0;
    if (bos) tokens[(*n_tokens)++] = 1;

    // add_dummy_prefix is true by default: prepend a dummy prefix token to the
    // input string, but only if text != ""
    if (text[0] != '\0') {
        int dummy_prefix = str_lookup(" ", g_sorted_vocab, MODEL_VOCAB);
        tokens[(*n_tokens)++] = dummy_prefix;
    }

    // process the raw (UTF-8) byte sequence of the input string
    for (const char* c = text; *c != '\0'; c++) {
        if ((*c & 0xC0) != 0x80) {
            str_len = 0;
        }
        str_buffer[str_len++] = *c;
        str_buffer[str_len] = '\0';
        if ((*(c + 1) & 0xC0) == 0x80 && str_len < 4) {
            continue;
        }
        int id = str_lookup(str_buffer, g_sorted_vocab, MODEL_VOCAB);
        if (id != -1) {
            tokens[(*n_tokens)++] = id;
        } else {
            // byte_fallback encoding: encode each byte as a token (+3 because
            // the first 3 vocab elements are <unk>, <s>, </s>)
            for (size_t i = 0; i < str_len; i++) {
                tokens[(*n_tokens)++] = (unsigned char)str_buffer[i] + 3;
            }
        }
        str_len = 0;
    }

    // merge the best consecutive pair each iteration, per the vocab_scores
    while (1) {
        float best_score = -1e10;
        int best_id = -1;
        int best_idx = -1;

        for (int i = 0; i < (*n_tokens - 1); i++) {
            snprintf(str_buffer, sizeof(str_buffer), "%s%s", g_vocab[tokens[i]],
                     g_vocab[tokens[i + 1]]);
            int id = str_lookup(str_buffer, g_sorted_vocab, MODEL_VOCAB);
            if (id != -1 && g_vocab_scores[id] > best_score) {
                best_score = g_vocab_scores[id];
                best_id = id;
                best_idx = i;
            }
        }

        if (best_idx == -1) {
            break;
        }

        tokens[best_idx] = best_id;
        for (int i = best_idx + 1; i < (*n_tokens - 1); i++) {
            tokens[i] = tokens[i + 1];
        }
        (*n_tokens)--;
    }

    if (eos) tokens[(*n_tokens)++] = 2;
}

// ----------------------------------------------------------------------------
// sampling: greedy argmax only (temperature 0) — deterministic by construction

static int sample_argmax(const float* probabilities, int n) {
    int max_i = 0;
    float max_p = probabilities[0];
    for (int i = 1; i < n; i++) {
        if (probabilities[i] > max_p) {
            max_i = i;
            max_p = probabilities[i];
        }
    }
    return max_i;
}

// ----------------------------------------------------------------------------
// generation loop (faithful to run.c generate(); esp_timer for tok/s)

static void generate(const char* prompt, int steps) {
    static int prompt_tokens[MAX_PROMPT_CHARS + 3];  // +3 for '\0', ?BOS, ?EOS
    int num_prompt_tokens = 0;
    encode(prompt, 1, 0, prompt_tokens, &num_prompt_tokens);
    if (num_prompt_tokens < 1) {
        printf("[tinyllm] FATAL: expected at least 1 prompt token\n");
        abort();
    }

    int64_t start = 0;  // timer starts after the first iteration, as upstream
    int next;
    int token = prompt_tokens[0];
    int pos = 0;
    while (pos < steps) {
        float* logits = forward(token, pos);

        if (pos < num_prompt_tokens - 1) {
            next = prompt_tokens[pos + 1];
        } else {
            next = sample_argmax(logits, g_config.vocab_size);
        }
        pos++;

        // data-dependent terminating condition: the BOS (=1) token delimits sequences
        if (next == 1) {
            break;
        }

        const char* piece = decode(token, next);
        safe_printf(piece);
        fflush(stdout);
        token = next;

        if (start == 0) {
            start = esp_timer_get_time();
        }
    }
    printf("\n");

    if (pos > 1) {
        int64_t end = esp_timer_get_time();
        double secs = (end - start) / 1e6;
        printf("[tinyllm] %d tokens, achieved tok/s: %.2f\n", pos, (pos - 1) / secs);
    }
}

// ----------------------------------------------------------------------------
// console: blocking line input over UART0

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, cap, stdin) == NULL) {
        buf[0] = '\0';
        return;
    }
    buf[strcspn(buf, "\r\n")] = '\0';
}

void app_main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    printf("\n[tinyllm] llama2.c stories260K on ESP32-S3 (fp32, flash-resident weights)\n");
    build_transformer();
    build_tokenizer();
    printf("[tinyllm] model: dim=%d hidden=%d layers=%d heads=%d kv_heads=%d vocab=%d seq=%d (cap %d)\n",
           g_config.dim, g_config.hidden_dim, g_config.n_layers, g_config.n_heads,
           g_config.n_kv_heads, g_config.vocab_size, g_config.seq_len, SEQ_CAP);
    printf("[tinyllm] free heap: %u bytes\n", (unsigned)esp_get_free_heap_size());

    const char* boot_prompt = "Once upon a time";
    printf("[tinyllm] boot story (greedy, prompt \"%s\"):\n\n", boot_prompt);
    generate(boot_prompt, SEQ_CAP);
    printf("[tinyllm] boot story done\n");

    console_init();
    static char line[MAX_PROMPT_CHARS];
    while (1) {
        printf("\nprompt> ");
        read_prompt(line, sizeof(line));
        printf("[prompt: %s]\n\n", line);
        generate(line[0] != '\0' ? line : "Once upon a time", SEQ_CAP);
    }
}

main/model_blobs.S

/*
 * Embedded model assets with guaranteed alignment. IDF's EMBED_FILES gives
 * no alignment guarantee (the stories260K blob landed on an odd address),
 * and the fp32 checkpoint is read in place from DROM with 4-byte loads.
 * CKPT_PATH / TOK_PATH are absolute paths injected by main/CMakeLists.txt.
 */
    .section .rodata.tinyllm_model, "a"
    .balign 16
    .global tinyllm_ckpt_start
tinyllm_ckpt_start:
    .incbin CKPT_PATH
    .global tinyllm_ckpt_end
tinyllm_ckpt_end:

    .balign 16
    .global tinyllm_tok_start
tinyllm_tok_start:
    .incbin TOK_PATH
    .global tinyllm_tok_end
tinyllm_tok_end:

Related projects