rml / Generation

Generation

What happens to the decoder's logits: a sampler that filters and draws the next token, a session that feeds it back and keeps one conversation's KV cache between turns, speculative decoding with a small draft model, and rml-chat, the command-line program that puts them together.

On this page
  1. The sampler
  2. Worked example
  3. The generation loop
  4. Against the documented decode loop
  5. Speculative decoding
  6. rml-chat
  7. Tests

In short: Sampler applies Hugging Face's steps in order (repetition penalty, temperature, top-k, top-p, min-p) and draws from what is left, with ties broken by token id so a seeded run is reproducible; temperature 0 is greedy. Session::Generate takes the whole context so far, keeps the part of its KV cache that the new prompt shares, runs the rest as one prefill, then samples, reports and feeds back one token at a time until a stop id, the token limit, a full cache or the caller says stop. SpeculativeSession lets a smaller model guess up to k tokens, checks them all in one batched target pass and keeps each with probability min(1, p / q), so the output distribution is exactly the target's. rml-chat loads a GGUF file or Hugging Face directory with its tokenizer and chat format and streams replies, optionally with a draft model.

The concepts are on /ml-models: decoding and autoregressive models. The code is sampler, generation and speculative in rml/llm, and examples/rml_chat. The forward pass that produces the logits is on The decoder, the chat formats and token bytes on Tokenizers.

The sampler

The header lists the steps in the order they run (sampler.h L4–21):

StepSamplerConfig field (default)What it doesSource
1. Repetition penaltyrepetition_penalty (1, off), penalty_window (64; 0 = all)Each distinct token among the last penalty_window has its logit divided by the penalty if positive, multiplied if negative (CTRL), so it always becomes less likely. A flag array makes a repeated token count once.L49–67
2. Temperaturetemperature (1; 0 = greedy)Greedy returns the arg max, the lowest id on ties. Otherwise logits are divided by T as probabilities are formed.L84–90
3. Top-ktop_k (0, off)std::nth_element moves the k best to the front; no full sort.L95–99
4. Top-ptop_p (1, off)Sort the candidates, then keep the shortest prefix whose mass reaches p of the total.L118–130
5. Min-pmin_p (0, off)Keep tokens whose probability is at least min_p times the best one's.L132–144
6. Drawseed (0)One uniform double from rml's Philox generator times the kept mass, walked down the candidates.L148–163

Filter does steps 1–5 on a copy of the logits (L75–146). Probabilities are kept unnormalized, relative to the best candidate, which gets exactly 1: exp((logit − best) / T) (L105–116). That makes min-p a plain comparison against min_p, and the draw needs only the running total. Ranking compares logits and breaks ties by the lower id (L69–73). All scratch (a logit copy, the candidate list, the probabilities, the penalty flags) is sized to the vocabulary when the sampler is made, so Sample never allocates (L41–47). Create rejects a negative or non-finite temperature, a negative top-k, top-p outside (0, 1], min-p outside [0, 1), a non-positive penalty and a negative window (L16–39).

Speculative decoding needs the distribution itself, not just a draw, so the sampler exposes two more calls: Distribution writes the filtered, normalized probabilities densely over the vocabulary (all mass on the arg max when greedy), and Draw samples from any non-negative weights (sampler.h L54–63; sampler.cc L165–196).

Worked example

Four tokens with logits [2, 1, 0, −1], temperature 1, top-p 0.9, min-p 0.05. Relative to the best, the probabilities are e0, e−1, e−2, e−3 = 1, 0.368, 0.135, 0.050, total 1.553. Top-p sorts them and keeps adding until the running sum reaches 0.9 × 1.553 = 1.398: after two tokens it is 1.368, after three 1.503, so three are kept. Min-p then keeps those at least 0.05 of the best, which all three are (the fourth, at 0.0498, would have fallen to min-p anyway). The draw is over 1, 0.368 and 0.135, so 66.5%, 24.5% and 9.0%. With repetition_penalty 1.1 and token 0 in the window, its logit would first become 2 / 1.1 = 1.82; a logit of −1 would become −1.1.

The generation loop

A Session keeps one conversation's KV cache between calls. Each call passes the whole context so far, the session finds how much of it the cache already holds and runs only the rest, so a chat turn costs its new tokens, not the history (generation.h L4–11). Session::Create(model, sampler, context_size, stop_ids, kv_dtype = kF16) makes the sampler and a cache through DecoderLM::NewCache (generation.cc L27–33).

