cd /news/artificial-intelligence/hn-i-ran-a-56m-parameter-llm-across-… Β· home β€Ί topics β€Ί artificial-intelligence β€Ί article
[ARTICLE Β· art-80421] src=github.com β†— pub= topic=artificial-intelligence verified=true sentiment=↑ positive

HN: I ran a 56M parameter LLM across three ESP32-S3 boards via ESP-NOW

A developer ran a 56M-parameter language model across three ESP32-S3 N16R8 microcontrollers using ESP-NOW wireless communication, achieving distributed inference of a 50.3M-parameter PLE-based transformer trained on WikiText-103. The system splits the model across boards with a web interface for real-time text generation, demonstrating feasibility of running large language models on low-power embedded devices.

read10 min views1 publishedJul 30, 2026
HN: I ran a 56M parameter LLM across three ESP32-S3 boards via ESP-NOW
Image: source

Distributed micro-LLM inference across three ESP32-S3 N16R8 boards with ESP-NOW communication.

This project implements a distributed AI system that runs a 56M-parameter language model across three ESP32-S3 microcontrollers. Inspired by slvDev/esp32-ai, which demonstrated running TinyStories on a single board, this project extends the architecture to a multi-board distributed system with web-based interaction.

The model is trained on WikiText-103 (Wikipedia corpus) using Per-Layer Embeddings (PLE) from Google's Gemma architecture, quantized to 4-bit, and split across three boards that communicate via ESP-NOW wireless protocol. The 50.3M-parameter PLE table is split across Board A and Board B (Split-PLE) to fit within the 16MB flash per board. Board B maintains a KV cache in PSRAM (1.5 MB for 256 positions), enabling the transformer to attend to the full generated sequence instead of operating token-by-token.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      ESP-NOW      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      ESP-NOW     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    Board A        β”‚ ◄──────────────► β”‚    Board B        β”‚ ◄──────────────► β”‚    Board C        β”‚
β”‚   Embeddings +    β”‚                  β”‚   Core + KV Cache β”‚                  β”‚   PLE_A + Decoder β”‚
β”‚   PLE_Proj + Head β”‚                  β”‚                   β”‚                  β”‚   + WiFi          β”‚
β”‚                   β”‚                  β”‚                   β”‚                  β”‚                   β”‚
β”‚ β€’ BPE Tokenizer   β”‚                  β”‚ β€’ 6 Attn layers   β”‚                  β”‚ β€’ PLE table A     β”‚
β”‚ β€’ tok_emb 8-bit   β”‚                  β”‚ β€’ 6 FFN layers    β”‚                  β”‚   (12.5 MB)       β”‚
β”‚ β€’ ple_model_proj  β”‚                  β”‚ β€’ KV Cache (128)  β”‚                  β”‚ β€’ Sampling        β”‚
β”‚ β€’ out_norm + head β”‚                  β”‚ β€’ PLE_B (half)    β”‚                  β”‚ β€’ Web Server      β”‚
β”‚                   β”‚                  β”‚ β€’ PLE Gating      β”‚                  β”‚ β€’ Space heuristic β”‚
β”‚ Flash: 4.31 MB    β”‚                  β”‚ Flash: 13.71 MB   β”‚                  β”‚ Flash: 13.37 MB   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Browser ──WiFi──► Board C ──ESP-NOW──► Board A ──ESP-NOW──► Board B
                        ◄─────────────── ◄──────────────

User types a prompt in the browser (connected to Board C's WiFi AP) and clicks "Generate"Board C receives the prompt via HTTP POST β†’ forwards it to Board A via ESP-NOW (MSG_EMBED_REQUEST

)Board A tokenizes the prompt text β†’ for each token: a. Looks up token embeddingx[D]

(8-bit tok_emb) b. Computes local PLE projection:tmpP = ple_model_proj_a @ x

, RMS-norms it c. Requests PLE table row fromBoard C via ESP-NOW: sendstoken_id

β†’ C looks upple_table_a[token]

β†’ sends row back d. Combines:ple = (tmpP + trow * sqrt(Ph)) / sqrt(2)

β†’ sendstoken_id + x[D] + ple[LΓ—Ph]

