Lesson 53 of 73
Capstone: A Chat Engine in Pure Java
Over the last lessons the course built the parts of an inference engine one at a time. An inference engine is the program that takes a model file and a piece of text and produces the model's answer, one token at a time. A token is a small chunk of text, often a word or part of a word, that the model reads and writes. This lesson connects the parts into one program, Project 20: a chat engine written in plain Java that does not call llama.cpp.
The parts it joins are the GGUF loader (Project 16, reads the model file), the tokenizer (Project 17, turns text into token ids and back), the forward pass (Project 18, the math that turns token ids into scores), the Vector API kernels (Project 19, fast matrix multiplication), the sampler chain (Project 2, picks the next token from the scores), and the chat template renderer (Project 3, wraps a conversation in the markers the model expects). Together they take a chat message, run it through the model, stream the answer as text, and stop in the right place. The loop run --engine java command can then talk to the engine through the same HTTP API as llama-server.
Building the whole chain matters for two reasons. It shows exactly what happens between a chat message and the first streamed word, with no hidden step. It also gives something to measure: the engine can be checked against llama.cpp for correctness and timed for speed, and the differences point to where the real work is. After this lesson, you can explain each step of the path from a chat message to a streamed answer, check a homemade engine against llama.cpp token by token, and say where it is slower and why.
The path of one request looks like this:
chat messages
| chat template (Example 2): wrap the messages in the model's markers
v
prompt text
| tokenizer (Project 17): text -> token ids
v
prompt ids
| prefill (Examples 1, 6): run all prompt tokens, fill the KV cache
v
logits of the last prompt token <-----------------+
| sampler chain (Example 3): pick one token id |
v |
token id --> stop check (Example 5) --> UTF-8 stream (Example 4) --> text out
| |
+-- decode (Example 1): run that one token, get the next logitsThe table lists what each part adds and which example shows it.
| Part | What it adds | Example |
|---|---|---|
| Engine API | tokenize, prefill, decode, sample |
1 |
| Chat template | renderer chosen from tokenizer.chat_template |
2 |
| Sampling | the Project 2 chain on 151,936 logits | 3 |
| Streaming | token bytes through a UTF-8 decoder | 4 |
| Stops | end-of-generation tokens, stop strings, limits | 5 |
| Prefill | batched against token by token | 6 |
| Memory | KV cache size and the context limit | 7 |
loop |
an in-process Backend |
8 |
| Project 20 | the engine on the tiny model, compared with llama.cpp | 9, 10 |
| Speed | llama-bench against the Java engine |
11 |
| Next steps | extension tasks, with one worked out | 12 |
The lesson builds on Loading GGUF with the FFM API, A Real Tokenizer from GGUF Metadata, The Forward Pass in Java, Quantized Kernels, the Vector API, and Threads, Penalties, Logit Bias, and Sampler Chains, and Chat Templates, Roles, and Special Tokens.
Examples 1 to 8 are small programs that can be run on their own. Each one restates the part of the engine it needs, so it does not depend on the other projects' source files. Examples 9 to 11 run the assembled engine on the course's tiny model. The tiny model has random weights, so the text it writes is meaningless. Its token ids, probabilities, and timings are real, and that is what the comparisons use.
Example 1: the engine API
An engine is easier to build, test, and replace when its operations are separate calls instead of one large function. This program defines an Engine interface with the steps of generation: tokenize (text to ids), prefill (feed the prompt), decode (feed one token), sample (pick the next token), and a test for the end of generation. A generation loop drives it.
Two terms come first. The model's output for one position is a list of logits: one score per token in the vocabulary, where a higher score means the model finds that token more likely to come next. The KV cache (key-value cache) is the memory where the engine keeps the attention keys and values of every token processed so far, so later tokens can look at earlier ones without recomputing them. The position() call reports how many tokens the cache holds.
The stand-in engine here has an 8-token vocabulary and logits that always favor the next word of a fixed sentence. It prints every prefill and decode call. Watch the order of the calls and how position grows.
import java.util.ArrayList;
import java.util.List;
public class EngineApi {
interface Engine {
List<Integer> tokenize(String text);
float[] prefill(List<Integer> tokens); // runs the prompt, returns the logits of its last token
float[] decode(int token); // runs one token at the next position, returns its logits
int sample(float[] logits);
String piece(int token);
boolean isEndOfGeneration(int token);
int position(); // tokens held in the KV cache
}
// A stand-in engine: 8 tokens, and logits that favor a fixed script, so every call is visible.
static final class ScriptEngine implements Engine {
static final String[] VOCAB = {"the", " cat", " sat", " on", " the", " mat", ".", "<end>"};
static final int[] SCRIPT = {0, 1, 2, 3, 4, 5, 6, 7}; // the cat sat on the mat . <end>
int position;
public List<Integer> tokenize(String text) {
List<Integer> ids = new ArrayList<>();
for (String w : text.split("(?= )")) ids.add(List.of(VOCAB).indexOf(w));
return ids;
}
float[] logitsAt(int pos) { float[] l = new float[VOCAB.length]; l[SCRIPT[Math.min(pos, SCRIPT.length - 1)]] = 2f; return l; }
public float[] prefill(List<Integer> tokens) {
position += tokens.size();
System.out.println(" prefill " + tokens + " -> position " + position);
return logitsAt(position);
}
public float[] decode(int token) {
position++;
System.out.println(" decode " + token + " -> position " + position);
return logitsAt(position);
}
public int sample(float[] logits) { int best = 0; for (int i = 1; i < logits.length; i++) if (logits[i] > logits[best]) best = i; return best; }
public String piece(int token) { return VOCAB[token]; }
public boolean isEndOfGeneration(int token) { return token == 7; }
public int position() { return position; }
}
static String generate(Engine e, String prompt, int maxTokens) {
StringBuilder out = new StringBuilder();
float[] logits = e.prefill(e.tokenize(prompt));
for (int n = 0; n < maxTokens; n++) {
int id = e.sample(logits);
if (e.isEndOfGeneration(id)) { System.out.println(" sampled " + id + ": end of generation"); break; }
out.append(e.piece(id));
logits = e.decode(id);
}
return out.toString();
}
public static void main(String[] args) {
Engine e = new ScriptEngine();
System.out.println("tokens of \"the cat\": " + e.tokenize("the cat"));
String text = generate(e, "the cat", 10);
System.out.println("generated: \"" + text + "\", KV cache holds " + e.position() + " tokens");
}
}tokens of "the cat": [0, 1]
prefill [0, 1] -> position 2
decode 2 -> position 3
decode 3 -> position 4
decode 4 -> position 5
decode 5 -> position 6
decode 6 -> position 7
sampled 7: end of generation
generated: " sat on the mat.", KV cache holds 7 tokensThe first line shows tokenization: the cat becomes the ids 0 and 1.
prefill [0, 1] -> position 2 is the prefill step. The engine runs every prompt token through the model, stores their keys and values in the KV cache, and returns the logits of the last prompt token only. After it, the cache holds 2 tokens. The logits of the earlier prompt tokens are not needed, because the model is only asked what comes after the whole prompt. That is why prefill returns one array and not one per token.
Each decode line is one decode step. The loop samples a token from the current logits and appends its text. Then it calls decode with that token, which runs the model for one new position and returns the logits for the next choice. The positions go 3, 4, 5, 6, 7, one per generated token. The sampled ids 2, 3, 4, 5, 6 are sat, on, the, mat, and ..
sampled 7: end of generation shows how the loop ends. Token 7 is <end>, which the engine reports as an end-of-generation token, so the loop stops before it appends the text and before it decodes the token. The KV cache therefore holds 7 tokens: 2 from the prompt and the 5 generated tokens that were decoded. The last sampled token is never run through the model, because nothing needs the logits that would follow it.
The picture to keep in mind is a loop with one extra step at the start:
prefill(prompt) -> logits
repeat: sample(logits) -> id; stop if id ends generation
decode(id) -> logits for the next choiceThe same five calls describe every engine in this course. In llama.cpp, llama_decode runs a batch of tokens (a whole prompt for prefill, or one token for a decode step), llama_get_logits_ith returns logits, and a llama_sampler chain picks the token. The Java engine of Example 9 implements the same interface with real weights.
Example 2: the chat template from GGUF metadata
A chat model was trained on conversations written in one exact format, with special marker tokens that say who is speaking. If the engine sends the messages in a different format, the model sees text unlike its training data and answers worse. The format is called the chat template.
A GGUF file stores its chat template in the metadata key tokenizer.chat_template, as a program in Jinja (a template language where {% ... %} holds logic such as loops and conditions, and {{ ... }} prints a value). Running Jinja needs an interpreter. The Java engine does not have one. Instead it looks for marker strings that identify the template family (<|im_start|> for ChatML, <|start_header_id|> for Llama 3) and renders with the matching record from Project 3.
Recognizing a family is only safe if the result equals what the real template produces. The program checks this against llama.cpp. The expected string is what llama-server's /apply-template endpoint returned for the same message with the tiny model, which carries the Qwen2 template.
import java.util.ArrayList;
import java.util.List;
public class TemplateFromMetadata {
record Msg(String role, String content) {}
sealed interface ChatTemplate permits ChatMl, Llama3 { // Project 3, two of its three variants
String render(List<Msg> chat, boolean generationPrompt);
String stopToken();
}
record ChatMl() implements ChatTemplate {
public String render(List<Msg> chat, boolean generationPrompt) {
StringBuilder sb = new StringBuilder();
for (Msg m : chat) sb.append("<|im_start|>").append(m.role()).append('\n').append(m.content()).append("<|im_end|>\n");
return generationPrompt ? sb.append("<|im_start|>assistant\n").toString() : sb.toString();
}
public String stopToken() { return "<|im_end|>"; }
}
record Llama3(String date) implements ChatTemplate {
public String render(List<Msg> chat, boolean generationPrompt) {
String system = chat.get(0).role().equals("system") ? chat.get(0).content().trim() : "";
StringBuilder sb = new StringBuilder("<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n"
+ "Cutting Knowledge Date: December 2023\nToday Date: " + date + "\n\n" + system + "<|eot_id|>");
for (Msg m : chat)
if (!m.role().equals("system"))
sb.append("<|start_header_id|>").append(m.role()).append("<|end_header_id|>\n\n").append(m.content().trim()).append("<|eot_id|>");
return generationPrompt ? sb.append("<|start_header_id|>assistant<|end_header_id|>\n\n").toString() : sb.toString();
}
public String stopToken() { return "<|eot_id|>"; }
}
static ChatTemplate templateFor(String jinja) {
if (jinja.contains("<|im_start|>")) return new ChatMl();
if (jinja.contains("<|start_header_id|>")) return new Llama3("26 Jul 2024");
throw new IllegalArgumentException("unknown chat template");
}
// The Qwen2 template inserts a default system message when the conversation has none.
static List<Msg> withDefaultSystem(String jinja, List<Msg> chat) {
String text = "You are a helpful assistant";
if (!jinja.contains("{{ '<|im_start|>system\n" + text + "<|im_end|>\n' }}") || chat.get(0).role().equals("system")) return chat;
List<Msg> out = new ArrayList<>(List.of(new Msg("system", text)));
out.addAll(chat);
return out;
}
public static void main(String[] args) {
// tokenizer.chat_template of the tiny random model (the Qwen2 template); its string literals contain real newlines
String jinja = "{% for message in messages %}{% if loop.first and messages[0]['role'] != 'system' %}"
+ "{{ '<|im_start|>system\nYou are a helpful assistant<|im_end|>\n' }}{% endif %}"
+ "{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}"
+ "{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}";
// the prompt that llama-server's /apply-template endpoint returned for the same messages
String fromLlamaServer = "<|im_start|>system\nYou are a helpful assistant<|im_end|>\n<|im_start|>user\n"
+ "Write a Java method that reverses a string.<|im_end|>\n<|im_start|>assistant\n";
List<Msg> chat = List.of(new Msg("user", "Write a Java method that reverses a string."));
ChatTemplate t = templateFor(jinja);
String plain = t.render(chat, true), withSystem = t.render(withDefaultSystem(jinja, chat), true);
System.out.println("template: " + t.getClass().getSimpleName() + ", stop token " + t.stopToken());
System.out.println("without the default system message, equal to llama-server: " + plain.equals(fromLlamaServer));
System.out.println("with the default system message, equal to llama-server: " + withSystem.equals(fromLlamaServer));
System.out.println(withSystem.replace("\n", "\\n\n"));
System.out.println("Llama 3 marker selects: " + templateFor("...<|start_header_id|>...").getClass().getSimpleName());
}
}template: ChatMl, stop token <|im_end|>
without the default system message, equal to llama-server: false
with the default system message, equal to llama-server: true
<|im_start|>system\n
You are a helpful assistant<|im_end|>\n
<|im_start|>user\n
Write a Java method that reverses a string.<|im_end|>\n
<|im_start|>assistant\n
Llama 3 marker selects: Llama3The first line shows the choice. The template text contains <|im_start|>, so templateFor returns ChatMl, and the stop token is <|im_end|>, the marker that ends each turn.
The next two lines show why rendering the messages alone is not enough. The conversation has one user message. Rendered as it is, the prompt differs from llama-server's (false). The reason is a rule inside the Qwen2 template, {% if loop.first and messages[0]['role'] != 'system' %}: when the first message is not a system message, the template adds a system turn with the text You are a helpful assistant. withDefaultSystem reproduces that rule. If the template contains that exact output expression and the first message is not a system message, it adds the default system message. With it, the Java prompt equals llama-server's character for character (true).
The printed prompt has the three turns of ChatML: the inserted system turn, the user turn, and the opening of the assistant turn (<|im_start|>assistant and a newline). The model starts writing at that opening. The last line shows that a template with Llama 3 header tokens selects the Llama3 record instead.
Marker detection covers the templates the course renders. For any other template, the engine fails loudly with unknown chat template instead of guessing. An engine that must support any model runs the Jinja program itself: llama.cpp has its own Jinja engine for --jinja, as Chat Templates, Roles, and Special Tokens described.
One more detail matters after rendering. The rendered text must be tokenized with special-token parsing turned on. Then <|im_start|> becomes the single control token 151644. With parsing off, the same text would become 12 ordinary tokens, and the model would not recognize it as a marker.
Example 3: the sampler chain on a full vocabulary
After the forward pass, the engine holds one logit per vocabulary token. For the tiny model that is 151,936 float values. The sampler chain from Project 2 turns these scores into one chosen token. It works in stages, each of which removes candidates or reshapes the scores. A reminder of each stage:
- top-k keeps only the k highest logits.
- top-p keeps the smallest set of top tokens whose probabilities add up to p.
- min-p drops tokens whose probability is below a fraction of the best token's probability.
- temperature divides the logits before the final random draw. A low value sharpens the choice, and a high value flattens it.
- dist (distribution) draws one token at random according to the remaining probabilities.
The Project 2 chain works on double[], but the forward pass returns float[], so the engine converts each step's logits first. This program runs two chains on five steps of seeded random logits. A greedy chain (temperature 0) always takes the largest logit. A second chain uses llama.cpp's default filters: top-k 40, top-p 0.95, min-p 0.05, and temperature 0.8. For each step the program also reports how many candidates survive the filters.
A small hand calculation shows what temperature does. Two logits, 2.0 and 1.0, become probabilities with softmax (raise e to each logit, then divide by the sum):
temperature 1.0: e^2.0 = 7.389, e^1.0 = 2.718, sum 10.107 -> 0.731 and 0.269
temperature 0.5: logits become 4.0 and 2.0:
e^4.0 = 54.6, e^2.0 = 7.389, sum 61.99 -> 0.881 and 0.119Dividing by a smaller temperature widened the gap between the logits, so the best token went from 73% to 88%.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
public class EngineSampling {
// Project 2's SamplerChain reduced to top-k, top-p, min-p, temperature, and dist, in the llama.cpp order.
record Chain(int topK, double topP, double minP, double temp, Random rng) {
int candidates(double[] l) { return (int) Arrays.stream(l).filter(Double::isFinite).count(); }
int sample(float[] logits, int[] kept) {
double[] l = new double[logits.length];
for (int i = 0; i < l.length; i++) l[i] = logits[i]; // the engine returns float logits
if (topK > 0 && topK < l.length) {
double[] sorted = l.clone();
Arrays.sort(sorted);
double kth = sorted[l.length - topK];
for (int i = 0; i < l.length; i++) if (l[i] < kth) l[i] = Double.NEGATIVE_INFINITY;
}
if (topP < 1) {
double[] p = softmax(l);
Integer[] order = new Integer[l.length];
for (int i = 0; i < l.length; i++) order[i] = i;
Arrays.sort(order, (a, b) -> Double.compare(p[b], p[a]));
double run = 0;
boolean cut = false;
for (int id : order) { if (cut) l[id] = Double.NEGATIVE_INFINITY; run += p[id]; if (run >= topP) cut = true; }
}
if (minP > 0) {
double min = Arrays.stream(l).max().getAsDouble() + Math.log(minP);
for (int i = 0; i < l.length; i++) if (l[i] < min) l[i] = Double.NEGATIVE_INFINITY;
}
if (temp <= 0) { // greedy: the first largest logit
int best = 0;
for (int i = 1; i < l.length; i++) if (l[i] > l[best]) best = i;
kept[0] = 1;
return best;
}
for (int i = 0; i < l.length; i++) l[i] /= temp;
kept[0] = candidates(l);
double[] p = softmax(l);
double u = rng.nextDouble(), run = 0;
for (int i = 0; i < p.length; i++) { run += p[i]; if (p[i] > 0 && u < run) return i; }
throw new IllegalStateException("no candidate");
}
static double[] softmax(double[] l) {
double max = Arrays.stream(l).max().getAsDouble(), sum = 0;
double[] p = new double[l.length];
for (int i = 0; i < l.length; i++) { p[i] = Math.exp(l[i] - max); sum += p[i]; }
for (int i = 0; i < l.length; i++) p[i] /= sum;
return p;
}
}
public static void main(String[] args) {
int vocab = 151_936; // the Qwen2 vocabulary of the tiny model
Random weights = new Random(50);
float[][] steps = new float[5][vocab];
for (float[] s : steps) for (int i = 0; i < vocab; i++) s[i] = (float) (weights.nextGaussian() * 2.5);
Chain greedy = new Chain(0, 1, 0, 0, new Random(1));
Chain defaults = new Chain(40, 0.95, 0.05, 0.8, new Random(1));
int[] kept = new int[1];
List<String> g = new ArrayList<>(), d = new ArrayList<>();
for (float[] logits : steps) {
g.add(String.valueOf(greedy.sample(logits, kept)));
d.add(defaults.sample(logits, kept) + " (" + kept[0] + " left)");
}
System.out.println("greedy: " + g);
System.out.println("top-k 40, top-p 0.95, min-p 0.05, temp 0.8: " + d);
System.out.println("logits per step: " + vocab + ", bytes as double[]: " + vocab * 8);
}
}greedy: [67488, 44599, 137048, 94756, 40351]
top-k 40, top-p 0.95, min-p 0.05, temp 0.8: [69849 (35 left), 44599 (36 left), 17772 (36 left), 55207 (36 left), 112859 (22 left)]
logits per step: 151936, bytes as double[]: 1215488The greedy line has five ids, one per step. With temperature 0, the chain keeps only the largest logit, so the choice is the position of the maximum, and the seeded Random is never used. Greedy decoding is what the comparisons with llama.cpp in Examples 9 and 10 use, because it removes randomness from both engines.
The second line shows the sampling chain. At step 2 it picks the same id as greedy (44599), and at the other steps a different one. The numbers in parentheses are the candidates left after top-k, top-p, and min-p: 35 or 36 of the 40 that top-k keeps, and 22 at the last step. top-p 0.95 removes the least likely few of the 40. min-p 0.05 removes candidates whose probability is below 5% of the best one's. For example, if the best token has probability 0.40, min-p drops every token below 0.02. How many candidates each filter removes depends on how the probability is spread over the 40 at that step. Out of 151,936 tokens, a few dozen remain, and the random draw happens among them.
The last line shows a cost. One double[] copy of the logits is 1,215,488 bytes (151,936 values of 8 bytes each) per step, and the chain copies, sorts, and scans arrays of that length several times per token. On a small model whose forward pass takes a few milliseconds, this work is visible in the measurements of Example 11, and Example 12 removes most of it.
The order of the stages is llama.cpp's order from Project 2. The order matters. Temperature divides every logit by the same number, so it never changes which token has the highest logit. This is why top-k selects the same candidates before or after temperature. Top-p and min-p are different, because they depend on probabilities, and temperature changes how probability is spread. Applying temperature last, as llama.cpp does, therefore gives different candidates for top-p and min-p than applying it first.
Example 4: streaming tokens through the UTF-8 decoder
Computers store text as bytes. In the UTF-8 encoding, a plain English letter takes 1 byte, an accented letter takes 2, many Chinese characters take 3, and an emoji takes 4. A model's token is a sequence of bytes, not a sequence of characters. Byte-level BPE (byte-pair encoding) vocabularies such as Qwen2's can therefore split one character across several tokens, when the vocabulary has no single token for it.
This is a problem for streaming. The engine sends text to the user as each token arrives. If a token ends in the middle of a character, converting its bytes to a string on its own produces garbage. The engine must wait for the rest of the character before it prints anything.
The program takes the five tokens of Ok 🦙 ok in the Qwen2 vocabulary (ids and bytes from llama-tokenize and the vocabulary file). It feeds them through Project 17's streaming decoder, which holds back incomplete bytes, and compares the result with decoding each token on its own. Look at the middle three rows.
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public class StreamingUtf8 {
// Project 17's streaming decoder: an incomplete UTF-8 sequence waits for the next token's bytes.
static final class Utf8Stream {
private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
private ByteBuffer pending = ByteBuffer.allocate(0);
String push(byte[] bytes) {
ByteBuffer in = ByteBuffer.allocate(pending.remaining() + bytes.length).put(pending).put(bytes).flip();
CharBuffer out = CharBuffer.allocate(in.remaining() + 1);
CoderResult r;
while ((r = decoder.decode(in, out, false)).isError()) { in.position(in.position() + r.length()); out.put('\uFFFD'); }
pending = in.slice();
return out.flip().toString();
}
}
public static void main(String[] args) {
// "Ok 🦙 ok" in the Qwen2 vocabulary: ids and the bytes of each token
int[] ids = {11578, 11162, 99, 247, 5394};
byte[][] bytes = {
{0x4F, 0x6B}, {0x20, (byte) 0xF0, (byte) 0x9F}, {(byte) 0xA6}, {(byte) 0x99}, {0x20, 0x6F, 0x6B}};
Utf8Stream stream = new Utf8Stream();
StringBuilder streamed = new StringBuilder(), naive = new StringBuilder();
for (int i = 0; i < ids.length; i++) {
String piece = stream.push(bytes[i]);
String perToken = new String(bytes[i], StandardCharsets.UTF_8);
System.out.printf("token %6d bytes %-8s streamed [%s] per-token String [%s]%n", ids[i], HexFormat.of().formatHex(bytes[i]), piece, perToken);
streamed.append(piece);
naive.append(perToken);
}
System.out.println("streamed: " + streamed);
System.out.println("per token: " + naive);
}
}token 11578 bytes 4f6b streamed [Ok] per-token String [Ok]
token 11162 bytes 20f09f streamed [ ] per-token String [ �]
token 99 bytes a6 streamed [] per-token String [�]
token 247 bytes 99 streamed [🦙] per-token String [�]
token 5394 bytes 206f6b streamed [ ok] per-token String [ ok]
streamed: Ok 🦙 ok
per token: Ok ��� okThe llama emoji is the four bytes f0 9f a6 99. The Qwen2 vocabulary has no token for it, so BPE splits it across tokens:
token 11162: 20 f0 9f a space, then the first two bytes of the emoji
token 99: a6 the third byte
token 247: 99 the fourth byteThe streamed column shows what the streaming decoder emits per token. For token 11162 it emits only the space. The two emoji bytes stay in its buffer, because they are the start of a 4-byte sequence that is not complete. For token 99 it emits nothing, the empty [], because three of four bytes have arrived. For token 247 the sequence is complete and the whole emoji comes out. The final streamed line is the correct text, Ok 🦙 ok.
The per-token String column decodes each token separately with new String(bytes, UTF_8). Every incomplete sequence becomes the replacement character U+FFFD (the � symbol that means "invalid bytes"), and the final line has three replacement characters where the emoji should be. A chat client that streams text from such an engine shows garbage for emoji, CJK (Chinese, Japanese, Korean) text, and accented letters whenever a split happens. The streaming decoder costs one small buffer per generation and fixes the problem.
llama.cpp's server has the same rule. It holds back incomplete UTF-8 at the end of the generated text before it sends a chunk (validate_utf8 in the server code), so a stream never contains a partial character.
Example 5: stop handling
A generation loop needs rules for when to stop. Generation ends for one of four reasons:
- eog: the model produces an end-of-generation token, its own signal that the answer is finished.
- stop: the generated text contains a stop string, a text pattern the caller chose, such as
\nUser:, which would mean the model started to write the next user turn. - length: the token limit (
max_tokens) is reached. - context: the context is full, meaning the KV cache has no room for another token.
Stop strings need care when streaming. A stop string can arrive split across tokens, for example \nUs in one token and er: in the next. Text that was already sent to the user cannot be taken back. So when the end of the text could be the start of a stop string, the engine holds those characters back until the next token shows whether the stop string really appears. This is the hold-back rule.
The program runs the engine's stop logic on five scripted sequences of token pieces, with the stop string \nUser:, and prints the chunks that would be streamed. Compare the chunks of stop string and false alarm.
import java.util.List;
import java.util.function.Consumer;
public class StopHandling {
record Result(String text, String reason, int tokens) {}
static int stopIndex(String text, List<String> stops) {
int best = -1;
for (String s : stops) { int i = text.indexOf(s); if (i >= 0 && (best < 0 || i < best)) best = i; }
return best;
}
static int holdBack(String text, List<String> stops) { // longest suffix of text that starts a stop string
int keep = 0;
for (String s : stops)
for (int n = Math.min(s.length() - 1, text.length()); n > keep; n--)
if (text.endsWith(s.substring(0, n))) { keep = n; break; }
return keep;
}
// The engine's loop with the model replaced by a script of pieces; null stands for an end-of-generation token.
static Result run(List<String> script, List<String> stops, int maxTokens, int contextLeft, Consumer<String> out) {
StringBuilder text = new StringBuilder();
int emitted = 0, n = 0;
String reason = "length";
for (String piece : script) {
if (n == maxTokens) break;
if (piece == null) { reason = "eog"; break; }
n++;
text.append(piece);
int cut = stopIndex(text.toString(), stops);
if (cut >= 0) { text.setLength(cut); reason = "stop"; break; }
int safe = text.length() - holdBack(text.toString(), stops);
if (safe > emitted) { out.accept(text.substring(emitted, safe)); emitted = safe; }
if (n == contextLeft) { reason = "context"; break; }
}
if (text.length() > emitted) out.accept(text.substring(emitted));
return new Result(text.toString(), reason, n);
}
static void show(String name, List<String> script, List<String> stops, int maxTokens, int contextLeft) {
StringBuilder chunks = new StringBuilder();
Result r = run(script, stops, maxTokens, contextLeft, s -> chunks.append('[').append(s.replace("\n", "\\n")).append(']'));
System.out.printf("%-14s chunks %-34s reason %-7s tokens %d%n", name, chunks, r.reason(), r.tokens());
}
public static void main(String[] args) {
List<String> stops = List.of("\nUser:");
java.util.ArrayList<String> eog = new java.util.ArrayList<>(List.of("Use", " a", " StringBuilder", "."));
eog.add(null);
eog.add("never shown");
show("end token", eog, stops, 100, 4096);
show("stop string", List.of("Done", ".", "\nUs", "er:", " next"), stops, 100, 4096);
show("false alarm", List.of("Done", ".", "\nUs", "ing it"), stops, 100, 4096);
show("max tokens", List.of("a", "b", "c", "d", "e"), stops, 3, 4096);
show("context full", List.of("a", "b", "c", "d", "e"), stops, 100, 2);
}
}end token chunks [Use][ a][ StringBuilder][.] reason eog tokens 4
stop string chunks [Done][.] reason stop tokens 4
false alarm chunks [Done][.][\nUsing it] reason length tokens 4
max tokens chunks [a][b][c] reason length tokens 3
context full chunks [a][b] reason context tokens 2Each line names the scenario, the chunks streamed in order, the stop reason, and the number of tokens generated.
end token: the script has four pieces, then an end-of-generation token (null), then never shown. The loop stops at the end token, which is not part of the text, and nothing after it is generated. The reason is eog and 4 tokens count. In the real engine, the test is tokenizer.isEog(id). For the Qwen2 vocabulary it is true for <|endoftext|> (151643) and <|im_end|> (151645), the ChatML turn end, as Project 17 established.
stop string: the pieces Done, ., \nUs, er: contain the stop string \nUser: across two pieces. After \nUs, the text ends with \nUs, which is the beginning of the stop string, so the loop holds those three characters back instead of streaming them. After er:, the stop string is complete. The loop cuts the text before it and stops with reason stop. The client received Done and ., and never saw \nUs.
false alarm: the same start, but the next piece is ing it. The held-back \nUs turns out to be the start of \nUsing it, which is not the stop string, so the loop streams it together with the new piece in one chunk \nUsing it. The script then ends, and the reason is length because no other stop applied. Holding back delays at most a few characters and never loses any.
max tokens: with a limit of 3, the loop stops after a, b, c with reason length. This is the same name the OpenAI API uses for finish_reason when max_tokens is reached.
context full: with room for only 2 more tokens in the KV cache, the loop stops after b with reason context. An engine must check this before it calls decode, because writing past the end of the cache arrays would fail or overwrite other data. Example 7 computes the limit.
Example 6: batched prefill against token by token
When a prompt has many tokens, the engine can feed them in two ways. One way is to run the prompt one token at a time, exactly as decode does. The other is to run all prompt tokens as one batch. The results are the same, but the cost is different, and the reason is memory traffic.
Running a token through the model multiplies the token's vector by the weight matrices. Those weights are gigabytes in a real model, and reading them from memory is the slow part. If the engine processes tokens one at a time, it reads all the weights again for every token. In a batch, each weight row is loaded once and multiplied with the vectors of every prompt token before the next row is loaded, so the weights are read once per prompt instead of once per token. As an analogy: eight people who each need a page of the same book can read it one after another, fetching the book eight times, or fetch it once and copy the page eight times. The analogy stops at the arithmetic, which is the same in both cases. Only the number of trips to memory changes.
The program builds a seeded two-layer model, prefills an 8-token prompt both ways, counts how many weight rows it reads, and compares the logits and the KV caches. Look at the row counts first, then at the two equality lines.
import java.util.Arrays;
import java.util.Random;
public class BatchedPrefill {
static final int DIM = 16, HEADS = 2, HD = 8, FFN = 32, VOCAB = 20, LAYERS = 2;
static float[][][] w = new float[LAYERS * 6 + 1][][]; // per layer: q, k, v, o, up, down; then output
static long rowReads;
static float[][] random(Random r, int rows, int cols) {
float[][] m = new float[rows][cols];
for (float[] row : m) for (int c = 0; c < cols; c++) row[c] = (float) (r.nextGaussian() * 0.3);
return m;
}
// y[t] = m * x[t] for every token t; each weight row is read once and used for all tokens
static float[][] matmul(float[][] m, float[][] x) {
float[][] y = new float[x.length][m.length];
for (int r = 0; r < m.length; r++) {
rowReads++;
for (int t = 0; t < x.length; t++) { float s = 0; for (int c = 0; c < m[r].length; c++) s += m[r][c] * x[t][c]; y[t][r] = s; }
}
return y;
}
static float[][] norm(float[][] x) {
float[][] y = new float[x.length][];
for (int t = 0; t < x.length; t++) {
float ss = 0; for (float v : x[t]) ss += v * v;
float s = (float) (1 / Math.sqrt(ss / x[t].length + 1e-5));
y[t] = new float[x[t].length]; for (int i = 0; i < y[t].length; i++) y[t][i] = x[t][i] * s;
}
return y;
}
// Runs tokens at positions start.. against the cache; returns the logits of the last token when asked.
static float[] forward(int[] tokens, int start, float[][][] kCache, float[][][] vCache, boolean wantLogits) {
int n = tokens.length;
float[][] x = new float[n][DIM];
for (int t = 0; t < n; t++) for (int i = 0; i < DIM; i++) x[t][i] = (float) Math.sin(tokens[t] * 0.7 + i); // fixed embedding
for (int l = 0; l < LAYERS; l++) {
float[][] h = norm(x), q = matmul(w[6 * l], h), k = matmul(w[6 * l + 1], h), v = matmul(w[6 * l + 2], h);
for (int t = 0; t < n; t++) { kCache[l][start + t] = k[t]; vCache[l][start + t] = v[t]; }
float[][] att = new float[n][DIM];
for (int t = 0; t < n; t++) // causal: position start+t sees 0..start+t
for (int hd = 0; hd < HEADS; hd++) {
int pos = start + t; float max = Float.NEGATIVE_INFINITY, sum = 0; float[] s = new float[pos + 1];
for (int j = 0; j <= pos; j++) { for (int i = 0; i < HD; i++) s[j] += q[t][hd * HD + i] * kCache[l][j][hd * HD + i]; s[j] /= (float) Math.sqrt(HD); max = Math.max(max, s[j]); }
for (int j = 0; j <= pos; j++) { s[j] = (float) Math.exp(s[j] - max); sum += s[j]; }
for (int j = 0; j <= pos; j++) for (int i = 0; i < HD; i++) att[t][hd * HD + i] += s[j] / sum * vCache[l][j][hd * HD + i];
}
float[][] o = matmul(w[6 * l + 3], att);
for (int t = 0; t < n; t++) for (int i = 0; i < DIM; i++) x[t][i] += o[t][i];
float[][] u = matmul(w[6 * l + 4], norm(x));
for (float[] row : u) for (int i = 0; i < FFN; i++) row[i] = Math.max(0, row[i]);
float[][] d = matmul(w[6 * l + 5], u);
for (int t = 0; t < n; t++) for (int i = 0; i < DIM; i++) x[t][i] += d[t][i];
}
if (!wantLogits) return null;
return matmul(w[6 * LAYERS], norm(new float[][] {x[n - 1]}))[0]; // logits only for the last token
}
public static void main(String[] args) {
Random r = new Random(6);
for (int l = 0; l < LAYERS; l++) {
w[6 * l] = random(r, DIM, DIM); w[6 * l + 1] = random(r, DIM, DIM); w[6 * l + 2] = random(r, DIM, DIM);
w[6 * l + 3] = random(r, DIM, DIM); w[6 * l + 4] = random(r, FFN, DIM); w[6 * l + 5] = random(r, DIM, FFN);
}
w[6 * LAYERS] = random(r, VOCAB, DIM);
int[] prompt = {3, 14, 15, 9, 2, 6, 5, 3};
long rowsPerPass = LAYERS * (4L * DIM + FFN + DIM);
float[][][] k1 = new float[LAYERS][64][], v1 = new float[LAYERS][64][];
rowReads = 0;
float[] last = null;
for (int t = 0; t < prompt.length; t++) last = forward(new int[] {prompt[t]}, t, k1, v1, t == prompt.length - 1);
long oneByOne = rowReads - VOCAB;
float[][][] k2 = new float[LAYERS][64][], v2 = new float[LAYERS][64][];
rowReads = 0;
float[] batched = forward(prompt, 0, k2, v2, true);
long batch = rowReads - VOCAB;
System.out.println("prompt tokens: " + prompt.length + ", layer weight rows per pass: " + rowsPerPass);
System.out.println("token by token: layer rows read " + oneByOne + " (" + oneByOne / rowsPerPass + " passes)");
System.out.println("batched: layer rows read " + batch + " (" + batch / rowsPerPass + " pass)");
System.out.println("last-token logits identical: " + Arrays.equals(last, batched));
System.out.println("KV caches identical: " + Arrays.deepEquals(k1, k2) + " " + Arrays.deepEquals(v1, v2));
}
}prompt tokens: 8, layer weight rows per pass: 224
token by token: layer rows read 1792 (8 passes)
batched: layer rows read 224 (1 pass)
last-token logits identical: true
KV caches identical: true trueThe first line gives the sizes: 8 prompt tokens, and 224 weight rows in the two layers. Per layer there are 4 square matrices of 16 rows (query, key, value, output), one 32-row up projection, and one 16-row down projection, which is 4 * 16 + 32 + 16 = 112 rows per layer and 224 for two.
Token by token, the program reads 1,792 layer rows, which is 8 full passes over the weights (8 * 224), one per token. Batched, it reads 224 rows, one pass. In a real model the weights are gigabytes, so prefill token by token reads the whole model once per prompt token. For a 1,000-token prompt that is 1,000 passes.
Batched prefill is the reason llama.cpp reports prompt processing in thousands of tokens per second while decode runs at tens: a decode step has one token to multiply each weight row with, and a batch has hundreds. FLOPs, Bandwidth, and the Roofline Model showed that prefill is compute-bound (limited by arithmetic) and decode is bandwidth-bound (limited by memory reads) for this reason.
The last two lines show that batching changes the cost and not the result. The logits of the last token are bit-identical, and so are both KV caches. Every dot product runs over the same row and the same activation vector in the same order. Only the loop order changed, from "for each token, for each row" to "for each row, for each token".
Attention is still computed token by token, with causal masking. Token t of the batch attends to positions 0 to start + t only, so it never sees later prompt tokens even though their keys are already in the cache.
The logits are computed for the last token only (wantLogits), in both modes. The output projection of the tiny model has 151,936 rows, much more than all its layers together, so skipping it for the other prompt tokens saves more than batching does.
The Project 20 engine in Example 9 prefills token by token, which keeps it short. Batched prefill with the Project 19 kernels is the first extension task in Example 12.
Example 7: KV cache memory and the context limit
The KV cache grows with every token, and it competes with the model weights for memory. Knowing its size tells you how long a context a machine can hold. The cache stores one key vector and one value vector per layer, per KV head, per position. That gives a size of 2 * layers * kvHeads * headDim * bytesPerValue bytes per token. The 2 counts the key and the value. This is the formula from Memory Math: Weights, KV Cache, and What Fits.
The program computes the size for the tiny model and two real model shapes. It then checks the size of the Java engine's cache arrays and shows what happens when generation reaches the end of the context. The "context" is the maximum number of tokens the cache can hold.
import java.util.Locale;
public class KvCacheLimit {
record Shape(String name, int layers, int kvHeads, int headDim) {
long bytesPerToken(int bytesPerValue) { return 2L * layers * kvHeads * headDim * bytesPerValue; }
}
public static void main(String[] args) {
Shape[] shapes = {
new Shape("tiny random model", 4, 2, 32),
new Shape("Qwen2.5 0.5B shape", 24, 2, 64),
new Shape("Llama 3.1 8B shape", 32, 8, 128)};
System.out.println("model bytes/token F16 ctx 4096 F16 ctx 4096 F32 ctx 32768 F16");
for (Shape s : shapes)
System.out.printf(Locale.ROOT, "%-20s %15d %11.2f MiB %10.2f MiB %11.2f GiB%n", s.name(), s.bytesPerToken(2),
s.bytesPerToken(2) * 4096 / 1048576.0, s.bytesPerToken(4) * 4096 / 1048576.0, s.bytesPerToken(2) * 32768 / 1073741824.0);
int trainCtx = 4096, requested = 8192, ctx = Math.min(requested, trainCtx);
Shape tiny = shapes[0];
short[][] keyCache = new short[tiny.layers()][ctx * tiny.kvHeads() * tiny.headDim()];
long javaBytes = 2L * keyCache.length * keyCache[0].length * Short.BYTES;
System.out.println("engine ctx = min(requested " + requested + ", trained " + trainCtx + ") = " + ctx
+ ", short[] K and V arrays: " + javaBytes / 1048576 + " MiB");
int prompt = 4090, maxTokens = 100, position = prompt;
int generated = 0;
while (generated < maxTokens && position < ctx) { position++; generated++; }
System.out.println("prompt " + prompt + " tokens, max_tokens " + maxTokens + ": generated " + generated
+ ", stop reason " + (generated < maxTokens ? "context" : "length"));
}
}model bytes/token F16 ctx 4096 F16 ctx 4096 F32 ctx 32768 F16
tiny random model 1024 4.00 MiB 8.00 MiB 0.03 GiB
Qwen2.5 0.5B shape 12288 48.00 MiB 96.00 MiB 0.38 GiB
Llama 3.1 8B shape 131072 512.00 MiB 1024.00 MiB 4.00 GiB
engine ctx = min(requested 8192, trained 4096) = 4096, short[] K and V arrays: 4 MiB
prompt 4090 tokens, max_tokens 100: generated 6, stop reason contextThe tiny model needs 1,024 bytes per token with an F16 cache (16-bit floating point values, 2 bytes each): 2 (key and value) times 4 layers times 2 KV heads times 32 values times 2 bytes. At the 4,096-token context of the model, that is 1,024 * 4,096 bytes, or 4.00 MiB. llama.cpp prints the same number when it loads the model: llama_kv_cache: size = 4.00 MiB (4096 cells, 4 layers, 1/1 seqs), K (f16): 2.00 MiB, V (f16): 2.00 MiB. An F32 cache would be twice as large, 8.00 MiB.
For real shapes the cache is the part of memory that grows with context. The Qwen2.5 0.5B shape (24 layers, 2 KV heads of 64 values) needs 12,288 bytes per token, which is 48 MiB at 4,096 tokens and 0.38 GiB at 32,768 tokens. The Llama 3.1 8B shape (32 layers, 8 KV heads of 128 values) needs 128 KiB per token, which is 512 MiB at 4,096 tokens and 4 GiB at 32,768. That is half the size of the model's 8-bit weights.
The engine line shows how the Java engine sizes its cache. The requested context (8,192) is clipped to the trained context from llama.context_length (4,096). The model never saw positions beyond its trained length during training, and RoPE (rotary position embedding, the way the model encodes where each token sits) produces rotations at those positions that the model did not learn. The engine allocates short[] arrays that hold F16 values, and their total size is 4 MiB, the same as llama.cpp's.
The last line shows the context limit in action. With a 4,090-token prompt and max_tokens of 100, only 6 positions remain, so generation stops after 6 tokens with reason context, not length.
Another policy is to shift the cache, which means discarding the oldest tokens to make room. llama.cpp offers it with --context-shift. Shifting changes what the model can see, so the Java engine stops instead. This keeps its output identical to a run with a larger context up to that point.
Example 8: an in-process Backend for loop run --engine java
Earlier in the course, loop chat was built to talk to engines through a small Backend interface (Project 12) with four methods: name, baseUrl, ensureReady, and close. The chat client sends requests to the backend's URL using the OpenAI-compatible streaming API. That design means a new engine needs no change in the chat client, the slash commands, or the streaming display, as long as it offers the same URL and the same API.
The simplest way to add loop run --engine java is a backend that starts an HTTP server inside the JVM, on 127.0.0.1 and a free port, and serves /v1/chat/completions from the Java engine. The program implements such a backend with the JDK's built-in HttpServer and Jackson (a JSON library). A scripted engine stands in for the model, so the program runs without a model file. It then reads the stream back with the JDK HTTP client, the way the chat client would. Look at the delta lines in the output: each is one chunk of the answer.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
public class JavaEngineBackend {
interface Backend extends AutoCloseable { // the interface from Project 12
String name();
String baseUrl();
void ensureReady() throws Exception;
@Override void close();
}
interface Engine { List<String> chat(List<Map<String, String>> messages, int maxTokens); } // pieces of the answer
static final class InProcessBackend implements Backend {
static final ObjectMapper JSON = new ObjectMapper();
final Engine engine;
HttpServer server;
InProcessBackend(Engine engine) { this.engine = engine; }
public String name() { return "java"; }
public String baseUrl() { return "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; }
public void ensureReady() throws Exception {
if (server != null) return;
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/v1/chat/completions", ex -> {
JsonNode req = JSON.readTree(ex.getRequestBody());
List<Map<String, String>> messages = JSON.convertValue(req.get("messages"), JSON.getTypeFactory().constructCollectionType(List.class, Map.class));
ex.getResponseHeaders().add("Content-Type", "text/event-stream");
ex.sendResponseHeaders(200, 0);
try (OutputStream out = ex.getResponseBody()) {
for (String piece : engine.chat(messages, req.path("max_tokens").asInt(256)))
send(out, Map.of("choices", List.of(Map.of("index", 0, "delta", Map.of("content", piece)))));
send(out, Map.of("choices", List.of(Map.of("index", 0, "delta", Map.of(), "finish_reason", "stop"))));
out.write("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8));
}
});
server.start();
}
static void send(OutputStream out, Object chunk) throws java.io.IOException {
out.write(("data: " + JSON.writeValueAsString(chunk) + "\n\n").getBytes(StandardCharsets.UTF_8));
out.flush();
}
public void close() { if (server != null) server.stop(0); }
}
public static void main(String[] args) throws Exception {
Engine scripted = (messages, max) -> List.of("Use", " new", " StringBuilder(s)", ".reverse()", ".toString()", ".");
try (Backend b = new InProcessBackend(scripted)) {
b.ensureReady();
System.out.println(b.name() + " backend ready, base URL ends with /v1: " + b.baseUrl().endsWith("/v1"));
String body = "{\"model\":\"tiny\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"Reverse a string.\"}]}";
HttpRequest req = HttpRequest.newBuilder(URI.create(b.baseUrl() + "/chat/completions"))
.POST(HttpRequest.BodyPublishers.ofString(body)).header("Content-Type", "application/json").build();
HttpResponse<java.io.InputStream> resp = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofInputStream());
StringBuilder answer = new StringBuilder();
try (BufferedReader in = new BufferedReader(new InputStreamReader(resp.body(), StandardCharsets.UTF_8))) {
for (String line; (line = in.readLine()) != null; ) {
if (!line.startsWith("data: ")) continue;
if (line.equals("data: [DONE]")) { System.out.println("[DONE]"); break; }
JsonNode choice = new ObjectMapper().readTree(line.substring(6)).get("choices").get(0);
if (choice.has("finish_reason")) System.out.println("finish_reason: " + choice.get("finish_reason").asText());
else { String d = choice.get("delta").get("content").asText(); answer.append(d); System.out.println("delta [" + d + "]"); }
}
}
System.out.println("answer: " + answer);
}
}
}java backend ready, base URL ends with /v1: true
delta [Use]
delta [ new]
delta [ StringBuilder(s)]
delta [.reverse()]
delta [.toString()]
delta [.]
finish_reason: stop
[DONE]
answer: Use new StringBuilder(s).reverse().toString().The first line shows that ensureReady started the server and that the base URL has the /v1 suffix that every other backend has. The port is not printed, because the operating system picks a free one when the server binds to port 0.
The delta lines are the content of the server-sent events (SSE) as the client reads them. SSE is a plain HTTP response that stays open while the server writes one data: line per event. Each line here holds a JSON chunk with choices[0].delta.content, which is the format of The Chat Completions Protocol and Streaming in Java. After the pieces comes a chunk with an empty delta and finish_reason: stop, then the data: [DONE] line that ends an OpenAI stream. The client joined the deltas into the full answer.
In loop, the real Engine implementation renders the messages with the template of Example 2, tokenizes them, and calls generate from Project 20. It passes each streamed piece to the HTTP response as it is produced, so the user sees text while the model is still working. The server maps the engine's stop reasons to finish_reason: eog and stop become stop, and length and context become length.
This backend keeps the separation that The Engine Landscape and How Ollama Works described, a server in front and the model behind it, but inside one process, so there is no child process to supervise. The cost is that a crash in the engine takes loop down with it, which a separate llama-server process avoids. The criteria for choosing between the options are in that lesson.
Example 9: Project 20 on the tiny model, compared with llama.cpp
Project 20 is the class JavaChatEngine. It keeps the API of Example 1 and builds each step from an earlier project. Read the class in four layers:
- Loading:
GgufModel.openmaps the model file into memory (Project 16). Mapping means the weights are not copied into Java arrays. The file's bytes are read from where they sit.Llama.Config.fromreads the hyperparameters and the RoPE layout (Project 18). Each weight matrix becomes aMatrixrecord that points into the mapped file, with its ggml type. Norm weights and biases are small and are read asfloat[]. - Forward pass:
forward(token, wantLogits)is the loop of Project 18. It reusesLlama.rmsnorm,Llama.rope,Llama.addBias, andLlama.silu. Every matrix-vector product goes toKernelLibrary.Kernels.matvec(Project 19), which reads F32 or Q8_0 rows straight from the mapping on 4 threads. The KV cache holds F16 values inshort[]arrays (Example 7). - Generation:
generateruns the prefill, samples with aSamplerChain(Project 2), streams throughGgufTokenizer.Utf8Stream(Project 17), and stops with the four reasons of Example 5. - Chat:
templateForpicks a Project 3 renderer fromtokenizer.chat_template(Example 2), and the rendered prompt is tokenized with special tokens parsed.
The main method has three modes:
rawgenerates from the plain promptHello world.chatgenerates from a rendered conversation with the system messageYou are a helpful assistantand one user message.benchmeasures speed (Example 11).
Both generation modes print the prompt ids, the three most likely first tokens with their log-probabilities, the generated ids, and the text. A log-probability is the natural logarithm of a token's probability, so it is negative, and a value closer to 0 means a more likely token. The program needs the model file and the other project classes, so the checker does not run it.
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.nio.ByteOrder;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Consumer;
/** Project 20: a chat engine in pure Java on top of Projects 2, 3, 16, 17, 18, and 19. F32 and Q8_0 weights. */
public final class JavaChatEngine implements AutoCloseable {
static final ValueLayout.OfFloat F32 = ValueLayout.JAVA_FLOAT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
static final ValueLayout.OfShort F16 = ValueLayout.JAVA_SHORT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
record Matrix(KernelLibrary.Type type, MemorySegment data, int rows, int cols) {}
record Layer(float[] attnNorm, Matrix wq, Matrix wk, Matrix wv, float[] bq, float[] bk, float[] bv, Matrix wo,
float[] ffnNorm, Matrix gate, Matrix up, Matrix down) {}
record Result(List<Integer> ids, String text, String stopReason, int promptTokens, long prefillNanos, long decodeNanos) {}
final GgufModel model;
final GgufTokenizer tokenizer;
final Llama.Config c;
final KernelLibrary.Kernels kernels;
final Matrix embedding, output;
final float[] outputNorm;
final Layer[] layers;
final int ctx;
final short[][] keyCache, valueCache; // F16, [layer][position * kvDim + i]
int position; // number of tokens in the KV cache
JavaChatEngine(Path path, int ctx, int threads) throws Exception {
model = GgufModel.open(path);
tokenizer = new GgufTokenizer(model.metadata());
c = Llama.Config.from(model.metadata()); // Project 18: hyperparameters and RoPE layout
this.ctx = Math.min(ctx, c.contextLength());
kernels = new KernelLibrary.Kernels(threads);
embedding = matrix("token_embd.weight");
output = model.tensors().containsKey("output.weight") ? matrix("output.weight") : embedding; // tied embeddings
outputNorm = model.floats("output_norm.weight");
layers = new Layer[c.layers()];
for (int l = 0; l < c.layers(); l++) {
String p = "blk." + l + ".";
layers[l] = new Layer(model.floats(p + "attn_norm.weight"), matrix(p + "attn_q.weight"), matrix(p + "attn_k.weight"),
matrix(p + "attn_v.weight"), optional(p + "attn_q.bias"), optional(p + "attn_k.bias"), optional(p + "attn_v.bias"),
matrix(p + "attn_output.weight"), model.floats(p + "ffn_norm.weight"),
matrix(p + "ffn_gate.weight"), matrix(p + "ffn_up.weight"), matrix(p + "ffn_down.weight"));
}
keyCache = new short[c.layers()][this.ctx * c.kvHeads() * c.headDim()];
valueCache = new short[c.layers()][this.ctx * c.kvHeads() * c.headDim()];
}
float[] optional(String name) { return model.tensors().containsKey(name) ? model.floats(name) : null; }
Matrix matrix(String name) {
GgufModel.TensorInfo t = model.tensors().get(name);
KernelLibrary.Type type = switch (t.type()) {
case 0 -> KernelLibrary.Type.F32;
case 8 -> KernelLibrary.Type.Q8_0;
default -> throw new IllegalArgumentException(name + ": ggml type " + t.type() + " not supported");
};
return new Matrix(type, model.tensorData(name), (int) t.dims()[1], (int) t.dims()[0]);
}
float[] embed(int token) { // one row of token_embd, dequantized
float[] x = new float[c.dim()];
long off = token * embedding.type().rowBytes(c.dim());
if (embedding.type() == KernelLibrary.Type.F32) MemorySegment.copy(embedding.data(), F32, off, x, 0, c.dim());
else for (int b = 0; b < c.dim() / 32; b++) {
float d = Float.float16ToFloat(embedding.data().get(F16, off + b * 34L));
for (int j = 0; j < 32; j++) x[b * 32 + j] = d * embedding.data().get(ValueLayout.JAVA_BYTE, off + b * 34L + 2 + j);
}
return x;
}
float[] matvec(Matrix m, float[] x) throws Exception {
float[] out = new float[m.rows()];
kernels.matvec(m.type(), m.data(), x, out, m.rows());
return out;
}
/** Runs one token at the next position; returns logits when asked for them. */
float[] forward(int token, boolean wantLogits) throws Exception {
if (position >= ctx) throw new IllegalStateException("context full: " + ctx + " tokens");
int pos = position++, hd = c.headDim(), kvDim = c.kvHeads() * hd, group = c.heads() / c.kvHeads();
float[] x = embed(token), xb = new float[c.dim()];
for (int l = 0; l < c.layers(); l++) {
Layer L = layers[l];
Llama.rmsnorm(xb, x, L.attnNorm(), c.eps());
float[] q = matvec(L.wq(), xb), k = matvec(L.wk(), xb), v = matvec(L.wv(), xb);
Llama.addBias(q, L.bq());
Llama.addBias(k, L.bk());
Llama.addBias(v, L.bv());
Llama.rope(q, c.heads(), hd, c.ropeDims(), pos, c.ropeBase(), c.neox());
Llama.rope(k, c.kvHeads(), hd, c.ropeDims(), pos, c.ropeBase(), c.neox());
for (int i = 0; i < kvDim; i++) {
keyCache[l][pos * kvDim + i] = Float.floatToFloat16(k[i]);
valueCache[l][pos * kvDim + i] = Float.floatToFloat16(v[i]);
}
float[] att = new float[c.dim()], scores = new float[pos + 1];
for (int h = 0; h < c.heads(); h++) {
int kvh = (h / group) * hd; // grouped-query attention: 4 query heads share one KV head
float max = Float.NEGATIVE_INFINITY, sum = 0;
for (int t = 0; t <= pos; t++) {
float s = 0;
for (int i = 0; i < hd; i++) s += q[h * hd + i] * Float.float16ToFloat(keyCache[l][t * kvDim + kvh + i]);
scores[t] = s / (float) Math.sqrt(hd);
max = Math.max(max, scores[t]);
}
for (int t = 0; t <= pos; t++) { scores[t] = (float) Math.exp(scores[t] - max); sum += scores[t]; }
for (int t = 0; t <= pos; t++)
for (int i = 0; i < hd; i++) att[h * hd + i] += scores[t] / sum * Float.float16ToFloat(valueCache[l][t * kvDim + kvh + i]);
}
float[] o = matvec(L.wo(), att);
for (int i = 0; i < c.dim(); i++) x[i] += o[i];
Llama.rmsnorm(xb, x, L.ffnNorm(), c.eps());
float[] g = matvec(L.gate(), xb), u = matvec(L.up(), xb);
for (int i = 0; i < c.ffn(); i++) g[i] = Llama.silu(g[i]) * u[i]; // SwiGLU
float[] d = matvec(L.down(), g);
for (int i = 0; i < c.dim(); i++) x[i] += d[i];
}
if (!wantLogits) return null;
Llama.rmsnorm(xb, x, outputNorm, c.eps());
return matvec(output, xb);
}
/** Feeds the prompt; logits are computed only for the last token. */
float[] prefill(List<Integer> tokens) throws Exception {
float[] logits = null;
for (int i = 0; i < tokens.size(); i++) logits = forward(tokens.get(i), i == tokens.size() - 1);
return logits;
}
static double[] toDouble(float[] f) { double[] d = new double[f.length]; for (int i = 0; i < f.length; i++) d[i] = f[i]; return d; }
/** Generates until an end-of-generation token, a stop string, maxTokens, or a full context; streams text to out. */
Result generate(List<Integer> prompt, SamplerChain sampler, int maxTokens, List<String> stops, Consumer<String> out) throws Exception {
long t0 = System.nanoTime();
float[] logits = prefill(prompt);
long t1 = System.nanoTime();
List<Integer> history = new ArrayList<>(prompt), ids = new ArrayList<>();
GgufTokenizer.Utf8Stream utf8 = new GgufTokenizer.Utf8Stream();
StringBuilder text = new StringBuilder();
int emitted = 0;
String reason = "length";
while (ids.size() < maxTokens) {
int id = sampler.sample(toDouble(logits), history);
if (tokenizer.isEog(id)) { reason = "eog"; break; }
ids.add(id);
history.add(id);
text.append(utf8.push(tokenizer.tokenBytes(id, false)));
int cut = stopIndex(text, stops);
if (cut >= 0) { text.setLength(cut); reason = "stop"; break; }
int safe = text.length() - holdBack(text, stops); // keep back a possible start of a stop string
if (safe > emitted) { out.accept(text.substring(emitted, safe)); emitted = safe; }
if (position >= ctx) { reason = "context"; break; }
if (ids.size() < maxTokens) logits = forward(id, true);
}
if (text.length() > emitted) out.accept(text.substring(emitted));
return new Result(ids, text.toString(), reason, prompt.size(), t1 - t0, System.nanoTime() - t1);
}
static int stopIndex(CharSequence text, List<String> stops) {
int best = -1;
for (String s : stops) { int i = text.toString().indexOf(s); if (i >= 0 && (best < 0 || i < best)) best = i; }
return best;
}
static int holdBack(CharSequence text, List<String> stops) { // length of the longest text suffix that starts a stop string
int keep = 0;
for (String s : stops)
for (int n = Math.min(s.length() - 1, text.length()); n > keep; n--)
if (text.toString().endsWith(s.substring(0, n))) { keep = n; break; }
return keep;
}
static TemplateProject.ChatTemplate templateFor(String jinja) {
if (jinja == null) throw new IllegalArgumentException("the model has no tokenizer.chat_template");
if (jinja.contains("<|im_start|>")) return new TemplateProject.ChatMl();
if (jinja.contains("<|start_header_id|>")) return new TemplateProject.Llama3("26 Jul 2024");
if (jinja.contains("[INST]")) return new TemplateProject.Mistral();
throw new IllegalArgumentException("unknown chat template");
}
void reset() { position = 0; }
@Override public void close() { kernels.close(); model.close(); }
static String top3(float[] logits) { // log-softmax of the three largest logits
double max = Double.NEGATIVE_INFINITY, sum = 0;
for (float l : logits) max = Math.max(max, l);
for (float l : logits) sum += Math.exp(l - max);
int[] best = java.util.stream.IntStream.range(0, logits.length).boxed()
.sorted((x, y) -> Float.compare(logits[y], logits[x])).limit(3).mapToInt(Integer::intValue).toArray();
StringBuilder sb = new StringBuilder();
for (int id : best) sb.append(String.format(Locale.ROOT, "%d (%.6f) ", id, logits[id] - max - Math.log(sum)));
return sb.toString().trim();
}
public static void main(String[] args) throws Exception {
String mode = args.length > 1 ? args[1] : "raw";
int threads = args.length > 2 ? Integer.parseInt(args[2]) : 4, n = args.length > 3 ? Integer.parseInt(args[3]) : 32;
try (JavaChatEngine e = new JavaChatEngine(Path.of(args[0]), 4096, threads)) {
Llama.Config c = e.c;
System.out.printf(Locale.ROOT, "config: dim %d, layers %d, heads %d, kv heads %d, head dim %d, ffn %d, vocab %d, ctx %d, weights %s%n",
c.dim(), c.layers(), c.heads(), c.kvHeads(), c.headDim(), c.ffn(), c.vocab(), e.ctx, e.layers[0].wq().type());
TemplateProject.ChatTemplate t = templateFor(e.model.string("tokenizer.chat_template"));
List<Integer> chat = e.tokenizer.encode(t.render(List.of(new TemplateProject.Msg("system", "You are a helpful assistant"),
new TemplateProject.Msg("user", "Write a Java method that reverses a string.")), true), true, true);
SamplerChain greedy = SamplerChain.builder().temperature(0).build();
if (mode.equals("bench")) {
for (int run = 1; run <= 3; run++) {
e.reset();
long t0 = System.nanoTime();
float[] logits = e.prefill(chat);
long t1 = System.nanoTime(), sampleNanos = 0;
List<Integer> history = new ArrayList<>(chat);
for (int i = 0; i < n; i++) {
long s0 = System.nanoTime();
int id = greedy.sample(toDouble(logits), history);
sampleNanos += System.nanoTime() - s0;
history.add(id);
logits = e.forward(id, true);
}
long t2 = System.nanoTime();
System.out.printf(Locale.ROOT, "run %d: prefill %d tokens %.1f tokens/s, decode %d tokens %.1f tokens/s, sampler %.1f ms of %.1f ms%n",
run, chat.size(), chat.size() / ((t1 - t0) / 1e9), n, n / ((t2 - t1) / 1e9), sampleNanos / 1e6, (t2 - t1) / 1e6);
}
return;
}
List<Integer> prompt = mode.equals("chat") ? chat : e.tokenizer.encode("Hello world", true, false);
List<String> stops = mode.equals("chat") ? List.of(t.stopToken()) : List.of();
System.out.println("prompt ids: " + prompt);
System.out.println("first token, top 3 log-probabilities: " + top3(e.prefill(prompt)));
e.reset();
StringBuilder streamed = new StringBuilder();
Result r = e.generate(prompt, greedy, n, stops, streamed::append);
System.out.println("generated ids: " + r.ids());
System.out.println("text: " + r.text().replace("\n", "\\n"));
System.out.println("stop reason: " + r.stopReason() + ", streamed text equals result: " + streamed.toString().equals(r.text()));
}
}
}The six classes compile together. Each run names the model file, the mode, the thread count, and the number of tokens to generate:
javac --add-modules jdk.incubator.vector -d out GgufModel.java GgufTokenizer.java Llama.java \
KernelLibrary.java SamplerChain.java TemplateProject.java JavaChatEngine.java
java --add-modules jdk.incubator.vector -Dstdout.encoding=UTF-8 -cp out JavaChatEngine tiny-random-qwen2vocab-f32.gguf raw 4 64
java --add-modules jdk.incubator.vector -Dstdout.encoding=UTF-8 -cp out JavaChatEngine tiny-random-qwen2vocab-f32.gguf chat 4 32Output of the two runs on the F32 tiny model (F32 means the weights are stored as 32-bit floats, with no compression). The values are fixed by the model file:
config: dim 256, layers 4, heads 8, kv heads 2, head dim 32, ffn 704, vocab 151936, ctx 4096, weights F32
prompt ids: [9707, 1879]
first token, top 3 log-probabilities: 146539 (-10.688072) 86468 (-10.691295) 8479 (-10.694056)
generated ids: [146539, 2373, 126339, 56171, 96902, 56171, 8359, 8359, 146539, 2373, 70670, 8359, 118552, 60522, 118552, 60522, 146539, 146539, 146539, 146539, 146539, 96902, 146539, 96902, 146539, 96902, 146539, 96902, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 43917, 129670, 50227, 43917, 129670, 50227, 43917, 146539, 50227, 43917, 129670, 50227, 43917, 146539, 150365, 43917, 14422, 43917]
text: 쾰rame ngo java collage java Russia Russia쾰rame Interesting Russia程序员ureka程序员ureka쾰쾰쾰쾰쾰 collage쾰 collage쾰 collage쾰 collage쾰 Yorkshire쾰 Yorkshire쾰 Yorkshire쾰 Yorkshire쾰 Yorkshire쾰 Yorkshire쾰 Yorkshire쾰 Yorkshire쾰 Yorkshire detached שבו Yorkshire detached שבו Yorkshire detached쾰 Yorkshire detached שבו Yorkshire detached쾰Ҁ detached broke detached
stop reason: length, streamed text equals result: true
config: dim 256, layers 4, heads 8, kv heads 2, head dim 32, ffn 704, vocab 151936, ctx 4096, weights F32
prompt ids: [151644, 8948, 198, 2610, 525, 264, 10950, 17847, 151645, 198, 151644, 872, 198, 7985, 264, 7943, 1714, 429, 17431, 288, 264, 914, 13, 151645, 198, 151644, 77091, 198]
first token, top 3 log-probabilities: 107917 (-10.583763) 119530 (-10.615812) 115309 (-10.639866)
generated ids: [107917, 123014, 35617, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 53555, 53555, 53555, 53555, 27701, 53555, 27701, 7723, 7723, 7723, 7723, 53555, 27701, 53555]
text: 早晨崶 publishers">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n kleine kleine kleine kleine>\n\n\n\n kleine>\n\n\n\n">\n\n">\n\n">\n\n">\n\n kleine>\n\n\n\n kleine
stop reason: length, streamed text equals result: trueThe reference is llama-server on the same file. It is asked for greedy completions of the same two prompts with return_tokens, which returns the generated ids. A second request uses n_probs, which returns the most likely first tokens with log-probabilities. The chat request sends the rendered prompt text from Example 2, which the server tokenizes with special tokens parsed:
llama-server -m tiny-random-qwen2vocab-f32.gguf --host 127.0.0.1 --port $PORT -t 4 -c 4096
curl -s 127.0.0.1:$PORT/completion -d '{"prompt": "Hello world", "n_predict": 64, "temperature": 0, "return_tokens": true, "cache_prompt": false}'
curl -s 127.0.0.1:$PORT/completion -d '{"prompt": "Hello world", "n_predict": 1, "temperature": 0, "n_probs": 3, "cache_prompt": false}'Fields of the responses (pinned build), first for the raw prompt, then for the chat prompt:
{"tokens": [146539, 2373, 126339, 56171, 96902, 56171, 8359, 8359, 146539, 2373, 70670, 8359, 118552, 60522, 118552, 60522, 146539, 146539, 146539, 146539, 146539, 96902, 146539, 96902, 146539, 96902, 146539, 96902, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 43917, 129670, 50227, 43917, 129670, 50227, 43917, 146539, 50227, 43917, 129670, 50227, 43917, 146539, 150365, 43917, 14422, 43917], "tokens_evaluated": 2, "stop_type": "limit"}
{"tokens": [107917, 123014, 35617, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 53555, 53555, 53555, 53555, 27701, 53555, 27701, 7723, 7723, 7723, 7723, 53555, 27701, 53555], "tokens_evaluated": 28, "stop_type": "limit"}
first token, top 3 log-probabilities (raw): 146539 (-10.688154) 86468 (-10.691395) 8479 (-10.694077)
first token, top 3 log-probabilities (chat): 107917 (-10.583829) 119530 (-10.616118) 115309 (-10.639831)The config line comes from the file's metadata. It says the model has width 256 (each token is a vector of 256 numbers), 4 layers, 8 query heads sharing 2 KV heads, head dimension 32, feed-forward width 704, a 151,936-token vocabulary, and a context clipped to the trained 4,096. weights F32 is the type of the first layer's query matrix.
The prompt ids agree with the server's tokens_evaluated. There are 2 tokens for Hello world (Hello and world, with no BOS, the beginning-of-sequence token, as the Qwen2 rules in A Real Tokenizer from GGUF Metadata require). The chat prompt has 28 tokens. It starts with the control token 151644 (<|im_start|>) and contains 151645 (<|im_end|>) twice.
All 64 generated ids of the raw prompt and all 32 ids of the chat prompt are equal to the server's, position by position. The Forward Pass in Java showed the first 32 for the raw prompt with plain float[] weights. Project 20 reaches the same ids with a different setup: weights read from the mapped file by the Vector API kernels on 4 threads, an F16 KV cache in short[] arrays, the Project 2 sampler chain at temperature 0, and the Project 3 template. stop_type: limit on the server and stop reason: length in Java both mean the token limit ended the run.
The log-probability lines show how close the two engines are numerically. For the raw prompt, the Java engine gives -10.688072, -10.691295, -10.694056 for the same three tokens that the server lists as -10.688154, -10.691395, -10.694077. The differences are at most 0.0001.
A log-probability near -10.7 means a probability of about 1 in 44,000. That is close to 1 in 151,936, the probability each token would have if all were equally likely (log-probability -11.93). The reason is that random weights spread probability almost evenly over the vocabulary. The text is meaningless for the same reason: 쾰rame ngo and ">\n\n repeated are what a random model's largest logits happen to select. streamed text equals result: true confirms that the pieces sent to the output callback join to the final text.
Example 10: the Q8_0 copy, and where greedy comparisons stop working
Quantization stores weights with fewer bits to make the file smaller and decode faster. Q8_0 keeps each weight as one signed byte, with one shared scale factor for every block of 32 weights. Because the stored values are rounded, the model's outputs change slightly. This example asks whether the Java engine and llama.cpp still agree token for token on a quantized file.
The Q8_0 copy of the tiny model is made with llama-quantize. The Java engine runs it through the integer kernel of Project 19. The activation vector (the input of each matrix multiplication) is quantized to Q8_0 once per matrix-vector product, the same scheme the llama.cpp CPU backend uses.
llama-quantize tiny-random-qwen2vocab-f32.gguf tiny-q8_0.gguf Q8_0Selected lines of the llama-quantize output (pinned build). In the bracketed rows, type = f32, converting to q8_0 means the matrix is quantized, and the two sizes are before and after:
[ 1/ 39] output.weight - [ 256, 151936, 1, 1], type = f32, converting to q8_0 .. size = 148.38 MiB -> 39.41 MiB
[ 2/ 39] output_norm.weight - [ 256, 1, 1, 1], type = f32, size = 0.001 MiB
[ 3/ 39] token_embd.weight - [ 256, 151936, 1, 1], type = f32, converting to q8_0 .. size = 148.38 MiB -> 39.41 MiB
llama_model_quantize_impl: model size = 307.51 MiB (32.00 BPW)
llama_model_quantize_impl: quant size = 81.69 MiB (8.50 BPW)Every 2-D weight became Q8_0, and the 1-D norm weights stayed F32. The file shrank from 307.51 MiB to 81.69 MiB, 8.5 bits per weight. This is what the Q8_0 block layout predicts: 34 bytes (32 one-byte values plus a 2-byte scale) hold 32 weights, and 34 * 8 / 32 = 8.5 bits.
Output of the Java engine on the Q8_0 file (raw 4 64, then chat 4 32):
config: dim 256, layers 4, heads 8, kv heads 2, head dim 32, ffn 704, vocab 151936, ctx 4096, weights Q8_0
prompt ids: [9707, 1879]
first token, top 3 log-probabilities: 86468 (-10.685380) 146539 (-10.687533) 8479 (-10.695733)
generated ids: [86468, 86220, 86468, 60, 108167, 71457, 71457, 71457, 71457, 71457, 71457, 71457, 71457, 71457, 71457, 71457, 71457, 71457, 44725, 86220, 71457, 71457, 71457, 44725, 18164, 44725, 18164, 18164, 34113, 18164, 18164, 18164, 34113, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 18164, 76918, 50227, 16882, 50227, 16882, 50227, 16882, 50227]
text: barang pourquoibarang]万里.parallel.parallel.parallel.parallel.parallel.parallel.parallel.parallel.parallel.parallel.parallel.parallel.parallel inspector pourquoi.parallel.parallel.parallel inspector.with inspector.with.witherti.with.with.witherti.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with.with(ViewGroup Yorkshire shoulder Yorkshire shoulder Yorkshire shoulder Yorkshire
stop reason: length, streamed text equals result: true
config: dim 256, layers 4, heads 8, kv heads 2, head dim 32, ffn 704, vocab 151936, ctx 4096, weights Q8_0
prompt ids: [151644, 8948, 198, 2610, 525, 264, 10950, 17847, 151645, 198, 151644, 872, 198, 7985, 264, 7943, 1714, 429, 17431, 288, 264, 914, 13, 151645, 198, 151644, 77091, 198]
first token, top 3 log-probabilities: 107917 (-10.585400) 119530 (-10.614269) 115309 (-10.637869)
generated ids: [107917, 123014, 35617, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 53555, 53555, 53555, 27701, 53555, 27701, 53555, 27701, 7723, 7723, 7723, 7723, 53555, 27701]
text: 早晨崶 publishers">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n">\n\n kleine kleine kleine>\n\n\n\n kleine>\n\n\n\n kleine>\n\n\n\n">\n\n">\n\n">\n\n">\n\n kleine>\n\n\n\n
stop reason: length, streamed text equals result: trueFields of llama-server's responses on the Q8_0 file, in the same form as in Example 9:
{"tokens": [146539, 2373, 126339, 56171, 96902, 56171, 8359, 8359, 146539, 2373, 8359, 118552, 60522, 146539, 146539, 50227, 146539, 50227, 146539, 50227, 146539, 50227, 118552, 69605, 50227, 146539, 146539, 68132, 146539, 68132, 146539, 68132, 146539, 68132, 50227, 25621, 50227, 43917, 25621, 18472, 131987, 106250, 50928, 43917, 25621, 43917, 25621, 43917, 25621, 43917, 14422, 43917, 14422, 43917, 14422, 43917, 14422, 43917, 14422, 43917, 14422, 43917, 14422, 43917], "tokens_evaluated": 2, "stop_type": "limit"}
{"tokens": [107917, 123014, 35617, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 7723, 53555, 53555, 53555, 27701, 53555, 27701, 53555, 27701, 7723, 7723, 7723, 7723, 53555, 27701], "tokens_evaluated": 28, "stop_type": "limit"}
first token, top 3 log-probabilities (raw): 146539 (-10.689584) 86468 (-10.690307) 8479 (-10.697269)
first token, top 3 log-probabilities (chat): 107917 (-10.584251) 119530 (-10.615549) 115309 (-10.637447)For the chat prompt, the 32 Java ids equal the server's. For the raw prompt, they differ from the very first token: Java picks 86468 (barang), the server 146539 (쾰). The log-probability lines explain why. In llama.cpp, 146539 leads 86468 by 0.0007 (-10.689584 against -10.690307). In Java, 86468 leads 146539 by 0.0022. The two candidates are a near-tie in both engines, and the small numerical differences between the engines decide the order. After the first token differs, the contexts differ, and every later token can differ too.
The table compares the first-token log-probabilities of the raw prompt in all four runs:
| Token | Java F32 | llama.cpp F32 | Java Q8_0 | llama.cpp Q8_0 |
|---|---|---|---|---|
| 146539 | -10.688072 | -10.688154 | -10.687533 | -10.689584 |
| 86468 | -10.691295 | -10.691395 | -10.685380 | -10.690307 |
| 8479 | -10.694056 | -10.694077 | -10.695733 | -10.697269 |
With F32 weights, the engines differ by at most 0.0001. With Q8_0 weights, they differ by up to 0.005. Both engines quantize the activations to Q8_0, and switching the Java quantizer to the rounding of llama.cpp's x86 quantize_row_q8_0 (127 / amax and round-half-even) left the Java log-probabilities unchanged. Other steps differ. llama.cpp's CPU flash-attention kernel converts the query to F16 and keeps its attention accumulator in F16, and its kernels add numbers in different orders. Floating-point addition depends on order, so a different order gives slightly different sums. A variant of the Java engine that copies the flash-attention rounding chose 146539 first, like llama.cpp, but then differed at the 11th token of the raw prompt and the 18th of the chat prompt. No single change removes all differences.
llama.cpp disagrees with itself in the same way. Its F32 and Q8_0 runs of the raw prompt (Examples 9 and 10) agree for 10 tokens and differ from the 11th (70670 in F32, 8359 in Q8_0), because quantization moves the logits by more than the gap between two candidates.
The test that keeps working is the one in the table. Compare log-probabilities (or logits) for the same context with a tolerance, step by step, feeding both engines the same tokens. This is called a teacher-forced comparison. Greedy ids are a quick check that holds until the first near-tie.
The tiny model has many near-ties because its random weights keep all logits close together. A model whose largest logit usually stands far above the others reaches its first near-tie later, but the same limit applies.
Example 11: speed compared with llama-bench, and the reasons for the gap
Correctness is one question. Speed is the other. llama-bench is llama.cpp's benchmark tool. It measures two numbers separately: prompt processing (pp, which is prefill) and text generation (tg, which is decode). Both are reported in tokens per second. The Java engine's bench mode measures the same two numbers for the 28-token chat prompt and 64 generated tokens. It runs three times in one JVM, because the first run includes JIT (just-in-time) compilation, when the JVM is still turning the Java code into machine code. It also reports how much time the sampler used.
First, the llama.cpp side:
llama-bench -m tiny-random-qwen2vocab-f32.gguf -m tiny-q8_0.gguf -t 1,4 -p 28 -n 64 -r 5Representative output from the 4-core reference machine (timings differ by machine and by run). pp28 is prefill of 28 tokens, tg64 is decode of 64 tokens, and t/s is tokens per second:
| model | size | params | backend | threads | test | t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| llama ?B all F32 | 307.51 MiB | 80.61 M | CPU | 1 | pp28 | 1654.50 ± 39.16 |
| llama ?B all F32 | 307.51 MiB | 80.61 M | CPU | 1 | tg64 | 96.22 ± 9.21 |
| llama ?B all F32 | 307.51 MiB | 80.61 M | CPU | 4 | pp28 | 6222.98 ± 404.83 |
| llama ?B all F32 | 307.51 MiB | 80.61 M | CPU | 4 | tg64 | 426.83 ± 59.49 |
| llama ?B Q8_0 | 81.69 MiB | 80.61 M | CPU | 1 | pp28 | 5168.30 ± 1009.42 |
| llama ?B Q8_0 | 81.69 MiB | 80.61 M | CPU | 1 | tg64 | 481.54 ± 9.44 |
| llama ?B Q8_0 | 81.69 MiB | 80.61 M | CPU | 4 | pp28 | 16805.95 ± 1710.38 |
| llama ?B Q8_0 | 81.69 MiB | 80.61 M | CPU | 4 | tg64 | 1320.89 ± 69.18 |
build: 0c1e570 (1)java --add-modules jdk.incubator.vector -cp out JavaChatEngine tiny-random-qwen2vocab-f32.gguf bench 1 64Now the Java engine's side. Representative output for 1 and 4 threads and both files (the first line of each block names the configuration):
threads 1 tiny-random-qwen2vocab-f32.gguf
run 1: prefill 28 tokens 102.1 tokens/s, decode 64 tokens 37.7 tokens/s, sampler 247.1 ms of 1698.0 ms
run 2: prefill 28 tokens 345.3 tokens/s, decode 64 tokens 41.2 tokens/s, sampler 183.7 ms of 1554.7 ms
run 3: prefill 28 tokens 357.0 tokens/s, decode 64 tokens 39.7 tokens/s, sampler 197.9 ms of 1611.5 ms
threads 1 tiny-q8_0.gguf
run 1: prefill 28 tokens 31.0 tokens/s, decode 64 tokens 42.4 tokens/s, sampler 230.0 ms of 1508.5 ms
run 2: prefill 28 tokens 364.3 tokens/s, decode 64 tokens 44.9 tokens/s, sampler 176.2 ms of 1425.8 ms
run 3: prefill 28 tokens 334.9 tokens/s, decode 64 tokens 41.8 tokens/s, sampler 189.6 ms of 1529.7 ms
threads 4 tiny-random-qwen2vocab-f32.gguf
run 1: prefill 28 tokens 95.8 tokens/s, decode 64 tokens 57.5 tokens/s, sampler 234.5 ms of 1112.2 ms
run 2: prefill 28 tokens 540.3 tokens/s, decode 64 tokens 90.3 tokens/s, sampler 155.6 ms of 708.7 ms
run 3: prefill 28 tokens 378.2 tokens/s, decode 64 tokens 84.7 tokens/s, sampler 172.6 ms of 755.3 ms
threads 4 tiny-q8_0.gguf
run 1: prefill 28 tokens 27.1 tokens/s, decode 64 tokens 47.2 tokens/s, sampler 273.6 ms of 1356.4 ms
run 2: prefill 28 tokens 421.9 tokens/s, decode 64 tokens 86.0 tokens/s, sampler 150.5 ms of 743.8 ms
run 3: prefill 28 tokens 466.0 tokens/s, decode 64 tokens 77.0 tokens/s, sampler 162.4 ms of 830.7 msThe comparison uses the warm runs 2 and 3 of the Java engine, after JIT compilation:
| Measure | llama.cpp F32 | Java F32 | llama.cpp Q8_0 | Java Q8_0 |
|---|---|---|---|---|
| Prefill, 1 thread (tokens/s) | 1,655 | about 350 | 5,168 | about 350 |
| Decode, 1 thread (tokens/s) | 96 | about 40 | 482 | about 43 |
| Prefill, 4 threads (tokens/s) | 6,223 | about 380 to 540 | 16,806 | about 420 to 470 |
| Decode, 4 threads (tokens/s) | 427 | about 85 to 90 | 1,321 | about 77 to 86 |
llama.cpp is 2.4 times faster at F32 decode on one thread, 11 times faster at Q8_0 decode on one thread, and 5 to 40 times faster at prefill. The reasons can be read from the model's shape and the measurements:
- The output projection dominates decode. The output projection is the last matrix, which turns the final vector into one logit per vocabulary token. The tiny model's layers hold about 2.8 million weights, and the output projection alone holds 38.9 million (151,936 rows of 256). Each decode step reads 155.6 MB of F32 output weights, or 41.3 MB in Q8_0. llama.cpp's one-thread F32 decode at 96 tokens per second reads about 15 GB/s. Its Q8_0 decode at 482 tokens per second reads about 20 GB/s, close to what memory delivers to one core. The Java engine at about 40 tokens per second reads about 6 GB/s in F32 and under 2 GB/s in Q8_0, because its Q8_0 kernel is limited by instructions, as Quantized Kernels, the Vector API, and Threads measured. That is why Java's Q8_0 decode is no faster than its F32 decode, while llama.cpp's is 5 times faster.
- The sampler costs about 2.5 ms per token. The
samplercolumn shows 150 to 200 ms for 64 tokens. At 4 threads that is about 22% of the decode time, and it runs on one thread while the kernel threads wait. Project 2's chain copies the 151,936 logits into adouble[], scans them several times, and computes a softmax over all of them even at temperature 0. llama.cpp's greedy path picks the maximum in one pass. Example 12 shows the first improvement. - Prefill runs token by token. The Java engine runs 28 separate forward passes for the prompt, each with 28 small matrix-vector products sent to the thread pool (7 per layer). For matrices of 64 to 704 rows, the cost of handing work to 4 threads and waiting for them is comparable to the work itself. This is also why 4 threads gain little over 1 for Java prefill. llama.cpp runs the 28 tokens as one batch of matrix-matrix products (Example 6), reading the weights once.
- Per-call overhead in the kernel. The F32 kernel allocates a 48-byte lane array per row (Example 15 of the kernels lesson), which is 151,936 small arrays per token for the output projection alone.
A profile confirms this picture. JFR (Java Flight Recorder) is the JDK's built-in profiler. It samples what each thread is doing at intervals and counts how often each method is on top of the stack. This is a recording of the bench mode on the F32 file with 4 threads:
java --add-modules jdk.incubator.vector -XX:StartFlightRecording=filename=engine.jfr,settings=profile -cp out JavaChatEngine tiny-random-qwen2vocab-f32.gguf bench 4 64
jfr view hot-methods engine.jfrOutput of jfr view hot-methods (the first 7 methods; sample counts differ by run):
Java Methods that Execute the Most
Method Samples Percent
------------------------------------------------------------------------------------------------------- ------- -------
KernelLibrary$Kernels.dotF32(MemorySegment, long, float[]) 155 46.41%
KernelLibrary$Kernels.lambda$matvec$0(...) 77 23.05%
java.util.stream.DoublePipeline$8$1.accept(double) 20 5.99%
SamplerChain.sample(double[], List) 15 4.49%
jdk.incubator.vector.FloatVector.toArray() 12 3.59%
SamplerChain$Builder.lambda$build$6(double[], List) 7 2.10%
GgufTokenizer.<init>(Map) 3 0.90%dotF32, the kernel's dot product of one weight row with the activation vector, has 46% of the samples. That is expected, because it is the core of every matrix-vector product. lambda$matvec$0, the task that loops over row chunks and calls the kernel once per row, has 23%. With rows of only 256 values, the work around each kernel call is a large share of the total.
The sampler appears as DoublePipeline$8$1.accept (the stream that counts finite logits and finds the maximum in SamplerChain.sample), SamplerChain.sample, and a lambda of the builder, together about 13% of the samples. Samples are counted per thread, and the sampler runs while the pool threads are idle, so its share of the wall-clock time is larger than its share of the samples. FloatVector.toArray at 3.6% is the per-row lane array of point 4.
The profile depends on the model's shape. For a model with large layers and a smaller vocabulary, such as an 8B model with 4,096-wide layers, the layers would dominate instead of the output projection and the sampler. The gap with llama.cpp would then be set mostly by the kernels and by batched prefill.
Example 12: extension tasks
Project 20 is a complete engine, and each of its simplifications is a task that brings it closer to llama.cpp. The table lists them in an order where each builds on the course so far. The last column says where the needed background is taught:
| Task | What changes | Expected effect | Course material |
|---|---|---|---|
| Batched prefill | prefill multiplies each weight row with all prompt tokens, as in Example 6, using the Project 19 kernels |
prefill several times faster | Example 6 |
| Faster greedy and top-k | argmax on float[] without a copy; a size-k heap before the chain |
sampler time near zero at temperature 0 | this example |
| Kernel overhead | lane sums into a reused per-thread buffer; two to four rows per call | fewer allocations, more reuse of the activation vector | the kernels lesson, Examples 13 to 15 |
| Q4_0 and K-quants | GgufModel type sizes and kernels for Q4_0 and Q4_K |
smaller files, faster decode where memory is the limit | k-quants, i-quants, and the Importance Matrix |
| Vectorized attention | F16 keys and values converted by vectors, one head per task | faster long contexts | Attention Optimizations and Long Context |
| Jinja templates | a small Jinja interpreter for tokenizer.chat_template |
any model's template | Chat Templates, Roles, and Special Tokens |
| Log-probability tests | a teacher-forced comparison with llama-server's n_probs |
tests that survive near-ties | Example 10 |
The second task shows the pattern for all of them. A top-k filter needs only the k largest logits, so the engine does not have to sort 151,936 values. It can find the k largest with a heap of size k (a small structure that always knows its smallest member) in one pass over the array, and run the rest of the chain on k candidates instead of 151,936.
The program compares that with Project 2's way (copy, sort a copy, mask, softmax over everything) on twelve steps of seeded logits. Two random generators are seeded alike, so both methods make the same random draws and any difference in the chosen ids would come from the method.
import java.util.Arrays;
import java.util.PriorityQueue;
import java.util.Random;
public class TopKFirst {
// As Project 2 does it: copy all logits, sort a copy to find the k-th value, mask, then softmax and draw over all ids.
static int fullChain(float[] logits, int k, double temp, Random rng) {
double[] l = new double[logits.length];
for (int i = 0; i < l.length; i++) l[i] = logits[i];
double[] sorted = l.clone();
Arrays.sort(sorted);
double kth = sorted[l.length - k];
for (int i = 0; i < l.length; i++) l[i] = l[i] < kth ? Double.NEGATIVE_INFINITY : l[i] / temp;
return draw(l, null, rng);
}
// Extension: keep the k largest with a size-k heap in one pass, then run the same stages on k candidates only.
static int topKFirst(float[] logits, int k, double temp, Random rng) {
PriorityQueue<Integer> heap = new PriorityQueue<>((a, b) -> Float.compare(logits[a], logits[b]));
for (int i = 0; i < logits.length; i++) {
if (heap.size() < k) heap.add(i);
else if (logits[i] > logits[heap.peek()]) { heap.poll(); heap.add(i); }
}
int[] ids = heap.stream().mapToInt(Integer::intValue).sorted().toArray(); // id order, as the full version scans
double[] l = new double[k];
for (int j = 0; j < k; j++) l[j] = logits[ids[j]] / temp;
return draw(l, ids, rng);
}
static int draw(double[] l, int[] ids, Random rng) { // softmax and inverse CDF in index order
double max = Arrays.stream(l).max().getAsDouble(), sum = 0;
double[] p = new double[l.length];
for (int i = 0; i < l.length; i++) { p[i] = Math.exp(l[i] - max); sum += p[i]; }
double u = rng.nextDouble() * sum, run = 0;
for (int i = 0; i < p.length; i++) { run += p[i]; if (p[i] > 0 && u < run) return ids == null ? i : ids[i]; }
throw new IllegalStateException();
}
public static void main(String[] args) {
int vocab = 151_936, k = 40, steps = 12;
Random weights = new Random(11);
Random a = new Random(5), b = new Random(5);
int same = 0;
StringBuilder chosen = new StringBuilder();
for (int s = 0; s < steps; s++) {
float[] logits = new float[vocab];
for (int i = 0; i < vocab; i++) logits[i] = (float) (weights.nextGaussian() * 2.5);
int x = fullChain(logits, k, 0.8, a), y = topKFirst(logits, k, 0.8, b);
if (x == y) same++;
chosen.append(x).append(' ');
}
System.out.println("chosen ids: " + chosen.toString().trim());
System.out.println("steps with the same id: " + same + " of " + steps);
System.out.println("values sorted per step: full " + vocab + ", heap keeps " + k);
}
}chosen ids: 131570 10344 86531 86072 66589 109406 27561 125211 99168 110037 139350 20595
steps with the same id: 12 of 12
values sorted per step: full 151936, heap keeps 40The twelve chosen ids are the same with both methods, at every step. The two methods compute the same probabilities for the same candidates in the same order. The full method also has 151,896 zero probabilities (from the masked logits), but adding zeros does not change a floating-point sum, and the inverse-CDF scan (walking through the probabilities until the running total passes the random number) skips them. The random draw u is the same because both generators were seeded with 5 and called once per step.
The difference is the work. The full method sorts 151,936 doubles every step, and the heap method keeps 40 indices and touches each logit once. Any change of this kind can be tested the same way: require identical choices from identical seeds before measuring its speed.
Interview points
- A generation loop is tokenize, prefill (all prompt tokens, with logits kept for the last token only), then repeated sample and decode. The last sampled token is never run through the model.
- The chat template lives in the metadata key
tokenizer.chat_template. An engine either runs the Jinja program or recognizes the template family and renders it natively, including default rules such as Qwen2's default system message. - Rendered prompts must be tokenized with special-token parsing on, so that markers such as
<|im_start|>become single control tokens. - Text should stream through a UTF-8 decoder that keeps incomplete byte sequences. Decoding each token on its own breaks characters that are split across tokens.
- Stop strings must be checked across token boundaries, with a hold-back of any suffix that could be the start of a stop string.
- Batched prefill reads each weight row once per prompt instead of once per token. It gives bit-identical results when the dot products keep their order.
- KV cache bytes per token are
2 * layers * kvHeads * headDim * bytes. The tiny model needs 4 MiB at 4,096 tokens in F16, the same as llama.cpp reports. - Greedy token ids from two correct engines agree until two logits are closer than the numerical differences between the engines. To test beyond that point, compare log-probabilities with a tolerance.
- On a model with a large vocabulary and small layers, the output projection and the sampler dominate decode time.
- An in-process backend that serves the OpenAI API lets an existing chat client use an embedded engine without changes.
- llama.cpp is faster than the Java engine mainly because of batched prefill, kernels that use byte dot-product instructions, low per-call overhead, and a sampler that does little work at temperature 0.
Summary
- Project 20,
JavaChatEngine, combinesGgufModel,GgufTokenizer,Llama.Configand its helpers,KernelLibrary,SamplerChain, andTemplateProject. - The tiny model's template is ChatML with a default system message. The Java rendering equals llama-server's
/apply-templateoutput. - A 151,936-token vocabulary costs a 1.2 MB
double[]copy per sampling step in the Project 2 chain. - The Qwen2 vocabulary splits the llama emoji over three tokens. Streaming decoding prints it once, and per-token decoding prints three replacement characters.
- The stop reasons are
eog,stop,length, andcontext. Stop strings are held back until they are ruled out. - Batched prefill of 8 tokens read 224 weight rows instead of 1,792, with identical logits and KV caches.
- The tiny model's F16 KV cache is 1,024 bytes per token, which is 4.00 MiB at 4,096 tokens.
- On the F32 tiny model, the Java engine's greedy ids equal llama.cpp's for 64 tokens of a raw prompt and 32 tokens of a chat prompt.
- On the Q8_0 copy, the chat prompt matched for 32 tokens. The raw prompt differed at the first token, where llama.cpp's two best log-probabilities were 0.0007 apart.
- llama-bench on 4 threads measured about 6,200 and 16,800 tokens per second of prefill and about 430 and 1,320 tokens per second of decode for F32 and Q8_0. The Java engine measured about 540 and 420 tokens per second of prefill and 90 and 86 of decode.
- The Java engine's decode time is spent mostly in the 151,936-row output projection and the sampler, which took about 2.5 ms per token.
