DocsConcepts

The inference core

How curta-core computes a token: its three targets, its fixed-point arithmetic and lookup tables, the forward pass in order, the state a turn resumes from, the planner that sizes a turn, and sampling.

One crate, three targets

curta-core is one Rust crate with no dependencies. It holds the weight format, the tokenizer, the resumable forward pass, the sampler, the turn planner and the readers for the program’s accounts. The same source builds for three targets:

TargetBuildWho runs it
SBFThe default build, no_std.The curta program links it and calls it in open_run, turn and seal_model.
NativeThe std feature, which adds conversion and the curta-core binary.Conversion, inspection and replay from an artifact on a workstation.
WebAssemblyThe std feature for wasm32-unknown-unknown, with a plain C interface.@curta/sdk carries the module: the browser and the curta CLI quote, plan turns, replay and convert with it.
bash
# from backend/
cargo build --manifest-path crates/curta-core/Cargo.toml --features std --release
cargo rustc --manifest-path crates/curta-core/Cargo.toml --features std --target wasm32-unknown-unknown --release --lib -- --crate-type cdylib

The crate’s modules:

formatmodule
The artifact header, Config, Metadata and the table lookups. The weight format is its layout.
tokenizermodule
Lookup, encoding and decoding over the tokenizer stored in section zero.
inferencemodule
The state layout, init_state, turn and the arithmetic kernels.
plannermodule
Phase codes, the cursor, the compute charges and plan_turn.
protocolmodule
Sizes, tags and readers for the Config, Model, Run and weight accounts.
convertmodulestd only
The checkpoint converter, the only code in the crate that uses floating point.

Fixed-point arithmetic

int8 weights, fixed-point activations, lookup tables. Bit-exact across targets. Every value in the forward pass is an integer with a fixed count of fractional bits:

ValueWidthFractional bits
Activations, queries, logits, attention scoresi6416
Cached keys and valuesi3216
Weightsint8, stored plus 1280
Row scalesu3224
Normalization weightsi3224
Rotary cosines and sinesi3224
One over the square root of the head sizeu3224
Attention probabilitiesi6424
Exponential tableu3230
SiLU tablei3216
Temperatureu3216

A matrix row

The step the forward pass repeats most is a row of int8 weights times a vector of activations. The weights are stored as unsigned bytes, each the weight plus 128, so the kernel multiplies unsigned bytes by 64-bit words with no sign extension, eight at a time, and corrects once per vector:

one output element
sum  = x[0] + x[1] + … + x[columns − 1]                 // once per input vector
acc  = byte[0] × x[0] + … + byte[columns − 1] × x[columns − 1]
y    = ((acc − 128 × sum) × scale) >> 24

The products wrap in 64 bits, which gives the same result as the signed dot product. The activation buffer is aligned to eight bytes, and the core checks that alignment before a turn runs.

RMS normalization

normalization
rms   = isqrt(sum(x[i] × x[i]) / dimension + 42,950)      // integer square root, at least 1
y[i]  = ((x[i] × weight[i]) / rms) >> 8

The constant 42,950 is the epsilon 0.00001 with 32 fractional bits. Division rounds toward zero.

The attention cache

Keys and values are kept for every position a run has passed, at the same 16 fractional bits as the activations, in 32-bit words. Every store into the cache, including a key after rotary encoding, checks that the value fits 32 bits. Attention reads the cached words back sign-extended to 64 bits.

Lookup tables

The two nonlinear functions of the forward pass are tables in section zero of the weights, each of 4,097 entries, computed at conversion.

exponentiale^(−d)softmax
The input d is a difference of two scores with 16 fractional bits, zero or more. The index is d >> 8, capped at 4,096, so the table steps by 1/256 and covers differences from 0 to 16. The result has 30 fractional bits.
SiLUx × sigmoid(x)feed-forward
For an input from -16 to 16 the index is (x + 16) >> 9 in fixed point, a step of 1/128. An input of 16 or more returns itself, and an input of -16 or less returns zero.

The forward pass, in order

A run moves through numbered phases. The run’s state names the phase it is in, and a turn continues from there. Phases 0 to 2 tokenize the prompt and run once. Phases 3 to 16 run once per position, with 4 to 16 repeated for each layer. Phases 17 to 19 run for positions that generate a token.

