Skip to the content
PCBKitNew board
2 layers118.1 × 105.8 mmHardGPT, the printed circuit board in 3D

GPT on a $1 chip (MSPM0G3507) - A tiny character-level transformer running on-chip

Size
118.1 × 105.8 mm
Copper layers
2
Thickness
1.6 mm
Parts
30
Nets
46
Pads
109
Tracks, vias
206, 0
Routed
Yes
KiCad
KiCad 9.0 (file 20241229)
Created
2 May 2026
Updated
2 May 2026

Overview

HardGPT is an open-source printed circuit board by hemeshch, from the hemeshch/HardGPT repository on GitHub. It is a 2-layer board of 118.1 × 105.8 mm with 30 parts on 46 nets, routed with 206 tracks and 0 vias.

It is built around the AMS1117. Its parts include 6 resistors, 6 connectors, 4 diodes, 4 switches and 3 capacitors.

Main components

1

README

README.md

HardGPT

A character-level GPT running entirely on a TI MSPM0G3507 Cortex-M0+ — no host PC, no network, no accelerator. Press Generate and the chip composes fake-Shakespeare one character at a time on a 16x2 LCD, beeping at the start and end. Three buttons, three LEDs, an active buzzer, and a dollar's worth of flash.

The model is a 3-layer transformer (4 heads, 48 embedding dims, block size 48, 65-character vocab) trained on the tiny-shakespeare corpus. Weights are int8 symmetric per-output-channel quantized so the whole network fits in flash, and the KV cache is also int8 to keep SRAM use sane during generation.

Etched PCB Film

Repo layout

text
HardGPT/
├── main.c              firmware entry point — UI, transformer inference loop
├── lcd.h               header-only HD44780-via-PCF8574 driver
├── weights.h           int8 weights + vocab, generated by training/export
├── hardgpt.syscfg      sysconfig project: clocks, GPIO, I2C
├── targetConfigs/      CCS debug configs for the launchpad
├── pcb/                kicad project for the board
├── img/                photos used in this readme
└── training/           offline training + weight export pipeline

How it works

The whole firmware lives in a single main.c because the model is small enough that splitting it would just add ceremony. Walking through it top to bottom:

Activation and KV-cache buffers

A handful of static SRAM arrays hold the activations of one token at a time:

  • x, x_residual, x_norm — the running embedding vector (length N_EMBD)
  • qkv — the combined query/key/value projection (length 3 * N_EMBD)
  • ffn_hidden — the 4×-wide MLP hidden state
  • logits — the output distribution over the 65-character vocabulary
  • x_q — int8 scratch buffer used whenever we need to quantize an activation

The KV cache is int8 kv_cache[N_LAYER][BLOCK_SIZE][2 * N_EMBD] with float kv_scales[...] alongside, so each cached K and V vector keeps its own quantization scale. Storing it as int8 cuts the cache footprint by ~4× versus float.

Math primitives

Everything that touches the hot path uses fast, approximate floats:

  • quantize_vec — symmetric int8 quantization of a vector with one shared scale, used to feed activations into the int8 matmul.
  • matvec_i8i8_pc — the workhorse. Multiplies an int8 weight matrix (with per-row scales) against an int8 activation vector (with one scale) and accumulates into int32, then converts to float at the end. This single routine handles QKV projection, attention output projection, and both MLP matmuls.
  • fast_invsqrt — the famous Quake 0x5f3759df trick + one Newton-Raphson step. Used by both LayerNorm and the attention scaling factor.
  • fast_expf — Schraudolph's bit-hack exp. Wildly inaccurate in absolute terms but plenty good once softmax normalizes.
  • fast_tanhf — a Padé approximation, ~1 % error in [-3, 3], used inside GELU.
  • layernorm, gelu, softmax — built on top of the above.

The transformer block

Layer is just a struct of pointers into weights.h so the block code can work with L->attn_qkv_w etc. instead of macro-mangled names. init_layers() wires layer 0/1/2 once at boot.

block_forward(layer_i, pos) does one transformer block:

  1. Save the residual, layernorm into x_norm.
  2. Quantize x_norm, run the QKV matmul, split into q, k, v.
  3. Quantize the new k and v and store them at kv_cache[layer_i][pos].
  4. For each head: dot the head's q against every cached k_t (dequantizing on the fly), scale by 1/sqrt(head_dim), softmax, then accumulate the weighted sum of cached v_t.
  5. Quantize the attention output, run the output projection, add the residual.
  6. LayerNorm again, run the 4× MLP with GELU, project back, add the residual.

forward(token_id, pos) adds the token + position embeddings, calls block_forward for each layer, runs the final LayerNorm, and reuses the embedding table as the LM head (weight tying) to produce logits.

slide_kv_cache() shifts the cache left by one when generation runs past BLOCK_SIZE, so the model always sees the most recent BLOCK_SIZE - 1 tokens.

Sampling

Two sampling modes:

  • sample_argmax — greedy.
  • sample_temp(temperature) — divide logits by temperature, softmax, draw from the distribution using a small linear-congruential RNG.

