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:
| Target | Build | Who runs it |
|---|---|---|
| SBF | The default build, no_std. | The curta program links it and calls it in open_run, turn and seal_model. |
| Native | The std feature, which adds conversion and the curta-core binary. | Conversion, inspection and replay from an artifact on a workstation. |
| WebAssembly | The 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. |
# 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 cdylibThe crate’s modules:
formatmodule- The artifact header,
Config,Metadataand 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,turnand the arithmetic kernels. plannermodule- Phase codes, the cursor, the compute charges and
plan_turn. protocolmodule- Sizes, tags and readers for the
Config,Model,Runand 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:
| Value | Width | Fractional bits |
|---|---|---|
| Activations, queries, logits, attention scores | i64 | 16 |
| Cached keys and values | i32 | 16 |
| Weights | int8, stored plus 128 | 0 |
| Row scales | u32 | 24 |
| Normalization weights | i32 | 24 |
| Rotary cosines and sines | i32 | 24 |
| One over the square root of the head size | u32 | 24 |
| Attention probabilities | i64 | 24 |
| Exponential table | u32 | 30 |
| SiLU table | i32 | 16 |
| Temperature | u32 | 16 |
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:
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) >> 24The 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
rms = isqrt(sum(x[i] × x[i]) / dimension + 42,950) // integer square root, at least 1
y[i] = ((x[i] × weight[i]) / rms) >> 8The 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
dis a difference of two scores with 16 fractional bits, zero or more. The index isd >> 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) >> 9in 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.
| Phase | Operation | What it computes |
|---|---|---|
| 0 | Open the prompt | Writes token 1, the opening token, and the token for a single space after it when the prompt has text. |
| 1 | Characters | Takes 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. |
| 2 | Merges | Looks 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. |
| 3 | Embedding | Reads the current token’s row of the embedding into the activation vector: ((byte − 128) × scale) >> 8 per element. |
| 4 | Attention normalization | RMS normalization of the activations with the layer’s first set of weights. |
| 5 | Query matrix | The normalized vector times the query matrix. |
| 6 | Key matrix | The normalized vector times the key matrix, stored in the cache at this layer and position. |
| 7 | Value matrix | The normalized vector times the value matrix, stored in the cache at this layer and position. |
| 8 | Rotary encoding | Rotates each pair of query elements, and each pair of this position’s key elements, by the position’s cosine and sine. |
| 9 | Attention | For each head, four stages over the positions so far: scores, exponentials, normalization, weighted values. |
| 10 | Attention output matrix | The heads’ outputs times the attention output matrix. |
| 11 | Residual and feed-forward normalization | Adds the attention output to the activations, then RMS normalization with the layer’s second set of weights. |
| 12 | Feed-forward gate matrix | The normalized vector times the gate matrix. |
| 13 | Feed-forward up matrix | The normalized vector times the up matrix. |
| 14 | SiLU | Each gate element through the SiLU table, times the matching up element, shifted right 16 bits. |
| 15 | Feed-forward down matrix | The hidden vector times the down matrix. |
| 16 | Residual | Adds 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. |
| 17 | Final normalization | RMS normalization with the last set of weights. |
| 18 | Classifier | The activations times the classifier: one logit per vocabulary entry. |
| 19 | Sampling | Chooses the token, appends its id and its text bytes to the output, and starts the next position at phase 3. |
| 20 | Done | The 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:
- 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.
- Exponentials. Each score becomes the exponential table’s value for the largest score minus that score. Their sum is kept.
- Normalization. Each exponential is shifted left 24 bits and divided by the sum, which makes it a probability with 24 fractional bits.
- 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.
| Offset | Type | Field |
|---|---|---|
| 0 | 8 bytes | The tag CURTAST2. |
| 8 | u32 | Phase, 0 to 20. |
| 12 | u32 | Layer. |
| 16 | u32 | Row: the next row of the matrix in progress, the head in attention, the next vocabulary entry in sampling. |
| 20 | u32 | Position in the sequence. |
| 24 | u32 | The current token. |
| 28 | u32 | The prompt’s length in bytes. |
| 32 | u32 | The count of prompt tokens. |
| 36 | u32 | Max tokens. |
| 40 | u32 | Temperature, with 16 fractional bits. |
| 44 | u32 | The count of tokens generated. |
| 48 | u32 | The count of output text bytes. |
| 52 | u32 | Tokenizer: the next prompt byte to read. |
| 56 | u32 | Tokenizer: the pair being looked up in the current merge scan. |
| 60 | u32 | Tokenizer: the position of the best merge found in this scan. |
| 64 | u32 | Tokenizer: that merge’s score key. |
| 68 | u32 | Tokenizer: that merge’s token id. |
| 72 | u64 | The seed, which becomes the generator’s state as tokens are drawn. |
| 80 | u64 | The count of turns the core has run on this state. |
| 88 | i64 | The largest score: of the head in attention, of the logits in sampling. |
| 96 | u32 | Sampling: the chosen vocabulary entry. |
| 100 | u32 | Sampling: the pass in progress, 0 maximum, 1 sum, 2 draw. |
| 104 | u64 | The sum of exponentials: of the head in attention, of the vocabulary in sampling. |
| 112 | u64 | Sampling: what is left of the draw. |
| 120 | u32 | Attention stage, 0 to 3. |
| 124 | u32 | Attention 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:
| Buffer | Element | Elements |
|---|---|---|
| Prompt | u8 | prompt bytes |
| Prompt tokens | u32 | prompt bytes + 2 |
| Output tokens | u32 | max tokens |
| Output text | u8 | max tokens × 128 |
| Activations, and three working vectors of the same size | i64 | dimension, each |
| Feed-forward gate and up vectors | i64 | hidden dimension, each |
| Logits | i64 | vocabulary |
| Attention scores | i64 | cache positions |
| Keys | i32 | layers × cache positions × key and value dimension |
| Values | i32 | layers × 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
sectionsare 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
| Work | Compute units |
|---|---|
| Setup of a matrix chunk: summing the input vector | 6 × n + 350 |
| One row | 37 × floor(n / 8) + 8 × (n mod 8) + 34 |
| One row of a key or value matrix, stored in the cache | 37 × 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
| Phase | Compute units |
|---|---|
| Embedding | 16 × d + 512 |
| RMS normalization, before attention and final | 48 × d + 1,500 |
| Rotary encoding | 48 × d + 512 |
| Residual and feed-forward normalization | 56 × d + 1,500 |
| SiLU | 24 × h + 512 |
| Residual | 8 × 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.
| Stage | Units | Per unit | Setup |
|---|---|---|---|
| 0, scores | p positions | 53 × floor(q / 8) + 11 × (q mod 8) + 25 | 100 |
| 1, exponentials | p positions | 23 | 80 |
| 2, normalization | p positions | 8 | 60 |
| 3, weighted values | q output elements | 11 × p + 32 | 60 |
Sampling
| Work | Compute units |
|---|---|
| Setup of a sampling chunk | 350 |
| One vocabulary entry, maximum pass | 11 |
| One vocabulary entry, sum pass | 36 |
| One vocabulary entry, draw pass | 42 |
| Finishing a pass: advancing the generator, decoding and appending the token | 4,000 |
Tokenizing
| Work | Compute units |
|---|---|
One vocabulary lookup of b bytes | 500 + (floor(log2(vocabulary)) + 1) × (100 + 20 × min(b, 128)) |
| A step between tokenizer phases | 1,000 |
Applying a merge to a sequence of t tokens | 1,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.
| Phase | stories260K | stories15M |
|---|---|---|
| Embedding | 1,696 | 5,280 |
| Attention normalization | 23,660 | 92,904 |
| Query matrix | 110,070 | 2,373,876 |
| Key matrix | 58,550 | 2,392,176 |
| Value matrix | 58,550 | 2,392,176 |
| Rotary encoding | 18,720 | 86,976 |
| Attention scores, softmax and values | 4,083,080 | 8,403,448 |
| Attention output matrix | 110,070 | 2,378,352 |
| Feed-forward normalization | 26,220 | 106,728 |
| Feed-forward gate matrix | 288,270 | 6,321,384 |
| Feed-forward up matrix | 288,270 | 6,316,908 |
| SiLU | 24,000 | 114,624 |
| Feed-forward down matrix | 279,012 | 6,247,788 |
| Residual | 4,000 | 15,552 |
| Final normalization | 4,732 | 15,484 |
| Classifier | 169,854 | 43,785,854 |
| Sampling and decoding | 10,142 | 357,020 |
| Fixed charge across the turns | 90,000 | 1,116,000 |
| Total | 5,648,896 | 82,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.
- Maximum. Finds the largest logit and its index. At temperature zero that index is the token.
- 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.
- 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:
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 sumA 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_lenbytes, up to 1,024, andmax_tokensfrom 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_sizebytes. 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.
metadatais section zero, andsectionsare the matrix sections the plan names, each aWeightSectionwith itsindexandbytes. 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,turnsandcomplete. 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_sizebytes.
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).
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, §ions[..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
lengthzeroed bytes in the module’s memory. dealloc(pointer, length)- Releases bytes from
allocorconvert. state_size(config, promptLength, maxTokens)→ u32- The state’s size.
0xFFFFFFFFfor 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.
| Number | Name | What raises it |
|---|---|---|
| 1 | InvalidConfig | A configuration value outside its range, or a turn allowance outside 22,096 to 1,350,000 compute units. |
| 2 | InvalidFormat | Weights 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. |
| 3 | InvalidState | A 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. |
| 4 | BufferTooSmall | init_state given a buffer whose length is not state_size. |
| 5 | MissingWeights | A turn without the matrix section its position needs, or with a section shorter than the matrix it holds. |
| 6 | InvalidPrompt | A 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. |
| 7 | ContextExceeded | The prompt’s tokens plus max tokens, less one, above the context length. |
| 8 | Arithmetic | A 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:
| Model | Compute units per multiply-accumulate | Turns | Median compute units per turn | Peak | Simulated turn at full context |
|---|---|---|---|---|---|
| stories260K | 5.11 | 20 | 1,357,097.5 | 1,368,737 | 1,362,625 |
| stories15M | 4.73 | 530 | 1,344,389 | 1,351,264 | 1,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.