PhaseOperationWhat it computes
0Open the promptWrites token 1, the opening token, and the token for a single space after it when the prompt has text.
1CharactersTakes the prompt one UTF-8 character at a time and looks it up in the vocabulary. A character with no token becomes one token per byte, id byte + 3.
2MergesLooks up every adjacent pair of tokens as one string, replaces the pair whose merged token has the highest score, and repeats until no pair merges. Then checks that the prompt’s tokens plus max tokens fit the context.
3EmbeddingReads the current token’s row of the embedding into the activation vector: ((byte − 128) × scale) >> 8 per element.
4Attention normalizationRMS normalization of the activations with the layer’s first set of weights.
5Query matrixThe normalized vector times the query matrix.
6Key matrixThe normalized vector times the key matrix, stored in the cache at this layer and position.
7Value matrixThe normalized vector times the value matrix, stored in the cache at this layer and position.
8Rotary encodingRotates each pair of query elements, and each pair of this position’s key elements, by the position’s cosine and sine.
9AttentionFor each head, four stages over the positions so far: scores, exponentials, normalization, weighted values.
10Attention output matrixThe heads’ outputs times the attention output matrix.
11Residual and feed-forward normalizationAdds the attention output to the activations, then RMS normalization with the layer’s second set of weights.
12Feed-forward gate matrixThe normalized vector times the gate matrix.
13Feed-forward up matrixThe normalized vector times the up matrix.
14SiLUEach gate element through the SiLU table, times the matching up element, shifted right 16 bits.
15Feed-forward down matrixThe hidden vector times the down matrix.
16ResidualAdds the feed-forward output to the activations. Then the next layer starts at phase 4, or the next prompt position starts at phase 3, or the pass goes on to phase 17.
17Final normalizationRMS normalization with the last set of weights.
18ClassifierThe activations times the classifier: one logit per vocabulary entry.
19SamplingChooses the token, appends its id and its text bytes to the output, and starts the next position at phase 3.
20DoneThe run produced token 1 or 2, or reached max tokens.

A prompt position before the last goes from phase 16 straight to the next position’s embedding: its keys and values are in the cache, and the classifier and the sampler run only where a token is generated.

Attention, stage by stage

Phase 9 handles one head at a time, and each head goes through four stages. With p positions so far, the current one included:

  1. Scores. For each position, the dot product of the head’s query with that position’s cached key, shifted right 16 bits, times one over the square root of the head size, shifted right 24 bits. The largest score is kept.
  2. Exponentials. Each score becomes the exponential table’s value for the largest score minus that score. Their sum is kept.
  3. Normalization. Each exponential is shifted left 24 bits and divided by the sum, which makes it a probability with 24 fractional bits.
  4. Weighted values. For each element of the head’s output, the sum over positions of probability times cached value, shifted right 24 bits.

With fewer key and value heads than heads, head h reads the keys and values of key and value head h / (heads / kvHeads).

The resumable state

Everything a run has computed lives in one buffer, the core state. On chain it is the run account after its 256-byte header. A turn reads its position from the state, works, and writes its position back, so the pass can stop between any two chunks of work and continue in a later transaction on any target.

The state opens with a 128-byte header tagged CURTAST2. All offsets count from the start of the state.

OffsetTypeField
08 bytesThe tag CURTAST2.
8u32Phase, 0 to 20.
12u32Layer.
16u32Row: the next row of the matrix in progress, the head in attention, the next vocabulary entry in sampling.
20u32Position in the sequence.
24u32The current token.
28u32The prompt’s length in bytes.
32u32The count of prompt tokens.
36u32Max tokens.
40u32Temperature, with 16 fractional bits.
44u32The count of tokens generated.
48u32The count of output text bytes.
52u32Tokenizer: the next prompt byte to read.
56u32Tokenizer: the pair being looked up in the current merge scan.
60u32Tokenizer: the position of the best merge found in this scan.
64u32Tokenizer: that merge’s score key.
68u32Tokenizer: that merge’s token id.
72u64The seed, which becomes the generator’s state as tokens are drawn.
80u64The count of turns the core has run on this state.
88i64The largest score: of the head in attention, of the logits in sampling.
96u32Sampling: the chosen vocabulary entry.
100u32Sampling: the pass in progress, 0 maximum, 1 sum, 2 draw.
104u64The sum of exponentials: of the head in attention, of the vocabulary in sampling.
112u64Sampling: what is left of the draw.
120u32Attention stage, 0 to 3.
124u32Attention index: the next position in stages 0 to 2, the next output element in stage 3.

