Making An Inference engine entirely in cpp (jwLLM part 1)

Intro and motivation

This semester I’m taking Linear Algebra, Operating Systems, and Cpp programming at UT Austin. I thought to myself, what better way to reinforce the learnings from the courses than to work on a semester long project that specifically focuses on the stuff I just learned from class?

I landed on building an inference engine in cpp to run GPT-2. Not only is it a super cool and fast growing field that I’m super interested in, It’ll almost perfectly map concepts from class to the project. Some things I’m looking forward to building and learning are:

  • Learning how virtual memory works and seeing how we can implement that for KV (vLLM)
  • Learning about matrix ops in lin alg and seeing how things like tensor parallel or efficient matrix mult can be performed
  • Learning about mmap and seeing how we can use it to multiprocessor with fork() and to manage shared KV cache
  • Learning about cache and how we can optimize our code (stride access patterns etc)
  • Learning about CPU scheduling algos and seeing how we can build a scheduler for forward passes and also at macro, the GPU routing algos
  • Learning Cpp tricks and tips and implementing them
  • Learning how data is stored and floating point is represented in memory and seeing how quantization can be built
  • Learning how efficient I/O is built and seeing how we can build things like FlexGen or SSD weight streaming
  • Learning concurrent programming and seeing how we can speed up our inference server
  • Learning networking and seeing how that can be applied to prefill decode disag

One thing I’d like to note: The goal of this project is to learn, not to showcase a project. If I wanted to, most of this could be completed with a LLM very quickly. Instead of focusing on learning, the projects are slow and intentional, so bear with me!

The code for this project can be found here: https://github.com/jwlaboratory/jwLLM

Making it just work

Before we get to do any of the cool optimizations; we must make the engine work (albeit slow!). Here’s a end demo of what we’ll have running by the end of this blog:

Caption: jwLLM runs GPT2 at .2 tok/sec on a macbook m4 pro!

Let’s look at the overall architecture we need to implement to guide us:

As you can see, it’s a long process, so onwards! Let’s start with the tokenizer.

Tokenizer

Let’s trace the data throughout generation, starting with the user request as a string. Since the model can only operate on numbers, we must convert the user query into a list of numbers. It seems trivial but actually was super painful to build. The code for the tokenizer is below, but I want to highlight the codepoint/utf-8/unicode translation and the merge priority system.

Codepoint Fiasco

Users can type many types of characters, including potentially blank or special characters such as new lines. This can be annoying for debugging with non-visible characters or accidentally outputting these types of characters, so the tokenizer does a unique mapping such that:

  1. All input items (emojis, etc) that may take multiple bytes are interpreted on byte at a time
  2. If they (when interpreted as one byte) are “nasty” (new line, white space, etc), they are added 256 to shift into a 2 byte known safe range. For example “ “ becomes “Ġ”.

The result is rather strange. Variable length encoded objects are reinterpreted as one byte, shifted, then reinterpreted. But it works to make all of the characters deterministically mapped to 256 options that are safe.

Merges

Each character, though has a token id mapping, is not well interpreted by the model. That's like a human trying to read characters by characters instead of words by words. So the tokenizer then merges the pairs of the most frequent subtokens repeatedly. Here’s an example:

SUBMARINE
123456789

You can see SUBMARINE with each character mapping to a token ID. Let’s perform the merges (which merge in the order of priority given).

Merge priorities (given, trained by frequencies in training set)

Tok 1Tok 2Merge Priority
SU1
SUB2
UB3
MA4
RI5
IN6
RIN7
RINE8
MARINE9

The tokenizer will repeatedly merge the top priority that exists in the given list of tokens. In this case, it’ll merge S-U, then SU-B, then M-A, then R-I, then RI-N, then RIN-E, then MA-RINE. Notice it skips U-B and I-N and never merges SUB-MARINE.

This is cool because it learns the most frequent merges and therefore can split where semantic meaning is most helpful. In this case, the submarine split clearly into sub (which means under) and marine (which means water). It allows the model to still understand, even if it's never seen a submarine, that the word represents under-water.

Embedding

Our tokenizer gave us a vector of numbers representing the IDs of each token. Next we need to convert into embeddings such that something that will give us rich features of each token. The dimensions of this is called dmodel. We do this by looking up in a table for our token ID.

The embeddings tell the model the meaning of each token, but it tells the model nothing about the position of each token. For example, if the word yellow appears at the start and at the end of the sentence, this looks identical to the model. So next we apply an addition of a positional vector, unique per position but the same for all tokens, that internally uses sine and cosine to be rotatory in nature to represent the location of each word.

Now we have a [sequence x d_model] representation that contains information about the tokens, the meaning, and the location of each token.

The attention block

Next, this [sequence x d_model] representing the token goes through 12 back to back attention blocks. Let’s look at what this is:

You’ll see the at each stage the dimensions remain the same (even though they may be internally transformed) from [seq, model]. You’ll also see this residual that keeps the original value summated throughout like a loop. Let’s dive into each piece we composed in this picture next.

Layer Norm

LayerNorm’s goal is to make all the data per row be standardized. It does this by normalizing each row to mean = 0, variance = 1, then scaling by gamma and shifting by beta. Eps is added to the denominator when making the variance zero to prevent dividing by zero. What helped me understand this part was remembering the Z-Score formula from high school stats.

We run this layernorm constantly throughout the model, notably before each attention block.

Attention

Attention is the key work that makes this whole language model work. We first create Q, K, V, split by head, then do the self attention equation over each head, then finally concat.

I’m going to defer to a better source to explain the meaning and how this works internally. What really helped me was the video by Umar Jamil on transformers, found here: https://www.youtube.com/watch?v=ISNdQcPhsts and https://www.youtube.com/watch?v=bCz4OMemCcA

I’ve also included the key code for the matrix helpers (slice_cols, concat_cols, and softmax) below, but they are pretty self explanatory.

MLP

The MLP (multi layer perceptron) is the next part in the transformer block. People commonly think the transformer is all the weights of the model, but in reality this part contains a huge amount of the compute and weights.

The MLP takes the incoming data, projects into a higher dimensionality, applies a non linearity, then projects back into the small space.

What made me understand this was thinking about how low rank factorization worked (which I wrote about here), and how this inverses it to get better (instead of less) control of how data can be separated.

Convert to logits, sample, and decode

The final part! We need to take this [seq, d_model] and turn it into something that is useful, the next token!

Recall at the start we indexed the tokid:embedding table. Now, we multiply by the transpose of the embedding table [dmodel, vocab], to get a table that shows [seq, vocab]. The last row tells us the scores of each vocab[i] position!

We sample from this (you can use things like temperature, top-p, nucleus, etc) but in this simple case we use argmax to get the top score and greedily decode it. We convert it back to a string and output it.

Fin

Obviously a lot of optimizations can be built next, which is exactly what we will be doing next! Stay peeled to watch for the next blog in this series.