toBoard B e.Board B computes PLE_B[LΓ—Ph] from its local table β†’ combines PLE_A + PLE_B into full PLE[LΓ—P] β†’ runs 6 transformer layers (attention + FFN + PLE gating) β†’ sends hidden statex[D]

back to Board A f.Board A appliesout_norm

β†’ computes output head:logits[V] = tok_emb^T Β· rmsnorm(x)

β†’ sends top-40 token IDs to Board C g.Board C samples the next token β†’ decodes via BPE vocab β†’ appends to generated outputBoard C streams the generated text to the browser via SSE (/stream

endpoint)User sees the text appear in real-time on the web page

The 50.3M-parameter PLE table (vocab Γ— layers Γ— 256) is split into two 128-dimension halves:

PLE_A(Board C, 12.5 MB, 4-bit group=64): first 128 dims per layer β€” reloaded from Board C at runtime via ESP-NOW (MSG_PLE_REQUEST / MSG_PLE_RESULT)PLE_B(Board B, 12.6 MB, 4-bit group=128): last 128 dims per layer β€” local on Board B

Board A keeps the small ple_model_proj

(~50 KB, 4-bit) and ple_proj_norm

(~0.5 KB, fp32) for local projection. The 12.5 MB PLE table was moved from Board A to Board C to fix partition overflow on Board A and allow upgrading tok_emb

to 8-bit for better inference quality.

Each token in the autoregressive loop previously ran through Board B's transformer with seq_len=1

β€” attention only saw the current token, not the history. This crippled coherence because the model was designed for seq_len=128

context.

Board B now maintains a KV cache in PSRAM (1.5 MB for 256 positions):

  • For each new token, Q is computed normally, while K and V are appended to per-layer caches
  • Attention scores are computed against all cached positions(not just the current token) - RoPE uses the actual position index instead of pos=0

β€” for the first time the transformer sees proper position-aware attention - Cache is reset on MSG_SEQ_START

(new prompt) and caps atseq_len=256

  • No changes to A, C, or the wireless protocol

This is the single largest quality improvement: the transformer finally works as designed, attending to the full generated sequence instead of operating token-by-token in isolation.

Board MAC Address Serial Port
A (Embeddings + PLE_Proj + Head) 14:c1:9f:2a:ac:c8
/dev/cu.usbmodem5C372059631
B (Core + KV Cache) 14:c1:9f:2c:91:10
/dev/cu.usbmodem5C372065471
C (PLE_A + Decoder + WiFi AP) 28:84:85:51:dc:10
/dev/cu.usbmodem5C4D0363671

ESP-NOW wireless peer-to-peer (no router needed)- Packets ≀ 250 bytes with automatic fragmentation

  • Custom protocol with sequence numbers and acknowledgments
  • Estimated latency: 1–5ms per round-trip