After the header come the run’s buffers, each starting at a multiple of eight bytes:

BufferElementElements
Promptu8prompt bytes
Prompt tokensu32prompt bytes + 2
Output tokensu32max tokens
Output textu8max tokens × 128
Activations, and three working vectors of the same sizei64dimension, each
Feed-forward gate and up vectorsi64hidden dimension, each
Logitsi64vocabulary
Attention scoresi64cache positions
Keysi32layers × cache positions × key and value dimension
Valuesi32layers × cache positions × key and value dimension

Cache positions is the count of positions the run can reach: the prompt’s bytes plus max tokens plus one, up to the context length. state_size computes the whole size from the configuration, the prompt’s length in bytes and max tokens, and output_tokens and output_text read the output out of a state.

On the 16-byte prompt “Once upon a time”, a 12-token run on stories260K has 29 cache positions and a run account of 48,304 bytes, header included. An 8-token run on stories15M has 25 positions and 624,832 bytes. With an empty prompt and max tokens equal to the context, the accounts are 736,328 and 3,852,680 bytes.

The turn planner

The planner decides how far one turn goes. It divides the forward pass by compute units, from the configuration and the state alone, so a turn takes no input from its sender and every target cuts the pass at the same places.

The unit of work is a chunk: a run of rows of one matrix, a run of positions or elements of one attention stage, a run of vocabulary entries of one sampling pass, or one whole vector operation. The planner charges each chunk a number of compute units and adds chunks until one of three things happens:

  • the next chunk does not fit what is left of the turn’s 1,350,000 compute units
  • the next chunk needs a third matrix section
  • the run is complete

A turn starts with 18,000 units charged for the instruction itself: accounts, payment and the event. Every chunk adds 160 units for reading the cursor, dispatching and writing the cursor back. A transaction may use 1,400,000 compute units, and the planner’s 1,350,000 sits inside that.

A turn runs across phase, layer and position boundaries: it finishes a token, samples it, and starts the next one when units remain. Tokenizing the prompt reads section zero alone, and the turn that finishes it stops at the first embedding.

sections[u32; 2]
The matrix sections the turn reads, in the order it reaches them. Section zero is always read and is left out of this list.
section_countusize0 to 2
How many entries of sections are used. Zero for a turn that tokenizes.
estimated_cuu32
The compute units the planner charged for the turn.
max_generatedu32
The most tokens the turn can complete. The program reserves the royalty for this many before the turn runs.
endCursor
Where the state’s cursor stands after the turn.

plan_turn returns this plan for a state. The program plans each turn to check the weight accounts the transaction names and to price the turn, and the SDK plans the same turn to choose those accounts. quote_turns tokenizes a prompt and then steps the planner to max tokens, which gives the count of turns a run takes. A run that produces token 1 or 2 early ends in fewer.

Compute costs

These are the planner’s charges, in compute units, before the 160 units per chunk. d is the dimension, h the hidden dimension and n the count of columns of a matrix.

Matrices

WorkCompute units
Setup of a matrix chunk: summing the input vector6 × n + 350
One row37 × floor(n / 8) + 8 × (n mod 8) + 34
One row of a key or value matrix, stored in the cache37 × floor(n / 8) + 8 × (n mod 8) + 42

The row kernel runs 37 instructions for every eight weights. A chunk takes as many rows as fit, so a large matrix spans several turns and a small one shares a turn with its neighbours.

Vector operations

PhaseCompute units
Embedding16 × d + 512
RMS normalization, before attention and final48 × d + 1,500
Rotary encoding48 × d + 512
Residual and feed-forward normalization56 × d + 1,500
SiLU24 × h + 512
Residual8 × d + 128

Attention

Each stage of a head is charged per unit, with q the head size and p the positions so far. A stage can stop after any unit and continue in the next turn.

StageUnitsPer unitSetup
0, scoresp positions53 × floor(q / 8) + 11 × (q mod 8) + 25100
1, exponentialsp positions2380
2, normalizationp positions860
3, weighted valuesq output elements11 × p + 3260

Sampling

WorkCompute units
Setup of a sampling chunk350
One vocabulary entry, maximum pass11
One vocabulary entry, sum pass36
One vocabulary entry, draw pass42
Finishing a pass: advancing the generator, decoding and appending the token4,000

Tokenizing

