26 · Content revision 2
Create query, key, and value views
Learn how Transformer self-attention creates query, key, and value tensors from one hidden-state sequence through three independent bias-free projections.
Predict three outputs from one sequence
Start with one batch containing two token states:
Use three distinct weights:
Before running the example, multiply the first token by each weight. The expected vectors are , , and . The second token should produce , , and .
The fixture produces three different answers because its three weights differ. Independent parameter sets do not guarantee different numbers for every possible input, but they let each role learn a different representation. Batch and token coordinates remain aligned.
Project the final feature axis three ways
The complete forward rule is
Its shape contract is
Every branch applies the same last-axis matrix operation independently at each batch item and token position. It does not compare tokens. In particular, these three projections alone produce no similarity scores, probabilities, causal mask, or weighted value mixture.
For the frozen fixture, the complete result is
Keep roles, axes, and dimensions separate
- is the hidden-state tensor entering self-attention.
- is the batch size and is the number of token positions.
- is the input feature width.
- is the output width for this one-head chapter.
- , , and are independent learned weights.
- is the query view: it will ask what each position should retrieve.
- is the key view: it will describe how each position can be matched.
- is the value view: it carries the content that a later mixture can retrieve.
The names do not perform attention by themselves. This chapter also imposes no multi-head divisibility constraint. Chapter 30 introduces a head count and the usual relation when it splits and merges multiple heads.
From learned alignment to self-attention projections
This history follows neural attention on the road to modern LLMs, not the history of any programming language.
Basic recurrent encoder-decoder models compressed the source into one fixed vector. Bahdanau, Cho, and Bengio, Neural Machine Translation by Jointly Learning to Align and Translate replace that bottleneck with differentiable alignment. At each target step, Bahdanau, Cho, and Bengio score every encoder annotation with the previous decoder state and use the resulting weights to form a context vector. In their notation, is the previous decoder state and is one encoder annotation entering the learned compatibility function. The query-side state and annotation-side content still come from two different parts of the encoder-decoder model.
It is useful to call the decoder state query-like and an annotation key/value-like, but this is only a retrospective bridge. Bahdanau and colleagues do not use this chapter’s query, key, and value terminology or its three-matrix layout.
Vaswani et al., Attention Is All You Need describe attention as mapping a query and key-value pairs to an output. Vaswani et al. use separate learned linear projections for queries, keys, and values and define self-attention over one sequence. Self-attention replaces two-source alignment with one previous-layer sequence feeding all three roles before scores are computed.
This separates the representations used for matching from the representation whose content will be mixed. The decoder built in this course uses the same sequence-to-three-projections pattern before causal attention. Its bias-free weights and exact dimensions are implementation choices, not claims about every Transformer.
The following comparison isolates the change in where attention inputs come from:
rust/demos/ch26-qkv-projections/src/lib.rs#historical-attention-source-contrast fn historical_source_contrast() -> HistoryEvidence {
HistoryEvidence {
earlier_left: "decoder-state",
earlier_right: "encoder-annotations",
transformer_source: "one-sequence",
mapping: "retrospective",
}
} Compose three existing differentiable linear layers
QkvProjections owns three bias-free Linear layers. Construction initializes
query, key, and value weights in that stable order using a temporary copy of the
seeded generator. It updates the original generator only after all three
branches are valid. Manual construction checks each branch, shared dimensions,
and unique names before returning the layer:
rust/crates/llm-from-scratch/src/attention/qkv.rs#qkv-layer /// The three projected views of the same batch and token positions.
#[derive(Clone, Debug)]
pub struct QkvForward {
query: TensorValue,
key: TensorValue,
value: TensorValue,
}
impl QkvForward {
pub fn query(&self) -> &TensorValue {
&self.query
}
pub fn key(&self) -> &TensorValue {
&self.key
}
pub fn value(&self) -> &TensorValue {
&self.value
}
pub fn into_parts(self) -> (TensorValue, TensorValue, TensorValue) {
(self.query, self.key, self.value)
}
}
/// Three independent `[model_width, head_width]` linear maps with no biases.
#[derive(Clone, Debug)]
pub struct QkvProjections {
query: Linear,
key: Linear,
value: Linear,
parameters: NamedParameters,
model_width: usize,
head_width: usize,
}
impl QkvProjections {
/// Initializes Q, K, and V in that order without partially advancing `rng`.
pub fn new(
parameter_prefix: impl Into<String>,
model_width: usize,
head_width: usize,
rng: &mut SplitMix64,
) -> Result<Self, QkvError> {
let parameter_prefix = parameter_prefix.into();
let mut trial = rng.clone();
let query = Linear::new(
format!("{parameter_prefix}.query"),
model_width,
head_width,
false,
&mut trial,
)
.map_err(projection_error(QkvProjection::Query))?;
let key = Linear::new(
format!("{parameter_prefix}.key"),
model_width,
head_width,
false,
&mut trial,
)
.map_err(projection_error(QkvProjection::Key))?;
let value = Linear::new(
format!("{parameter_prefix}.value"),
model_width,
head_width,
false,
&mut trial,
)
.map_err(projection_error(QkvProjection::Value))?;
let projections = Self::from_layers(query, key, value)?;
*rng = trial;
Ok(projections)
}
/// Gives Q/K/V semantics to three existing matrix parameters.
pub fn from_weights(
query_weight: NamedParameter,
key_weight: NamedParameter,
value_weight: NamedParameter,
) -> Result<Self, QkvError> {
let query = Linear::from_parameters(query_weight, None)
.map_err(projection_error(QkvProjection::Query))?;
let key = Linear::from_parameters(key_weight, None)
.map_err(projection_error(QkvProjection::Key))?;
let value = Linear::from_parameters(value_weight, None)
.map_err(projection_error(QkvProjection::Value))?;
Self::from_layers(query, key, value)
}
fn from_layers(query: Linear, key: Linear, value: Linear) -> Result<Self, QkvError> {
let input_widths = (query.input_width(), key.input_width(), value.input_width());
if input_widths.0 != input_widths.1 || input_widths.0 != input_widths.2 {
return Err(QkvError::BranchInputWidthMismatch {
query: input_widths.0,
key: input_widths.1,
value: input_widths.2,
});
}
let output_widths = (
query.output_width(),
key.output_width(),
value.output_width(),
);
if output_widths.0 != output_widths.1 || output_widths.0 != output_widths.2 {
return Err(QkvError::BranchOutputWidthMismatch {
query: output_widths.0,
key: output_widths.1,
value: output_widths.2,
});
}
let parameters = NamedParameters::try_new(vec![
query.weight().clone(),
key.weight().clone(),
value.weight().clone(),
])?;
Ok(Self {
query,
key,
value,
parameters,
model_width: input_widths.0,
head_width: output_widths.0,
})
}
/// Projects exactly `[batch, tokens, model_width]` into three head-width views.
pub fn forward(&self, input: &TensorValue) -> Result<QkvForward, QkvError> {
let shape = input.shape();
if shape.len() != 3 {
return Err(QkvError::InputRank { rank: shape.len() });
}
if shape[2] != self.model_width {
return Err(QkvError::InputWidthMismatch {
expected: self.model_width,
actual: shape[2],
});
}
let query = self
.query
.forward(input)
.map_err(projection_error(QkvProjection::Query))?;
let key = self
.key
.forward(input)
.map_err(projection_error(QkvProjection::Key))?;
let value = self
.value
.forward(input)
.map_err(projection_error(QkvProjection::Value))?;
Ok(QkvForward { query, key, value })
}
pub fn query(&self) -> &Linear {
&self.query
}
pub fn key(&self) -> &Linear {
&self.key
}
pub fn value(&self) -> &Linear {
&self.value
}
pub fn parameters(&self) -> &[NamedParameter] {
self.parameters.as_slice()
}
pub const fn model_width(&self) -> usize {
self.model_width
}
pub const fn head_width(&self) -> usize {
self.head_width
}
pub const fn parameter_count(&self) -> usize {
3 * self.model_width * self.head_width
}
} The wrapper requires exactly rank-three input so the batch and token axes remain explicit. Typed failures preserve rank-before-width and query-before-key-before-value precedence:
rust/crates/llm-from-scratch/src/attention/qkv.rs#qkv-errors /// The projection branch that rejected construction or a delegated operation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QkvProjection {
Query,
Key,
Value,
}
impl fmt::Display for QkvProjection {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Query => "query",
Self::Key => "key",
Self::Value => "value",
})
}
}
/// A rejected Q/K/V parameter set or hidden-state input.
#[derive(Clone, Debug, PartialEq)]
pub enum QkvError {
Projection {
projection: QkvProjection,
source: LinearError,
},
InputRank {
rank: usize,
},
InputWidthMismatch {
expected: usize,
actual: usize,
},
BranchInputWidthMismatch {
query: usize,
key: usize,
value: usize,
},
BranchOutputWidthMismatch {
query: usize,
key: usize,
value: usize,
},
Initialization(InitializationError),
}
impl fmt::Display for QkvError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Projection { projection, source } => {
write!(formatter, "{projection} projection: {source}")
}
Self::InputRank { rank } => write!(
formatter,
"Q/K/V input must have rank three [batch, tokens, model_width], got rank {rank}"
),
Self::InputWidthMismatch { expected, actual } => write!(
formatter,
"Q/K/V input final width must equal model width {expected}, got {actual}"
),
Self::BranchInputWidthMismatch { query, key, value } => write!(
formatter,
"Q/K/V model widths must match, got query {query}, key {key}, value {value}"
),
Self::BranchOutputWidthMismatch { query, key, value } => write!(
formatter,
"Q/K/V head widths must match, got query {query}, key {key}, value {value}"
),
Self::Initialization(source) => source.fmt(formatter),
}
}
}
impl Error for QkvError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Projection { source, .. } => Some(source),
Self::Initialization(source) => Some(source),
_ => None,
}
}
}
impl From<InitializationError> for QkvError {
fn from(error: InitializationError) -> Self {
Self::Initialization(error)
}
}
fn projection_error(projection: QkvProjection) -> impl FnOnce(LinearError) -> QkvError {
move |source| QkvError::Projection { projection, source }
} The reverse fixture forms one scalar objective,
If bars denote reverse-mode gradients, all three paths accumulate into the gradient of the shared input:
For the weights, flatten the batch and token axes into one row axis, denoted by the subscript . Each branch then keeps its own gradient:
With the fixture’s upstream gradients, the shared-input result is
while each branch keeps its own weight gradient. Central differences check all six coordinates of , , , and with step and tolerance . Empty batch and token axes remain connected to the gradient tape, and two runs replay by exact floating-point bit pattern:
rust/demos/ch26-qkv-projections/src/lib.rs#qkv-fixture pub fn learner_evidence() -> Result<LearnerEvidence, FixtureError> {
let primary = primary_once()?;
let replay = primary_once()?;
let (query_changed, key_unchanged, value_unchanged) = independence_evidence()?;
let (input_checks, query_weight_checks, key_weight_checks, value_weight_checks, passed) =
gradient_evidence(&primary)?;
Ok(LearnerEvidence {
replay_bitwise: same_primary_bits(&primary, &replay),
primary,
shapes: shape_evidence()?,
errors: error_evidence()?,
initialization: initialization_evidence()?,
history: historical_source_contrast(),
query_changed,
key_unchanged,
value_unchanged,
input_checks,
query_weight_checks,
key_weight_checks,
value_weight_checks,
gradcheck_passed: passed,
})
} rust/demos/ch26-qkv-projections/src/main.rs fn main() -> Result<(), Box<dyn std::error::Error>> {
let evidence = ch26_qkv_projections::learner_evidence()?;
print!("{}", ch26_qkv_projections::render_report(&evidence));
Ok(())
} Run cargo run --quiet --locked -p ch26-qkv-projections to inspect the same
forward values, gradients, shape probes, and rejected inputs from the executable
example.
Inspect how one sequence becomes three learned representations
The diagram brings the shared input, three projection weights, exact outputs, combined reverse path, branch-local gradients, empty shapes, rejected inputs, and historical source comparison into one view:
Split one hidden sequence into three learned views
Trace one hidden-state sequence through query, key, and value projections, then compare their shapes, gradients, independence, historical sources, and rejected inputs.
- Solid query border
- Dashed key border
- Double value border
Preserve positions while changing feature space
Shared hidden-state input
- Shape
- Bias policy
bias=false
-
Solid query border Query projection
Query: what should this position retrieve?
- Stable parameter
decoder.block.0.attention.query.weight- Projection weight
- Projected output
Projected output -
Dashed key border Key projection
Key: how can this position be matched?
- Stable parameter
decoder.block.0.attention.key.weight- Projection weight
- Projected output
Projected output -
Double value border Value projection
Value: what content can this position contribute?
- Stable parameter
decoder.block.0.attention.value.weight- Projection weight
- Projected output
Projected output
The three branches share coordinates, not weights or feature values.
Change from two attention sources to one
| Previous decoder state | Encoder annotations | One previous-layer sequence | |
|---|---|---|---|
| Additive encoder-decoder attention | decoder-state | encoder-annotations | — |
| Transformer self-attention | — | — | one-sequence |
Query-like and key/value-like are a limited retrospective analogy for the earlier two-stream mechanism.
Check independence, gradients, and boundaries
Combined input gradient
Branch-local weight gradients
| Query: what should this position retrieve? | |
|---|---|
| Key: how can this position be matched? | |
| Value: what content can this position contribute? |
Change only the query weight
- Query: what should this position retrieve?
- Changed
- Key: how can this position be matched?
- Unchanged
- Value: what content can this position contribute?
- Unchanged
Empty batch axis
Empty token axis
Rejected boundaries
- Rejected
rank-twoThe input must keep explicit batch, token, and feature axes. - Rejected
input-widthThe final input axis must match the model width. - Rejected
branch-mismatchAll three projection weights must use the same model width.
Numerical checks
gradcheck=true replay=bitwise names=unique initialization=transactional
The fixture checks every input and weight coordinate and confirms repeatable, independent projections.
Read the three branches from the same input coordinate. Their solid, dashed, and double borders distinguish query, key, and value without relying on color. The diagram stops before comparing queries with keys: it shows the representations that attention will consume, not an attention decision.
Predict before reading the evidence
- Predict all three output shapes for input when .
- Compute , , and for the first frozen token.
- Decide whether changing only can change or .
- Count the trainable scalars in three bias-free weights.
- Predict the three outputs’ shapes for inputs and .
- Explain why rank-two input is rejected by this wrapper.
- Decide whether must be divisible by here.
- Identify which historical mechanism uses two source streams and which uses one.
- State which computation is still missing before these tensors form an attention output.
Check the predictions
- Each output has shape .
- The vectors are , , and .
- No. Independent weights make the unchanged key and value outputs replay bitwise.
- The count is .
- The outputs have shapes and for every branch.
- The API keeps both batch and token axes explicit, so it requires rank three.
- No. Divisibility belongs to later multi-head splitting.
- Additive encoder-decoder attention uses decoder and encoder streams; self-attention projects one sequence three ways.
- Chapter 27 must compute query-key scores, probabilities, and a weighted value mixture.
Compare queries with keys and mix values next
The cumulative decoder now accepts a normalized hidden sequence with shape and emits separate , , and tensors with shape . Chapter 27 will turn those prepared feature views into one unmasked attention head by computing scores, probabilities, and a value mixture.