esp32s3/
β”œβ”€β”€ README.md                       # This file (English)
β”œβ”€β”€ README.es.md                    # Spanish version
β”œβ”€β”€ pyproject.toml                  # Python dependencies
β”‚
β”œβ”€β”€ src/                            # Training pipeline (runs on PC/Mac)
β”‚   β”œβ”€β”€ model.py                    # PLE TinyLM architecture (PyTorch)
β”‚   β”œβ”€β”€ dataset.py                  # TinyStories/WikiText-103 data 
β”‚   β”œβ”€β”€ train.py                    # Training loop
β”‚   β”œβ”€β”€ quantize.py                 # 4-bit group-wise quantization
β”‚   β”œβ”€β”€ export.py                   # Export to 3 board binaries
β”‚   └── gen_assets.py               # Generate vocab.h for firmware
β”‚
β”œβ”€β”€ firmware/                       # ESP32-S3 firmware (Arduino IDE)
β”‚   β”œβ”€β”€ common/
β”‚   β”‚   β”œβ”€β”€ llm.h                   # Distributed C inference runtime
β”‚   β”‚   └── espnow_protocol.h       # ESP-NOW message protocol
β”‚   β”‚
β”‚   β”œβ”€β”€ board_a_embeddings/         # Board A firmware
β”‚   β”‚   β”œβ”€β”€ board_a_embeddings.ino  # Main sketch
β”‚   β”‚   β”œβ”€β”€ partitions.csv          # Flash partition table
β”‚   β”‚   └── vocab.h                 # Generated tokenizer vocabulary
β”‚   β”‚
β”‚   β”œβ”€β”€ board_b_core/               # Board B firmware
β”‚   β”‚   β”œβ”€β”€ board_b_core.ino        # Main sketch
β”‚   β”‚   └── partitions.csv
β”‚   β”‚
β”‚   β”œβ”€β”€ board_c_decoder/            # Board C firmware
β”‚   β”‚   β”œβ”€β”€ board_c_decoder.ino     # Main sketch (WiFi + Web UI)
β”‚   β”‚   β”œβ”€β”€ partitions.csv
β”‚   β”‚   └── vocab.h                 # Generated tokenizer vocabulary
β”‚   β”‚
β”‚   └── model/                      # Exported model binaries (gitignored)
β”‚       β”œβ”€β”€ board_a.bin             # 4.31 MB (tok_emb 8-bit + ple_proj + out_norm)
β”‚       β”œβ”€β”€ board_b.bin             # 13.71 MB (transformer layers + PLE_B)
β”‚       β”œβ”€β”€ board_c.bin             # 13.37 MB (PLE table A, 4-bit group=64)
β”‚       β”œβ”€β”€ golden.npz              # Reference logits for verification
β”‚       └── golden.txt              # Text-format golden reference
β”‚
β”œβ”€β”€ tools/                          # Utility scripts
β”‚   β”œβ”€β”€ flash_all.sh                # Flash all 3 boards
β”‚   β”œβ”€β”€ verify_models.py            # Verify exported binaries
β”‚   └── setup_env.sh                # Install Python dependencies
β”‚
β”œβ”€β”€ data/                           # Dataset (gitignored)
β”‚   β”œβ”€β”€ tinystories/                # Raw TinyStories text
β”‚   β”œβ”€β”€ wikitext103/                # Raw WikiText-103 text
β”‚   β”œβ”€β”€ tokenizer/                  # Trained BPE tokenizer
β”‚   β”œβ”€β”€ train.bin                   # Tokenized training data (112M tokens)
β”‚   └── val.bin                     # Tokenized validation data
β”‚
└── runs/                           # Training checkpoints (gitignored)
    β”œβ”€β”€ ple-wiki60m-s0.pt            # 56M model checkpoint
    β”œβ”€β”€ ple-wiki60m-s0.json          # Training history
    └── train.log                   # Training output log

| Parameter | Value | |---|---|---| | Architecture | Tiny decoder-only transformer with Split-PLE | | Total Parameters | 56.0M stored | | Core (dense, SRAM) | 1.5M | | PLE Table (flash, split) | 50.3M (25.2M per board) | | Output Head (tied) | 4.2M | | Vocabulary Size | 32,768 (BPE, WikiText-103) | | d_model | 128 | | n_layers | 6 | | n_heads | 4 | | ffn_hidden | 223 | | ple_dim | 256 (128 per board) | | Quantization | tok_emb: 8-bit group=128, PLE table A: 4-bit group=64, rest: 4-bit group=128 | | Model Binary Size | 31.39 MB total (A: 4.31, B: 13.71, C: 13.37) | | Dataset | WikiText-103 (Wikipedia) | | Training Steps | 12,000 | | Batch Size | 8 | | Sequence Length | 128 |

Metric Value
Final Validation Loss (fp32) 5.26
Perplexity (fp32) 192
4-bit Quantization Degradation +0.62 nats
4-bit Perplexity 358
Training Tokens 12.3M
Training Time ~6.9 hours (Mac i7 CPU)
  • Python 3.11+ uvpackage manager- Arduino IDE with ESP32 board package
  • 3x ESP32-S3 N16R8 boards
source .venv/bin/activate
python src/dataset.py --dataset wikitext103
python src/train.py --arm ple --d-model 128 --n-layers 6 --ple-dim 256 \
  --target-core 1500000 --batch-size 8 --seq-len 128 --steps 12000 --tag wiki60m
python src/quantize.py --tag wiki60m
python src/export.py ple-wiki60m-s0
python src/gen_assets.py
./tools/flash_all.sh /dev/cu.usbmodemXXXX

Note:All 3 boards are already flashed. If you need to reflash, seetools/flash_all.sh

.

Power on all three boards

Connect your phone/laptop to WiFi:

ESP32-DIST-AI(password:ai123456

) - Open