WorkCompute units
One vocabulary lookup of b bytes500 + (floor(log2(vocabulary)) + 1) × (100 + 20 × min(b, 128))
A step between tokenizer phases1,000
Applying a merge to a sequence of t tokens1,000 + 4 × t

A lookup is a binary search over the sorted token ids. A character’s lookup uses its own byte length and a merge’s lookup uses the joined length of its two tokens.

One position, added up

The planner’s charges for one generating position at the end of the context, at temperature zero: position 511 of stories260K, which takes 5 turns, and position 255 of stories15M, which takes 62. Each row sums every layer and every chunk.

Phasestories260Kstories15M
Embedding1,6965,280
Attention normalization23,66092,904
Query matrix110,0702,373,876
Key matrix58,5502,392,176
Value matrix58,5502,392,176
Rotary encoding18,72086,976
Attention scores, softmax and values4,083,0808,403,448
Attention output matrix110,0702,378,352
Feed-forward normalization26,220106,728
Feed-forward gate matrix288,2706,321,384
Feed-forward up matrix288,2706,316,908
SiLU24,000114,624
Feed-forward down matrix279,0126,247,788
Residual4,00015,552
Final normalization4,73215,484
Classifier169,85443,785,854
Sampling and decoding10,142357,020
Fixed charge across the turns90,0001,116,000
Total5,648,89682,522,530

The classifier of stories15M is 32,000 rows of 288 weights, over nine million multiply-accumulates for each generated token.

Sampling

The core samples each token itself, from the logits in the state, the run’s temperature and the run’s seed. Sampling is phase 19, and it makes one pass over the vocabulary at temperature zero and three passes above zero. Each pass is resumable at any vocabulary entry.

  1. Maximum. Finds the largest logit and its index. At temperature zero that index is the token.
  2. Sum. For each logit, takes the largest logit minus it, divides by the temperature, reads the exponential table, and adds the result to a sum.
  3. Draw. Advances the generator once and reduces its output modulo the sum. Walks the vocabulary again, subtracting each entry’s exponential, and takes the first entry the remainder falls inside.

The generator is xorshift64*, with the run’s seed as its first state:

one draw
x ^= x >> 12
x ^= x << 25
x ^= x >> 27                                  // x is stored back as the generator's state
draw = (x × 0x2545F4914F6CDD1D mod 2^64) mod sum

A seed of zero starts the generator at 0x9E3779B97F4A7C15. The draw runs over the whole vocabulary, weighted by the exponentials.

The chosen token’s id is appended to the output tokens. Its text is the token’s bytes from the tokenizer, with two rules from llama2.c: a leading space is dropped right after the opening token, and a token written <0xXX> becomes that one byte. Tokens 1 and 2 add no text and complete the run. The run is also complete when the count of generated tokens reaches max tokens.

The Rust API

state_size(config, prompt_len, max_tokens)Result<usize, Error>
The size of the state for a prompt of prompt_len bytes, up to 1,024, and max_tokens from 1 to the context length.
init_state(config, state, prompt, max_tokens, temperature_q16, seed)Result<(), Error>
Writes a fresh header and the prompt into a buffer of exactly state_size bytes. The prompt is UTF-8 with no zero byte. The temperature is at most 16, which is 1,048,576 with 16 fractional bits.
planner::plan_turn(config, state)Result<TurnPlan, Error>
The plan of the next turn.
turn(config, state, metadata, sections)Result<TurnResult, Error>
Runs one turn. metadata is section zero, and sections are the matrix sections the plan names, each a WeightSection with its index and bytes.
output_tokens(config, state)&[u8]
The generated token ids, four little-endian bytes each.
output_text(config, state)&[u8]
The generated text, UTF-8.
StateInfo::read(state)Result<StateInfo, Error>
The header’s cursor: phase, layer, row, position, prompt_tokens, generated, text_len, turns and complete.
inference::quote_turns(config, metadata, prompt, max, temperature_q16, scratch)Result<u64, Error>
The count of turns a run takes, computed in a scratch buffer of state_size bytes.

turn returns a TurnResult: complete, generated (tokens so far), generated_this_turn, token (the last token this turn sampled, if any) and work (the compute units the planner charged).

run.rs
use curta_core::{
    format::SECTION_TABLE, init_state, output_text, planner, read_u64, state_size, turn,
    Metadata, StateInfo, WeightSection,
};