The probability of the chosen token is stashed in last_token_prob so the "confidence" LED can light up when the model is sure.

LCD typewriter

LcdView holds a 2-row shadow buffer plus the current (row, col). As characters arrive, lcd_view_putchar writes them straight to the LCD and mirrors them into the shadow buffer. When the cursor walks off row 1, we copy row 1 up to row 0, blank row 1, and continue — the result is a smooth typewriter that scrolls upward indefinitely.

UI / main loop

Boot sequence:

  1. SYSCFG_DL_init() brings up clocks/GPIO/I2C from sysconfig.
  2. init_layers() wires the layer pointer table.
  3. lcd_init() runs the HD44780 wake-up dance and switches it into 4-bit mode.
  4. A short buzzer beep confirms power-on.

Then the forever loop:

  • Idle screen says HardGPT v1.0 / Press Generate!. The buttons let you cycle through seeds (Random, KING, ROMEO, LORD, HENRY) or peek at the temperature.
  • Generate: beep, light up the "running" LED, clear the KV cache, print the seed name as a cosmetic prefix, then loop:
    • run forward(token, pos)
    • sample the next token
    • print it through the LCD typewriter
    • toggle LED 2 (one tick per token), light LED 3 if last_token_prob > 0.5
    • increment pos; slide the KV cache if we hit BLOCK_SIZE
    • bail out early if Generate is pressed again (interrupts the run)
  • After 300 tokens (or interrupt), beep twice, wait, and return to idle.

Hardware

PeripheralPinPurpose
16x2 LCDI2C0 — SDA on PA0, SCL on PA1shows generated text, scrolls upward (PCF8574 backpack)
Generate btnPA5 (input, internal pull-up)start / interrupt generation
Temp btnPA7 (input, internal pull-up)preview the sampling temperature
Seed btnPA8 (input, internal pull-up)cycle the seed character / name
LED 1PA12 (output)"running" — lit during generation
LED 2PA17 (output)toggles once per generated token
LED 3PA18 (output)lit when sampled token's probability > 0.5
BuzzerPA22 (output, NPN switch)boot beep + start/end-of-generation cue
SWDSWCLK on PA20, SWDIO on PA19flashing / debug from CCS

Pin assignments live in hardgpt.syscfg; the SDK generates ti_msp_dl_config.{c,h} into Debug/ from it.

Training

The model is trained offline using a trimmed-down fork of Andrej Karpathy's nanoGPT. Pretty much all of the training code (model.py, train.py, sample.py, configurator.py, the data prep scripts, the overall project structure) is his — go give that repo a star. The training pipeline lives in training/ and is a UV-managed Python project.

text
training/
├── pyproject.toml
├── uv.lock
├── nanoGPT/                  Karpathy's nanoGPT (trimmed to what we use)
│   ├── model.py              GPT model definition
│   ├── train.py              training loop
│   ├── sample.py             inference / sampling reference
│   ├── configurator.py
│   ├── config/train_micro.py our tiny config (3 layers, 48 dims, ...)
│   ├── data/shakespeare_char/ tiny-shakespeare, char-level tokenizer
│   ├── out-micro/ckpt.pt     trained checkpoint
│   ├── LICENSE               nanoGPT MIT license
│   └── README.md             upstream nanoGPT README
├── export/
│   ├── export_weights.py     converts ckpt.pt → weights.h
│   └── weights.h             generated header (also copied into the firmware)
└── inference_c/              desktop C reference impl, used to sanity-check
    ├── main.c                  the on-chip math before flashing
    └── weights.h

End-to-end flow:

text
training/nanoGPT/data/shakespeare_char/prepare.py
        │
        ▼     (tokenize)
   train.bin / val.bin
        │
        ▼     (uv run python nanoGPT/train.py config/train_micro.py)
training/nanoGPT/out-micro/ckpt.pt
        │
        ▼     (uv run python export/export_weights.py)
training/export/weights.h ── copied to ──▶ HardGPT/weights.h
                                                   │
                                                   ▼  (CCS build + flash)
                                            on-chip generation

config/train_micro.py sets the architecture used by the firmware (n_layer=3, n_head=4, n_embd=48, block_size=48, dropout 0.2) and trains for 30k iterations on shakespeare_char with the AdamW recipe nanoGPT ships with.