Session::Generate Generate takes the whole prompt. It finds the longest prefix the cached tokens share with it, keeping at least one prompt token to run, truncates the cache to that prefix, and runs the rest of the prompt as one Forward call, which leaves the last position's logits. Then it loops: the sampler picks a token from the logits and the context so far; a stop id ends generation without reporting the token; otherwise the token goes to the callback, which may stop; the token limit or a full cache stop; otherwise Forward runs the one token and the loop repeats. Generate(prompt, max_new_tokens, on_token) prompt = the whole conversation so far reused = min(shared prefix, prompt − 1) cache.Truncate(reused): later turns skip the history prefill: Forward(prompt[reused:]) one call; logits for the last position token = sampler.Sample(logits, context) penalty · T · top-k · top-p · min-p · draw stop id? → kStopToken (not reported) on_token(token) false? → kCallerStopped limit reached? → kMaxTokens cache full? → kContextFull decode: Forward({token}) no allocation; context += token stats: prompt, reused and generated tokens, prefill and decode seconds
Session::Generate (generation.cc L54–103). The prefill and decode boxes are the two shapes of DecoderLM::Forward.

Details worth knowing:

  • Prefix reuse. std::mismatch finds how many cached tokens the new prompt starts with; at most prompt − 1 are reused, so there is always one token to run and logits to sample from. The cache is truncated to that length (L63–70).
  • The context the sampler sees is the session's cached tokens, so the repetition penalty covers the prompt and the reply so far.
  • Stop order. A stop id ends the call before it is counted or reported; then the callback; then the token limit; then a full cache. The token that hits the limit is reported but never run, so the next call's prefix match stops just before it (L85–102).
  • Errors. An empty prompt or non-positive limit is InvalidArgument; a prompt longer than the cache is OutOfRange; a failed Forward is returned as is (L54–61).
  • Stats. GenerationStats records prompt tokens run, tokens reused, tokens generated and the prefill and decode wall-clock seconds (generation.h L34–40).

Against the documented decode loop

docs/architecture.md describes the decode loop in three steps: load (parse, mmap, tokenizer, size the KV cache and a step arena), prefill in chunks of up to 512 tokens with logits only for the last position, and a per-token decode whose every buffer is preallocated (architecture.md L92–101). The code matches on the decode step (the order of operations, logits for the last position only, zero allocations, tested by decoder_alloc_test), with two differences at the pin:

  • Scratch is DecoderLM's workspace of vectors that grow to the largest call, not an Arena reset each step.
  • Generate runs the whole unmatched prompt as one Forward call; chunked prefill is planned. So is the backend dispatch table the same document has the decode loop call through (L83–90); today DecoderLM calls the kernels in rml/llm/kernels.h directly.

Speculative decoding

Each round the draft samples k tokens from its distributions q1..k; the target runs the pending token and the k guesses as one batch, giving p1..k+1. Guess i is kept with probability min(1, pi(xi) / qi(xi)); the first rejected one is replaced by a draw from max(0, pi − qi), renormalized, and the round ends; if every guess is kept a bonus token comes from pk+1. Every kept token is then distributed exactly as the target alone would sample it (Leviathan et al. 2023; Chen et al. 2023), so the draft changes the speed and never the output; with greedy sampling the output is the target's token for token (speculative.h L4–20).

One speculative round with k = 3 The context ends with a pending token t whose keys are not cached in the target yet. The draft catches up on any context it lacks and samples three guesses, x1, x2 and x3, keeping each q distribution. The target runs t, x1, x2 and x3 as one Forward with all logits, giving p1 to p4. Guess x1 is accepted, x2 is rejected and replaced by a draw from the residual max(0, p2 minus q2); the round ends with two new tokens, and both caches are truncated to hold every context token but the last. context … t draft: k steps q_i = Distribution(draft logits) x_i = Draw(q_i), fed back one at a time target: one Forward [t, x1, x2, x3], all_logits → p1, p2, p3, p4 x1: u·q1 < p1 kept x2: u·q2 ≥ p2 rejected → Draw(max(0, p2 − q2)) x3, p3, p4 unused this round context += x1, x2′ (2 tokens this round) all k kept → a bonus token from p_{k+1}: k + 1 tokens TruncateContext: both caches ≤ context − 1 the last token is the next round's pending token
SpeculativeSession::Round (speculative.cc L100–149) and internal::AcceptOrResample (L23–42).
PieceWhat the code doesSource
CreateTarget and draft must have the same vocabulary size, and num_draft must be positive; one sampler, two caches of the same size and dtype. Buffers for k draft distributions and k + 1 target logit rows are sized here.L46–80
Cache invariantThe target cache holds every context token but the last, which the next round feeds; the draft cache holds a prefix of the context, and catches up at the start of a round.h L77–80, cc L93–98
DraftingThe sampler's Distribution on the draft's logits gives qi, Draw picks xi; the draft runs each guess except the last.L104–121
VerifyingOne target Forward over the last k + 1 tokens with all_logits, then for each row the target's filtered Distribution. The same sampler settings shape both p and q, so the guarantee holds for the filtered distribution.L123–146
AcceptOrResampleAccepts when u · q(x) < p(x), avoiding a division; on rejection overwrites q with max(0, p − q) and draws from it. If rounding leaves no residual mass, it draws from p.L23–42
GenerateThe same prefix reuse as Session, then rounds of min(num_draft, tokens still allowed − 1, room left) drafts; a round with 0 drafts is a plain target step. New tokens are checked for stop ids, the callback and the limit one by one, and the context is cut back to exactly what was reported.L151–203