fn main() {
    let artifact = std::fs::read("stories260K.curta").unwrap();
    // Section zero's length is the second field of the first section table entry.
    let zero = read_u64(&artifact, SECTION_TABLE + 8) as usize;
    let m = Metadata::new(&artifact[..zero]).unwrap();

    let prompt = b"Once upon a time";
    let mut state = vec![0u8; state_size(m.config, prompt.len(), 32).unwrap()];
    init_state(m.config, &mut state, prompt, 32, 0, 42).unwrap();

    while !StateInfo::read(&state).unwrap().complete {
        let plan = planner::plan_turn(m.config, &state).unwrap();
        let mut sections = [WeightSection { index: 0, bytes: &[] }; 2];
        for i in 0..plan.section_count {
            let index = plan.sections[i] as usize;
            let offset = m.section_offset(index);
            sections[i] = WeightSection {
                index: index as u32,
                bytes: &artifact[offset..offset + m.section_len(index)],
            };
        }
        turn(m.config, &mut state, m.bytes, &sections[..plan.section_count]).unwrap();
    }
    println!("{}", String::from_utf8_lossy(output_text(m.config, &state)));
}

WebAssembly exports

The WebAssembly build exports plain functions over the module’s linear memory. Pointers and lengths are u32. The configuration is passed as a pointer to 28 bytes, the seven u32 values in order. @curta/sdk wraps these as CoreWasm and CoreSession, loaded with loadCore().

alloc(length)→ pointer
Reserves length zeroed bytes in the module’s memory.
dealloc(pointer, length)
Releases bytes from alloc or convert.
state_size(config, promptLength, maxTokens)→ u32
The state’s size. 0xFFFFFFFF for parameters out of range.
init_state(config, state, stateLength, prompt, promptLength, maxTokens, temperatureQ16, seedLow, seedHigh)→ error
Starts a state. The seed is passed as its low and high 32 bits.
plan_turn(config, state, stateLength, output)→ error
Writes 20 bytes at output: section count, first section, second section, estimated compute units and the most tokens the turn can complete, each a u32.
turn_with_sections(config, state, stateLength, metadata, metadataLength, first, firstLength, second, secondLength)→ error
Runs one turn with the sections its plan names. A slot the plan leaves out is passed empty.
quote(config, metadata, metadataLength, prompt, promptLength, maxTokens, temperatureQ16)→ u64
The count of turns a run takes.
convert(checkpoint, checkpointLength, tokenizer, tokenizerLength)→ pointer
Converts a checkpoint and returns the artifact’s address, or zero. result_len() returns its length.
required_section(config, state, stateLength)→ u32
The first section the next turn reads, zero for a turn that reads section zero alone.
turn(config, state, stateLength, metadata, metadataLength, weights, weightsLength)→ error
Runs a turn whose plan names one section.

An error result of zero is success, and any other value is a core error’s number.

Core errors

The core’s functions return one of eight errors. The WebAssembly exports return its number, and the SDK reports it as Curta core error N. The program reports a core error inside a turn as CoreError, listed with the program’s other errors in Errors.

NumberNameWhat raises it
1InvalidConfigA configuration value outside its range, or a turn allowance outside 22,096 to 1,350,000 compute units.
2InvalidFormatWeights that do not open with CURTAW01, a header whose counts, offsets or descriptors do not fit the file, or a checkpoint or tokenizer file that does not match its format.
3InvalidStateA state that does not open with CURTAST2, has the wrong size for its configuration, holds a cursor out of range, is complete, or is not aligned to eight bytes.
4BufferTooSmallinit_state given a buffer whose length is not state_size.
5MissingWeightsA turn without the matrix section its position needs, or with a section shorter than the matrix it holds.
6InvalidPromptA prompt over 1,024 bytes, with a zero byte or not UTF-8. Max tokens of zero or above the context length. A temperature above 16.
7ContextExceededThe prompt’s tokens plus max tokens, less one, above the context length.
8ArithmeticA key or value that does not fit the cache’s 32 bits.

Measured on a validator

The program’s turns on the reference runs, on the prompt “Once upon a time” at temperature zero, from the receipts of their transactions:

ModelCompute units per multiply-accumulateTurnsMedian compute units per turnPeakSimulated turn at full context
stories260K5.11201,357,097.51,368,7371,362,625
stories15M4.735301,344,3891,351,2641,348,924

Compute units per multiply-accumulate is the slope between two simulated turns that differ only in the count of matrix rows. Both runs produce the same tokens and the same text bytes on the program, the native build and the WebAssembly build.