http://192.168.4.1

in your browser - Type a prompt and click "Generate"

Architecture β€” PLE TinyLM v2, Split-PLE, 56M params #

  • PLE TinyLM v2 with d_model=128, n_layers=6, ple_dim=256
  • Split-PLE: PLE table divided across Board A + Board C (128+128 per layer)
  • PLE table A moved from Board A to Board C (fixed partition overflow on A)
  • Per-token A↔C PLE request/response via ESP-NOW
  • KV cache on Board B: full multi-head causal attention with K/V caching in PSRAM (1.5 MB for 256 positions), RoPE uses actual position indices
  • MAX_SEQ_LEN=256, gen loop=128, supporting 30+ word sequences
  • srand(esp_random()) for non-deterministic sampling

Training β€” WikiText-103, PPL 192, 12K steps, 12.3M tokens #

  • WikiText-103 dataset download and BPE tokenization (112M tokens)
  • Model training: 12,000 steps, batch_size=8, seg_len=128, 12.3M tokens
  • Final fp32 loss: 5.26, Perplexity: 192
  • Training time: ~6.9 hours (Mac i7 CPU)

Quantization & Export β€” 4-bit, 31.39 MB total #

  • 4-bit group-wise quantization (+0.62 nats degradation from fp32, 4-bit PPL 358)
  • tok_emb: 8-bit group=128, PLE table A: 4-bit group=64, rest: 4-bit group=128
  • Export to 3 board binaries: A: 4.31 MB, B: 13.71 MB, C: 13.37 MB
  • Reference golden logits for verification

Firmware β€” 3 boards, ESP-NOW, Web UI, autoregressive loop #

  • Board A: tokenizer + 8-bit tok_emb + PLE projection + output head
  • Board B: 6 transformer layers + PLE_B + PLE gating + KV cache
  • Board C: PLE table A (4-bit group=64) + decoder + WiFi AP + Web UI
  • ESP-NOW protocol with fragmentation, sequence numbers, and acks
  • Web UI for prompt input, streaming output, and SSE endpoint
  • Autoregressive generation loop (up to 128 tokens) across all 3 boards
  • Space-insertion heuristic on Board C
  • Arduino core 3.3.11 compatibility, partition table 1.25 MB

Flash & Deploy β€” 3 boards flashed with firmware + model #

  • Board A: MAC 14:c1:9f:2a:ac:c8

/ port5C372059631

  • Board B: MAC 14:c1:9f:2c:91:10

/ port5C372065471

  • Board C: MAC 28:84:85:51:dc:10

/ port5C4D0363671

  • Flash and verification scripts

Testing β€” Local inference confirmed coherent output (~30 words) #

  • Local inference test confirmed ~30 coherent words

  • Confirmed 4-bit quantization is the quality bottleneck (not protocol)

  • Comparison between full fp32, quantized, and on-device inference

BPE tokenizer in Cβ€” Replace brute-force vocab search with proper BPE merge implementation - Static ESP-NOW MACsβ€” Configure known MACs instead of broadcast - Web UI improvementsβ€” Streaming token display, temperature/top-k controls, status indicators - Error handlingβ€” ESP-NOW retransmission and timeout on packet loss - Power optimizationβ€” Deep sleep between inference requests - Audio outputβ€” I2S speaker integration (future)

Component Quantity Notes
ESP32-S3 N16R8 3 512KB SRAM, 8MB PSRAM, 16MB flash
USB-C cables 3 For flashing firmware
Computer 1 Mac/Linux/Windows with Arduino IDE

This project extends the work of:

β€” Running 28.9M parameter LLM on a single ESP32-S3 using Per-Layer EmbeddingsslvDev/esp32-aiGoogle Gemmaβ€” Per-Layer Embeddings architecture** WikiText-103**β€” Dataset by Salesforce Research (arXiv:1609.07843)** TinyStories**β€” Dataset by Eldan & Li (Microsoft Research,arXiv:2305.07759)β€” Inspiration for running tiny LMs in plain Ckarpathy/llama2.c

MIT

── more in #artificial-intelligence 4 stories Β· sorted by recency
── more on @esp32-s3 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/hn-i-ran-a-56m-param…] indexed:0 read:10min 2026-07-30 Β· β€”