Back in April, I set out with a simple goal: build and understand a Transformer language model end to end — not just training it, but everything that happens after, all the way down to the metal.
The spark came from contributing to llama.cpp, where I implemented a SIMD-vectorized version of ggml_vec_dot_q4_1_q8_1 — a quantized dot-product kernel used during inference. That PR taught me a lot about one optimized operation. But it also left me with a bigger question: what happens around that one kernel? How does a model actually get from "trained weights" to "text streaming out token by token," and what does it take to make that fast?
I decided the only real way to find out was to build the whole stack myself — training included.
I implemented a decoder-only Transformer in PyTorch and trained a ~30M parameter GPT on the TinyStories dataset, on Kaggle, for 2 epochs. Nothing exotic here — the interesting part of this project was never the training run, it was what came after.
| Parameter | Value |
|---|---|
| Parameters | ~30M |
| \(d_{\text{model}}\) | 256 |
| Heads | 8 |
| Blocks | 6 |
| \(d_k\) (per head) | 32 |
| Vocabulary | 50,257 (GPT-2 BPE) |
| Max sequence length | 128 |
Once training was done, I exported the weights and made a deliberate choice: no torch.jit, no ONNX, no existing runtime. I wanted to write the inference engine myself, in C++, from the ground up.
The engine grew in distinct stages, each one solving a specific bottleneck I hit in the previous one.
.npy weights and runs autoregressive generation. No optimization, just correctness. I built a custom tensor library from scratch—implementing matrix multiplication, multi-head attention, LayerNorm, softmax, GELU, positional encodings, and the complete autoregressive inference pipeline. The engine loads exported NumPy weights directly and is organized as a set of header-only components (tensor.hpp, runner.hpp, parser.hpp).operator+, operator*, LayerNorm, softmax, add_bias), and switched matmul to a cache-aware \(32 \times 32\) tiled implementation to cut down on cache misses and make better use of memory bandwidth.I benchmarked the final engine against a naive PyTorch implementation, on both MPS and CPU, on an Apple M1 MacBook Air (mean of 5 runs):
| Metric | PyTorch (MPS) | PyTorch (CPU) | Custom C++ Engine |
|---|---|---|---|
| Time-to-First-Token | 910.29 ms | 13.53 ms | 14.08 ms |
| Avg time/token | 87.00 ms | 12.20 ms | 8.11 ms |
| Throughput | 11.49 tok/s | 81.96 tok/s | 120.94 tok/s |
The MPS comparison looks dramatic, but the more honest and interesting number is the CPU-vs-CPU one: even against PyTorch's own CPU path, the custom engine wins on every metric, purely from tiling, SIMD, KV caching, and threading — no exotic tricks, just doing the well-known things correctly and stacking them.
If I had to rank the four optimizations by impact:
The broader lesson: inference engineering isn't really about writing a forward pass. It's about not doing work you don't have to do (KV caching), respecting the memory hierarchy (tiling), and using the hardware you actually have (SIMD + threads). None of these ideas are novel—they're standard techniques used in mature inference systems such as llama.cpp and vLLM—but implementing them myself, and watching the profiler numbers change as each optimization landed, made the "why" click in a way that simply reading papers never did.
This is a learning project, not a production runtime, and it's honest about that:
The natural next step is weight quantization (starting with INT8 and eventually mixed precision), followed by exploring multi-GPU inference to better understand tensor parallelism, communication costs, and memory-bandwidth tradeoffs at larger scales.
Note: AI assistance was used for debugging, error handling, and some boilerplate during development. The model implementation, training pipeline, inference engine architecture, and optimization strategy are my own work.