Measured. rml's own run, on the 4-core x86 development container: SmolLM2-360M-Instruct with the 135M model as draft gives greedy replies byte-identical to the 360M model alone, but decodes slower, 8.3 tok/s with 3 drafted tokens at 50% acceptance against 12.7 tok/s alone, because a batched pass over 4 tokens costs about two single steps there and the draft is only 2.7× smaller. A real speedup needs a memory-bound target (8B at Q4 on the Mac) and is unmeasured (progress.md L77); that measurement is planned (L291–292).

rml-chat

examples/rml_chat keeps its logic in a library, chat_lib (chat.h / chat.cc), with a thin main.cc that parses flags and prints, as the project's rule for entry points asks (BUILD.bazel).

ItemSignatureMeaningSource
LoadChatModel(path, format?) → Expected<ChatModel>A directory loads DecoderLM::FromHf, tokenizer.json and the chat template from tokenizer_config.json (a string, or the list entry named "default"); a file loads GGUF, its tokenizer and tokenizer.chat_template. A --format name overrides detection; no template and no format is Unsupported.L30–65, L120–139
StopIds(config, tokenizer, format)The tokenizer's EOS ids, the config's, and the format's turn terminator (<|im_end|>, <|eot_id|>, <|endoftext|>, </s>, <end_of_turn>) if the vocabulary has it, sorted and deduplicated.L86–118
ChatOptionsstructSampler settings, context 4096, f16 KV cache, 512 new tokens, a system prompt, 4 draft tokens.h L48–56
Chat::Create(model, options, draft = nullptr)A Session, or a SpeculativeSession when there is a draft whose every token's bytes match the model's tokenizer; a tokenizer larger than the model's vocabulary is refused.L141–162
Chat::Send(message, on_text) → Expected<string>Appends the user turn, formats the whole conversation, encodes it and generates; each non-control token's bytes are streamed. The conversation changes only if generation succeeds. With a draft, prefill time runs to the first token.L185–228

Because Send re-encodes the whole conversation and the session reuses the shared prefix, a second turn runs only the new user message and the tokens around it. The command line (main.cc L33–55):

rml-chat MODEL [-p TEXT] [--system TEXT] [--format NAME]
         [--temp 0.7] [--top-k 40] [--top-p 0.95] [--min-p 0.05] [--repeat-penalty 1.1]
         [--seed 0] [--ctx 4096] [--kv f16|f32|i8] [--max-tokens 512] [--threads N]
         [--draft MODEL] [--draft-tokens 4]

The defaults above are rml-chat's, set in ParseFlags (L83–89), not SamplerConfig's. With -p it answers once and exits; otherwise each line is a user turn, /reset starts over and an empty line quits. After each reply it prints prompt tokens (and how many were cached), prefill and decode tok/s, and with a draft the acceptance rate and tokens per round to stderr (L154–175). A draft is loaded with the main model's chat format, and --threads gives both models one pool (L191–243).

Tests

FileTestsWhat they pin down
sampler_test.cc14Greedy ties to the lowest id; unfiltered draws match the softmax; temperature; top-k, top-p and min-p each and composed; Distribution is what Sample draws from; Draw follows unnormalized weights; the penalty divides and multiplies once and sees only its window; same seed, same tokens; bad configs; size and history checks die.
generation_test.cc7Greedy generation equals rerunning from scratch; the next prompt's cached prefix is reused; stop tokens end generation unreported; caller and context limits; bad requests; the cache dtype is selectable; Reset forgets the context.
speculative_test.cc6 (one over draft lengths 1, 3, 5, 8)Kept tokens follow the target distribution; a token the target likes more is kept; greedy output matches the target alone; a draft equal to the target is always accepted; stopping matches the target alone; bad requests.
chat_test.cc11Stop ids gain the format's terminator; replies stream; later turns reuse the cache; greedy replies are deterministic; reset keeps the system prompt; a failed turn leaves the conversation; bad options; with a draft, greedy replies match the model alone and a same-model draft is always accepted; a draft with another tokenizer is refused; a missing file fails.

These run on the toy models from tools/make_llm_fixtures.py (see The decoder). The program itself is listed with the others on Examples.