export_weights.py does symmetric int8 per-output-channel quantization of every weight matrix, keeps LayerNorm gains as float (they're tiny), and emits a single weights.h containing:

  • the architecture #defines (VOCAB_SIZE, N_LAYER, N_HEAD, N_EMBD, HEAD_DIM, BLOCK_SIZE)
  • the character vocab[] lookup table
  • token / position embeddings + per-row scales
  • per-layer attention QKV, attention proj, MLP fc, MLP proj weights and scales
  • per-layer LayerNorm gains as float
  • the final LayerNorm gain

That header is exactly what main.c #includes. The on-chip int8 matmul in matvec_i8i8_pc is the dequantization counterpart to what export_weights.py does at quantization time.

Reproducing

bash
cd training
uv sync                                       # install torch, numpy, etc.
uv run python nanoGPT/data/shakespeare_char/prepare.py
uv run python nanoGPT/train.py config/train_micro.py
uv run python export/export_weights.py
cp export/weights.h ../weights.h              # firmware picks it up on next build

Then build the firmware in CCS (Project → Clean → Build) and flash to the LP-MSPM0G3507.

Credits

  • Andrej Karpathy — the entire training stack (nanoGPT) is his work, used here under its MIT license. See training/nanoGPT/LICENSE.
  • Tiny-Shakespeare corpus from char-rnn, also via nanoGPT.
  • HardGPT firmware (main.c, lcd.h, weights.h packaging, sysconfig, board bring-up) is original to this project.

Parts

30
QtyValueFootprintReferences
3220 ohmsResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR3, R4, R5
3LEDLED_THT:LED_D3.0mmD1, D2, D3
10.1uFCapacitor_THT:C_Disc_D5.0mm_W2.5mm_P2.50mmC2
10.47uFCapacitor_THT:CP_Radial_D5.0mm_P2.00mmC1
1100kResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR1
11N4148Diode_THT:D_DO-35_SOD27_P7.62mm_HorizontalD4
11kResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR8
12N3904Package_TO_SOT_THT:TO-92_Inline_WideQ1
147kResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR2
147uCapacitor_THT:CP_Radial_D5.0mm_P2.00mmC4
1AMS1117Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_VerticalJ5
1BuzzerBuzzer_Beeper:Buzzer_TDK_PS1240P02BT_D12.2mm_H6.5mmBZ1
1Conn_01x08Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_VerticalJ6
1DebugConnector_PinHeader_2.54mm:PinHeader_1x05_P2.54mm_VerticalJ2
1GenerateButton_Switch_THT:SW_PUSH_6mmSW1
1LCDConnector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_VerticalJ3
1MSPM0 BreakoutPackage_DIP:DIP-32_W15.24mm_SocketJ1
1ModeButton_Switch_THT:SW_PUSH_6mmSW3
1PotentiometerPotentiometer_THT:Potentiometer_Alpha_RD901F-40-00D_Single_VerticalRV1
1SW_SPDTButton_Switch_THT:SW_Slide-03_Wuerth-WS-SLTV_10x2.5x6.4_P2.54mmSW4
1SeedButton_Switch_THT:SW_PUSH_6mmSW2
1UARTConnector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_VerticalJ4

The 26 most common of 30 parts.

Questions

How big is the HardGPT PCB?

It measures 118.1 × 105.8 mm and has 2 copper layers, 1.6 mm thick.

What parts does the HardGPT use?

30 parts, built around the AMS1117. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the HardGPT KiCad files?

From the hemeshch/HardGPT repository on GitHub, at the commit this page was made from: the board is pcb/hardgpt.kicad_pcb and its schematic pcb/hardgpt.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the HardGPT design in my own project?

Its repository states no licence, so its author keeps all rights: ask hemeshch before reusing the design.

Similar boards

  • 2 layers74.4 × 51.2 mmEsp32-wroom timer, the printed circuit board in 3D

    Esp32-wroom timer

    AlessandroAU/Chorus32-ESP32LapTimer

    This is an ESP32 port of the popular Chorus RF Laptimer

    66 parts102GPL-3.0
  • 2 layers44 × 20.5 mmQrng, the printed circuit board in 3D

    Qrng

    Spooky-Manufacturing/QRNG

    Quantum Random Number Generator

    29 parts86GPL-3.0
  • 4 layers125 × 90 mmAnalog board, the printed circuit board in 3D

    Analog board

    EERNINUO/ArbWave30

    ArbWave30 – 30MHz 任意波形发生器 | FPGA + 高速DAC | SCPI 可控

    295 parts53GPL-3.0
  • 2 layers100 × 45 mmTranspOtterNG, the printed circuit board in 3D

    TranspOtterNG

    Jana-Marie/transpOtterNG

    Next Generation of TranspOtter.

    63 parts51
  • 2 layers85 × 56 mmBasicengine, the printed circuit board in 3D

    Basicengine

    uli/basicengine-pcb

    Schematics and PCB layout for the BASIC Engine home computer

    115 parts48
  • 2 layers230 × 116 mmCvocd banana, the printed circuit board in 3D

    Cvocd banana

    hotchk155/cvocd.a

    176 parts38
  • 2 layers22 × 71 mmCircut, the printed circuit board in 3D

    Circut

    larry-wong/tiny_sensor

    基于 ESP32-S3 的温湿度 / 光照 / 人体存在传感器和红外发射器。

    18 parts34
  • 2 layers89.6 × 79 mmTDR 5351 core, the printed circuit board in 3D

    TDR 5351 core

    MR-DOS/TDR_diploma_thesis

    Time domain reflectometer. Includes Hardware, firmware and GUI in Octave. Also a diploma thesis.

    199 parts33GPL-3.0