Chapter 17 · Content revision 1
Store weights in four bits and measure the error
Fit row-group scales, pack signed weight codes, and compare reconstructed products, saved bytes and the same text model's likelihood on CPU.
Spend fewer saved bytes on each weight
The model saved in Chapter 15 stores each learned weight as an f64, using
bytes per value. Many nearby weight values can instead share a small set
of representable levels. Saving an integer level number and a scale can reduce
the weight payload, but reconstructing a level generally changes the weight.
Those changes can alter matrix products, token probabilities and generated
tokens. A useful comparison therefore needs both a storage count and a measure
of what the reconstructed model does.
Weight quantization makes this approximation explicit. We will store each
weight as a signed four-bit code, keep a positive scale for a small group of
weights, and multiply the code by that scale when loading the model. The
existing f64 matrix multiplication and model remain our execution path.
Start with this constructed matrix and input row:
has shape , has shape , and the product has shape . These values are an explanatory fixture, separate from the learned model we will compare later.
Let one scale cover each row
A group size places each complete row in one group. The largest absolute weight in either row is . Choosing scale makes code represent weight and code represent weight .
Divide each weight by its scale and round to the nearest integer. Our tie rule rounds halfway values away from zero: becomes , and becomes . In the second row, , and all round to zero because the scale is still . The resulting signed codes and reconstructed matrix are
The equal code and weight values here follow from scales of ; codes do not generally have the same numerical value as reconstructed weights. In particular, sharing a scale with the large weight erased the three small values in the second row.
Give smaller groups their own scales
For , split each row into its first two and last two entries. The four scales, in row order, are ideally
The first row’s last group now represents exactly as code times scale . The second row’s first group no longer shares a scale with : rounds to code , while uses code . Its last group still contains and , so still becomes zero. The codes are
The actual Rust report prints the third scale as 0.028571428571428574 and
the reconstruction of the source value 0.1 as 0.1142857142857143.
These are stored f64 observations. The ideal expression explains
the intended reconstruction; its decimal appearance does not make all the
intermediate operations exact in binary floating point.
Fit, round and reconstruct within each row group
For a nonempty matrix with rows and columns, choose a positive integer group size . Partition every row into contiguous groups of at most weights. A final shorter group keeps its own scale; groups never cross rows. For the index set of one such group, use
is source weight in the group; is the group’s largest absolute weight. The scale gives the weight change represented by one integer code step. Dividing by converts a weight to code units; rounding selects the nearest level, and clamping restricts the result to through . is the dimensionless signed code, and is its reconstructed weight in the original weight units. The subscript “away” specifies that a halfway value moves away from zero.
This is a symmetric representation with zero point : code reconstructs weight . It uses levels, from through . Four signed bits could also encode , but this course representation reserves and rejects that code. An all-zero group uses : the positive fallback keeps division defined and gives only zero codes and zero reconstructions. It does not preserve the sign of a floating-point zero.
Separate rounding error from clipping error
In ideal real arithmetic, suppose and the ratio lies within , so clamping does not change the rounded result. Nearest-integer rounding then gives
Multiplication by the positive scale converts a half-code-step distance to a half-scale weight error. The away-from-zero tie rule attains that bound at either halfway value. Fitting the scale from the absolute maximum keeps the ideal source ratios inside the code range.
A supplied fixed scale is a different situation. At scale , halfway values and become codes and . With , source weight rounds beyond the range and clips to code ; reconstruction is and absolute error is . Source weight similarly becomes . The half-scale bound does not apply to these clipped values. The scalar Rust helper supports this explicit fixed-scale contrast; it is separate from fitting an absolute-maximum scale to the group.
The implementation also rounds during f64 scale fitting, division and
reconstruction. The ideal inequality is therefore not a universal exact
f64 guarantee. Nonfinite weights, nonpositive or nonfinite scales, and
nonfinite reconstructions are refused. Even a positive finite absolute
maximum can be too small: dividing it by can underflow to zero, which is
an invalid scale.
Pack two signed codes into one byte
Keep the codes in row-major order. Encode each signed code as a four-bit
two’s-complement nibble, put the first code in the low four bits of a byte,
and put the next code in the high four bits. For example, code has
nibble 9, and code has nibble C. Their byte is C9: the low nibble
still belongs to the first weight.
The code stream [-7,-4,0,4,0,0,0,7] packs as C9 40 00 70.
For , [-7,-4,0,7,4,7,0,7] packs as C9 70 74 70. These hexadecimal
bytes are storage literals, not floating-point values. Nibble 8 would
decode as the reserved code and is rejected.
Packing follows the continuous code stream, so one byte may contain the last
code of one row and the first code of the next. Scale lookup still uses each
code’s row and column. An odd total code count leaves the final high nibble
unused and zero. For the one-row fixture [-7,-3.5,0,3.5,7] with ,
the last weight forms a shorter second group; scales are and packed
bytes are C9 40 07. The high half of 07 is padding, not another weight.
Measure the error where the weights are used
For the constructed matrix, the existing matrix product gives
After reconstruction, the same Rust multiplication prints
[-7.0,-4.0,0.0,18.0] for , and
[-6.771428571428571,-3.6,0.0,17.5] for .
The following summaries compare each reconstructed value with its source:
| Measured error | ||
|---|---|---|
| Maximum absolute weight error | ||
| Weight mean squared error | ||
| Maximum absolute product error | ||
| Product mean squared error |
Mean squared error is :
and are the source and reconstructed values being compared,
and counts those values. Here for weights and for product
outputs. MSE uses squared weight or output units; maximum absolute error
uses the corresponding original units. Error summaries are rounded to twelve
decimal places after calculation, while the report retains the normal f64
printing of scales and individual reconstructed values.
The connection to a product is explicit in ideal arithmetic. Let be the matrix of weight errors. Then
Output index selects one column; input coordinate weights the error in row of that column. The triangle inequality gives the bound by adding those absolute contributions. This local linear result explains why weight error can change a layer’s output. It does not bound the quality of an entire transformer. Smaller groups improve the MSE in this fixture, but do not prove that every value or language-model score improves for every group size.
Count scales and complete files as well as codes
For one matrix, the number of weights is , and the number of row groups
is . The original f64 weight payload uses bytes.
Packed codes use bytes, and the f64 scales add bytes.
The tiny matrix therefore uses code-and-scale bytes for , or
for , compared with original weight bytes. These subtotals
omit logical shape, other tensors, configuration and the file header.
Each independently stored matrix has its own final padding nibble. For many matrices, add their individual code-byte counts , where is the weight count of matrix ; rounding the combined weight count once could miss padding between matrices.
Now use actual learned weights. The default example reuses Chapter 15’s training job for updates, snapshots that state once, and derives both group sizes from it. There are learned matrices containing weights. Gains, model constants and text assets retain their original payloads. The observed archive counts, all in bytes, are:
| Stored quantity | Source | ||
|---|---|---|---|
| Matrix weights or codes | |||
| Group scales | |||
| Logical matrix shapes | |||
| Unchanged payload | |||
| Total tensor payload | |||
| Complete saved file |
Each packed matrix adds a logical shape of two U64 dimensions, or
bytes; accounts for the shape row. The existing SafeTensors
container stores code bytes as U8, scales as F64, and logical shapes as
U64[2], alongside the native configuration descriptor. These are signed
integer codes packed into bytes; the floating-point F4 dtype is a different
representation. In this small model, scale and file-header overhead consume
much of the code-payload saving. A count of code bytes alone would greatly
overstate the reduction in the complete saved file.
Loading reconstructs the ordinary f64 matrices before the existing model
uses them. The smaller file therefore does not establish less live inference
RAM or faster multiplication. We have measured saved storage and numerical
changes, not an integer inference kernel.
Compare the same learned state on fixed documents
The reloaded models use Chapter 16’s fixed comparison documents: aaab and
aab retain its train label; abaa and baa retain its validation label.
Those labels identify the comparison splits. The Chapter 15 parent job was
not trained on these two train strings. Quantization and scoring perform no
additional training or tokenizer fitting.
Each split supplies selected next-token targets in batches, with zero recorded graphs. The unchanged evaluator measures mean negative log-likelihood (NLL) in nats per selected target. Perplexity is , where is that target-weighted mean NLL.
| Model state | Comparison train NLL | Comparison validation NLL |
|---|---|---|
| Source | ||
| Groups of | ||
| Groups of |
The corresponding train/validation perplexities are for the source, for , and for . These metrics round to six decimals after calculation. Across the learned matrix entries, both quantized forms have maximum absolute error ; weight MSE is for and for .
Both quantized validation NLLs are higher than the source value in this observation: the selected validation targets became less likely on average. The smaller file also has the larger observed weight MSE and validation NLL. This is a tradeoff on four fixed constructed strings, not a population estimate, benchmark result or guarantee about useful language generation. The source settings, named tensors and model identity remain unchanged; each quantized representation has its own derived model identity.
Relate integer representation to inference arithmetic
A shared scale makes the largest magnitude determine the spacing available to smaller values; representation alone also leaves execution costs unresolved.
Jacob and colleagues describe a scaled affine integer representation and the additional arithmetic needed for integer-only inference. The linked author preprint dates from 2017; the work appeared at CVPR 2018. In that author preprint, the affine representation is , where is the represented real value, is an integer, is a scale and is the code corresponding to real zero.
Dettmers and colleagues discuss absolute-maximum quantization and combine vector-wise scaling with mixed-precision treatment of transformer outlier features. Their LLM.int8() paper appeared at NeurIPS 2022. LLM.int8() combines vector-wise scaling with a separate higher-precision path for transformer outlier features. This does not make our tiny matrix an outlier study of a large transformer.
Measure both numerical changes and model scores when reducing stored precision, and distinguish the stored representation from the arithmetic used for inference.
Our reference contrast is narrower: keep the original f64 matrix product,
then run it again with weights reconstructed from codes and scales.
The row groups, four-bit range, reserved code and tie policy here are course choices. This implementation reproduces neither paper, GPTQ nor AWQ, and makes no claim to their quality or speed results. The Rust contrast below isolates lossy weight storage from execution engineering.
Add the representation around the existing computation
The scalar helper makes the required order visible: validate, divide, round, then clamp. Its supplied scale also lets the fixed-scale clipping example remain separate from group fitting. If division of finite inputs overflows, clamping still saturates to the appropriate endpoint code.
rust/crates/llm-from-scratch-practical/src/tensor/quantization.rs#symmetric-i4-code /// Quantize with an already chosen scale. Halfway values round away from zero.
/// A finite input whose ratio overflows still saturates to the appropriate end.
pub fn symmetric_i4_code(value: f64, scale: f64) -> Result<i8, QuantizationError> {
if !value.is_finite() {
return Err(QuantizationError::NonFinite);
}
if !scale.is_finite() || scale <= 0.0 {
return Err(QuantizationError::Scale);
}
Ok((value / scale).round().clamp(-7.0, 7.0) as i8)
} GroupedI4Matrix::quantize fits an absolute-maximum scale inside each row
group. The single row-major index chooses both the destination byte and its
low or high nibble; it continues across row boundaries while scale fitting
starts afresh for every row.
rust/crates/llm-from-scratch-practical/src/tensor/quantization.rs#grouped-i4-quantize pub fn quantize(matrix: &Tensor, group_size: usize) -> Result<Self, QuantizationError> {
let shape: [usize; 2] = matrix
.shape()
.try_into()
.map_err(|_| QuantizationError::Shape)?;
let (count, groups) = layout(shape, group_size)?;
let mut packed = vec![0; count.div_ceil(2)];
let mut scales = Vec::with_capacity(groups);
let mut index = 0;
for row in matrix.as_slice().chunks_exact(shape[1]) {
for group in row.chunks(group_size) {
if group.iter().any(|value| !value.is_finite()) {
return Err(QuantizationError::NonFinite);
}
let maximum = group.iter().map(|value| value.abs()).fold(0.0, f64::max);
let scale = if maximum == 0.0 { 1.0 } else { maximum / 7.0 };
for &value in group {
let code = symmetric_i4_code(value, scale)?;
packed[index / 2] |= ((code as u8) & 0x0f) << (4 * (index % 2));
index += 1;
}
scales.push(scale);
}
}
Self::from_parts(shape, group_size, packed, scales)
} Reading packed data requires the same invariants as writing it. A wrong code length or scale count would associate weights with the wrong bytes or scales; an invalid scale or overflowing reconstruction would not produce a finite logical matrix. The constructor also rejects a reserved code or nonzero padding instead of silently treating either as a weight.
rust/crates/llm-from-scratch-practical/src/tensor/quantization.rs#grouped-i4-validation pub fn from_parts(
shape: [usize; 2],
group_size: usize,
packed: Vec<u8>,
scales: Vec<f64>,
) -> Result<Self, QuantizationError> {
let (count, groups) = layout(shape, group_size)?;
if packed.len() != count.div_ceil(2) {
return Err(QuantizationError::PayloadLength);
}
if scales.len() != groups {
return Err(QuantizationError::ScaleCount);
}
if scales.iter().any(|s| !s.is_finite() || *s <= 0.0) {
return Err(QuantizationError::Scale);
}
if count % 2 == 1 && packed.last().unwrap() & 0xf0 != 0 {
return Err(QuantizationError::Padding);
}
let result = Self {
shape,
group_size,
packed,
scales,
};
for index in 0..count {
if result.code(index) == -8 {
return Err(QuantizationError::ReservedCode);
}
if !result.value(index).is_finite() {
return Err(QuantizationError::NonFinite);
}
}
Ok(result)
} Dequantization multiplies each signed code by its row group’s scale and
returns the existing Tensor. The matrix-product probe then calls the same
matmul on the source and reconstructed matrices.
rust/crates/llm-from-scratch-practical/src/tensor/quantization.rs#grouped-i4-dequantize /// Reconstruct the logical matrix for the existing floating-point operations.
pub fn dequantize(&self) -> Result<Tensor, QuantizationError> {
let count = self.shape[0] * self.shape[1];
Tensor::from_vec(
self.shape.to_vec(),
(0..count).map(|i| self.value(i)).collect(),
)
.map_err(QuantizationError::Layout)
} rust/demos/practical-ch17-weight-quantization/src/lib.rs#quantized-matrix-probe pub fn matrix_probe(config: &Config, group_size: usize) -> DemoResult<Value> {
let weights = Tensor::from_vec(
integers(config, "probe.shape")?,
floats(config, "probe.weights")?,
)?;
let input_values = floats(config, "probe.input")?;
let input = Tensor::from_vec(vec![1, input_values.len()], input_values)?;
let encoded = GroupedI4Matrix::quantize(&weights, group_size)?;
let decoded = encoded.dequantize()?;
let original_output = matmul(&input.view(), &weights.view())?;
let decoded_output = matmul(&input.view(), &decoded.view())?;
Ok(json!({
"shape":weights.shape(),"group_size":group_size,
"scales":encoded.scales(),"codes":encoded.codes(),
"packed_hex":encoded.packed().iter().map(|b|format!("{b:02X}")).collect::<Vec<_>>(),
"decoded":decoded.as_slice(),"weight_error":errors(weights.as_slice(),decoded.as_slice()),
"input":input.as_slice(),"original_output":original_output.as_slice(),
"decoded_output":decoded_output.as_slice(),
"output_error":errors(original_output.as_slice(),decoded_output.as_slice()),
"f64_weight_bytes":weights.len()*8,"code_bytes":encoded.packed().len(),
"scale_bytes":encoded.scales().len()*8,
}))
} PortableModel::quantize_weights derives an archive from all and only the
rank-two learned matrices. Other learned gains, model constants and text
assets keep their original records. The existing native config-lib
settings name the versioned scheme and group size; SafeTensors owns the
container format. The method refuses an already quantized source rather than
silently quantizing its reconstruction again.
rust/crates/llm-from-scratch-practical/src/checkpoint/quantization.rs#portable-weight-quantization /// Derive a lossy inference archive; leave the source model unchanged.
pub fn quantize_weights(&self, group_size: usize) -> ArchiveResult<Self> {
self.validate()?;
if self.quantization_group_size()?.is_some() {
return Err("weights are already quantized; select an unquantized source".into());
}
if group_size == 0 {
return Err("quantization group size must be positive".into());
}
let mut result = Self {
settings: config_lib::Config::from_string(&self.configuration()?, Some("conf"))?,
tensors: self.tensors.clone(),
};
for parameter in self.parameters()? {
let matrix = parameter.tensor().value();
if matrix.shape().len() != 2 {
continue;
}
let name = parameter.name();
let quantized = GroupedI4Matrix::quantize(&matrix, group_size)?;
result.tensors.insert(
name.into(),
NamedData {
dtype: Dtype::U8,
shape: vec![quantized.packed().len()],
bytes: quantized.packed().to_vec(),
},
);
result.tensors.insert(
format!("{SCALES}{name}"),
NamedData::f64(vec![quantized.scales().len()], quantized.scales()),
);
result.tensors.insert(
format!("{SHAPES}{name}"),
NamedData::u64(&[
u64::try_from(matrix.shape()[0])?,
u64::try_from(matrix.shape()[1])?,
]),
);
}
result.settings.set("quantization.scheme", I4_SCHEME)?;
result
.settings
.set("quantization.group_size", i64::try_from(group_size)?)?;
result.validate()?;
Ok(result)
} Restoration checks the descriptor and typed records, reconstructs logical matrices, and passes them through the existing decoder or backbone constructors. The text embedding and output head still share one live table; a generic backbone archive needs no tokenizer. This is lossy inference storage. Exact training resume retains Chapter 15’s unquantized model/optimizer/random-state compatibility envelope and rejects packed model state, even inside an otherwise checksum-valid training checkpoint.
rust/crates/llm-from-scratch-practical/src/checkpoint/quantization.rs#portable-weight-dequantization pub(super) fn logical_parameter(
&self,
name: &str,
data: &NamedData,
group_size: Option<usize>,
) -> ArchiveResult<Tensor> {
if let Some(group_size) = group_size.filter(|_| name.ends_with(".weight")) {
let shape = self
.tensors
.get(&format!("{SHAPES}{name}"))
.ok_or("missing quantized logical shape")?;
if data.dtype != Dtype::U8 || data.shape != [data.bytes.len()] || shape.shape != [2] {
return Err("invalid packed matrix dtype or shape".into());
}
let dimensions = shape.unsigned_values()?;
if dimensions.len() != 2 {
return Err("invalid quantized logical shape payload".into());
}
let scales = self
.tensors
.get(&format!("{SCALES}{name}"))
.ok_or("missing quantized matrix scales")?;
let scale_values = scales.float_values()?;
if scales.shape != [scale_values.len()] {
return Err("invalid quantization scale shape".into());
}
return Ok(GroupedI4Matrix::from_parts(
[
usize::try_from(dimensions[0])?,
usize::try_from(dimensions[1])?,
],
group_size,
data.bytes.clone(),
scale_values,
)?
.dequantize()?);
}
Ok(Tensor::from_vec(data.shape.clone(), data.float_values()?)?)
} The default comparison reuses Chapter 15’s fresh_job and advance and
Chapter 16’s fixture_from_tokenizer and score_model. Each group size is
serialized and reloaded before comparison. There is one training algorithm,
one evaluator and one generator; quantization changes their input weights.
rust/demos/practical-ch17-weight-quantization/src/lib.rs#trained-weight-comparison pub fn demo() -> DemoResult<Value> {
let config = settings()?;
let mut job = fresh_job()?;
let updates = number(&config, "reference_updates")?;
advance(&mut job, updates)?;
let source = PortableModel::from_text(&job.model, &job.tokenizer)?;
let source_id = source.model_id()?;
let fixture = fixture_from_tokenizer(job.tokenizer.clone())?;
let mut probes = Vec::new();
let mut models = Vec::new();
for group_size in integers(&config, "group_sizes")? {
probes.push(matrix_probe(&config, group_size)?);
let encoded = source.quantize_weights(group_size)?;
let loaded = PortableModel::from_bytes(&encoded.to_bytes()?)?;
models.push(comparison(&source, &loaded)?);
}
Ok(
json!({"chapter":"17-weight-quantization","reference_updates":updates,
"comparison_note":"Fixed Chapter 16 comparison documents; train is their split label, not the Chapter 15 parent's training corpus.",
"source":{"model_id":source_id,"storage":storage_record(&source)?,
"comparison_documents":score_model(&job.model,&fixture,1)?},
"matrix_probes":probes,"quantized_models":models,
"source_unchanged":source.model_id()?==source_id}),
)
} The thin clap command accepts a saved model, derives packed weights and
saves the new archive. Its comparison uses the saved text model’s restored
tokenizer and the same fixed comparison documents; it does not retrain.
rust/demos/practical-ch17-weight-quantization/src/lib.rs#quantize-saved-model pub fn quantize_saved(model: &Path, output: &Path, group_size: usize) -> DemoResult<Value> {
let source = PortableModel::load(model)?;
let encoded = source.quantize_weights(group_size)?;
encoded.save_atomic(output)?;
Ok(
json!({"source_model_id":source.model_id()?,"source_storage":storage_record(&source)?,
"saved":true,"derived":comparison(&source,&encoded)?}),
)
} Follow two representations of the same matrix
One matrix, two code-and-scale representations
Both paths start from the same constructed weight matrix. Groups of two or four consecutive weights within each row fit their own scales and produce packed signed codes. Each path reconstructs f64 weights, then uses the same matrix multiplication and input row. Product errors compare each reconstructed result with the original matrix's result; more scales reduce the observed mean squared error in this fixture.
Start with one source matrix
Both group sizes encode these same eight weights; neither path trains a model.
Store codes together with their scales
Each path keeps four code bytes. Smaller groups add scales so a large weight affects fewer neighboring weights.
Reconstruct before the existing product
Multiply each signed code by its own row-group scale, then run the existing f64 matrix product with this input row.
Compare the two paths with the original product
Weights per row group:
- Packed bytes, low nibble first
C9 70 74 70- Scale payload (bytes)
- Maximum absolute product error
- Product mean squared error
Weights per row group:
- Packed bytes, low nibble first
C9 40 00 70- Scale payload (bytes)
- Maximum absolute product error
- Product mean squared error
Constructed matrix only: code bytes exclude scales, shape and file headers. These product errors establish neither a transformer-wide error bound nor an inference speedup. Both paths reconstruct f64 weights before multiplication.
The figure compares representations against one unchanged matrix and product. The learned-model NLL comparison above is a separate observation of how reconstructed weights affect selected token probabilities.
Reproduce the report and load the saved packed model
From the repository root, with Docker and the existing course Docker setup available, run the default CPU example. The matrix, native configuration and small training job are included; no external corpus or downloaded model is needed.
COURSE_CORPUS=false ./course run cargo run --quiet --offline --locked -p practical-ch17-weight-quantizationThe JSON contains matrix_probes, source, quantized_models,
reference_updates: 2 and source_unchanged: true. It reports both group
sizes, their packed bytes, errors, storage and fixed-document scores. The
default serialized-model comparisons reload bytes in the same program.
To use actual saved files across separate programs, keep the same repository-root working directory and Docker setup and run this entire block as one command. One Docker session retains the files while each Rust program exits before the next program starts.
COURSE_CORPUS=false ./course run sh -c '
set -eu
mkdir -p tmp/ch17-quantization-flow
cargo run --quiet --offline --locked -p practical-ch15-save-load-resume-model -- train --until 2 --model tmp/ch17-quantization-flow/parent.safetensors --checkpoint tmp/ch17-quantization-flow/parent.train
cargo run --quiet --offline --locked -p practical-ch17-weight-quantization -- quantize --model tmp/ch17-quantization-flow/parent.safetensors --output tmp/ch17-quantization-flow/group4.safetensors --group-size 4
cargo run --quiet --offline --locked -p practical-ch15-save-load-resume-model -- infer --model tmp/ch17-quantization-flow/group4.safetensors --prompt ab --tokens 3
cargo run --quiet --offline --locked -p practical-ch16-language-model-evaluation -- evaluate --model tmp/ch17-quantization-flow/group4.safetensors --batch-rows 1
'The first program saves the source model after updates and its separate
training checkpoint. The second saves the inference archive; the
unchanged Chapter 15 inference and Chapter 16 evaluation commands then load
that archive without training. The quantizer’s model_id, inference’s
model_id and evaluator’s model_sha256 all identify the derived model as
ba40116d31d1d7c25b63fc8b6dca03d0e4a510d3efdc3a29a50ec058948fa12f.
Inference reports device: "cpu", precision: "f64" and tied_alias: true.
Its generated IDs are [108,27,159]; the corresponding bytes [106,25,157]
do not form useful prose.
Evaluation prints validation NLL and perplexity ,
with selected targets, batches and zero recorded graphs. Equal printed
metrics establish agreement at the stated six-decimal display precision.
This bounded CPU run does not establish GPU performance or useful language
generation. Separate ./course run invocations would use disposable
containers and lose the saved files between programs.
With the same repository-root working directory and Docker setup, run the demo’s library tests:
COURSE_CORPUS=false ./course run cargo test --offline --locked -p practical-ch17-weight-quantizationThe library result is two passed tests: the constructed packing/product comparison and the actual trained-model archive/evaluation comparison.
Optional inspection and explanation practice
- Inspect the default command’s two matrix probes. Explain why the source weight reconstructs as for and as for . Contrast that fitted-scale case with clipping source weight at fixed scale .
- Decode byte
C9in low-nibble-first order. For source row[-7,-3.5,0,3.5,7]at , explain bytesC9 40 07and distinguish the final shorter group from the padding nibble. - Use the default report’s reconstructed products and error summaries for groups of and weights to explain why product MSE differs even though their maximum absolute weight errors are equal.
- Compare the source model and groups of and weights in the default report: complete files, code payloads, scale payloads and validation scores. Explain the storage/error tradeoff and the limit of the fixed-document quality comparison.
- Run the complete single-session save/quantize/infer/evaluate block and the two-test command above. Check the derived model identity, CPU precision, tied table and validation score reported by the separate consumers.
Checked answers: scales, byte packing, product error, saved storage and independent model loading
- For , the first row’s scale is and rounds away from zero to code , reconstructing . For , its group contains and , so the scale is and code reconstructs . At fixed scale , source weight saturates at code and has absolute error . That clipped example is outside the ideal no-clipping half-scale bound.
- The low nibble
9represents code ; the high nibbleCrepresents . The five source weights form groups of four and one, with scales and . The first four codes occupyC9 40; the last code occupies the low nibble of07. Its high zero nibble is unused padding. A shorter row group still represents a real weight and has a scale; padding represents no weight. - Both weight maxima are , but the errors occur in different entries. The existing product gives source , reconstructed output , and printed output
[-6.771428571428571,-3.6,0.0,17.5]. Product MSE falls from to . The identity explains the different weighted column sums; equal maximum weight error does not imply equal product error. - Both packed models use code bytes, but scales cost bytes for and for . Both add shape bytes and retain other payload bytes. Complete files are and bytes versus for the source. Validation NLL rises from to and . Thus both files are smaller and both selected-target validation scores worsen in this observation. The four comparison strings, and their train/validation labels, do not establish general language quality or describe the parent job’s training corpus.
- The quantizer and both consumers report the same derived identity
ba40116d31d1d7c25b63fc8b6dca03d0e4a510d3efdc3a29a50ec058948fa12f. Inference restores CPUf64weights and one tied embedding/head table; it reports IDs[108,27,159], not useful prose. Evaluation retains targets, batches, zero graphs and validation NLL/PPL . Neither consumer trains. The demo command reports two passed library tests. Loading lossy weights for inference does not make them an exact training-resume state.
Keep the model path while changing its saved representation
The learned matrices now have an explicit code-and-scale storage option. The same text encoder, source-independent backbone and tied head load their reconstructed weights and use the existing CPU evaluator and generator. The comparison keeps smaller saved storage distinct from numerical error, model likelihood, live memory and execution speed. Exact training continuation still needs the original unquantized training state.
Planned Chapter 18, Grouped-query attention and context masks, will extend attention sharing and the context permitted in full and cached computation. That changes a different part of the model than the weight representation studied here.