Field notes / Computer architecture

Quantizationthrough the lens of CS61C

Where does floating point come into the picture?

From my handwritten notebook

Adapted from my handwritten notes, with edits for clarity and technical accuracy. The drawings are my original handwriting; the quantization-error number line is a digital addition.

CHAPTER 01Why floating point matters beyond an exam

As a TA for CS61C, I wanted to write this for students wondering something I also wondered when I took the class: where does floating point actually show up outside an exam?

If you’ve taken a 61C exam, you have probably converted a decimal number into IEEE 754 by hand:

  1. Determine the sign.
  2. Normalize the binary value.
  3. Calculate the biased exponent.
  4. Fill in the fraction bits.

With enough practice, you can get good at those steps and ace the exam question. But that still leaves the question I cared about as a student: what do we actually use this for?

Quantization is one example I want to connect back to 61C. The same choices about bits, precision, and range affect how much memory a model needs and how it runs on real hardware.

BUT, the format used to represent a number changes what a computer can do with it.
CS61C review

So far, we have seen fixed-point binary representations and IEEE 754 floating-point representations. As a quick review, a simple 6-bit representation in binary point for positive numbers is:

Six bits arranged as xx.yyyy, with place values 2 to the 1, 0, minus 1, minus 2, minus 3, and minus 4.
A 6-bit binary-point representation.
Read the diagram as text

The six bits xxyyyy are interpreted as xx.yyyy. From left to right, their place values are 21, 20, 2−1, 2−2, 2−3, and 2−4.

Floating point lets us store an approximation of a real number using binary scientific notation. Here I’m focusing on FP32, the 32-bit format we work with in CS61C.

A note on float and double

In C and C++, float is typically 32-bit and double is typically 64-bit. Java defines them as 32-bit and 64-bit, respectively. Python’s built-in float ordinarily uses double precision.

In CS61C, we teach the 32-bit IEEE 754 single-precision format:

Handwritten 32-bit single-precision layout: sign at bit 31, exponent at bits 30 through 23, and fraction field at bits 22 through 0 (labeled significand in the original drawing).
The single-precision layout from my notes. The field labeled “significand” in this drawing is the stored fraction field.
Read the diagram as text

An FP32 value occupies a 32-bit word:

  • Bit 31: one sign bit.
  • Bits 30–23: eight exponent bits.
  • Bits 22–0: 23 fraction bits.

The handwritten label “Significand” refers to the stored fraction field. For normalized values, the full significand also includes the implicit leading 1.

We’ll use this layout in the next chapter to understand the tradeoff between range and precision.

Four things to consider

Given a fixed number of bits, we must decide how to divide them among the sign, exponent, and fraction. That decision affects four properties: range, precision, storage, and hardware execution.

01 / Range
The span of magnitudes the format can represent.
02 / Precision
How closely we can distinguish nearby values.
03 / Storage
How many bytes every value occupies.
04 / Execution
Hardware instructions that can process the value efficiently.

These choices have become very visible in machine learning. Modern models can contain millions or billions of parameters. Now if we follow the standard taught in 61C, then each numerical parameter occupies 4 bytes (32 bits).

What if each parameter used fewer bits?

With one billion parameters, changing the representation from 32 bits to 16 or 8 bits can save gigabytes of storage. We’ll calculate this more carefully in Chapter 4.

The same value can be represented differently depending on the format. Here is a preview using 0.93; Chapter 3 explains how each conversion works.

0.93 in three formats: FP32 stores approximately 0.9300000072 in 4 bytes, FP16 approximately 0.9301757813 in 2 bytes, and INT8 stores 118 with a shared scale of 1/127, reconstructing approximately 0.92913.
0.93 in three formats. Tap to view it larger.
Read the diagram as text

The mathematical value 0.93 is approximated in three ways:

  • FP32: approximately 0.9300000072, using 4 bytes.
  • FP16: approximately 0.9301757813, using 2 bytes.
  • INT8: integer code 118, using 1 byte plus shared scale metadata. With s = 1/127, q = round(0.93/s) = 118 and x̂ = 118/127 ≈ 0.92913. Code 118 is 01110110 in binary.

FP32 and FP16 encode scale through each value’s exponent. INT8 uses separate quantization metadata to reconstruct an approximate real value.

CHAPTER 02Range vs precision

Here’s a quick review of floating point formats from CS61C: floating point formats are able to represent extremely large values and small differences between these nearby values by dividing their available bits into three fields:

For normalized finite values, we use:

(−1)s × (1.f)2 × 2e − bias

Here, s is the sign bit, e is the stored exponent, and f is the stored fraction. The significand (1.f)2 includes the implicit leading 1. The bias is 127 for FP32; zero, subnormal values, infinities, and NaNs need separate treatment.

For ordinary normalized values:

  • s determines sign.
  • e determines scale of the number.
  • f determines precision within that scale.

A useful interpretation I tell students is that the exponent chooses the general “neighborhood” on this number line, while the fraction f chooses the specific position within that neighborhood.

There are tradeoffs with deciding between more bits for exponent or more bits for the fraction.

1. Exponent bits provide range

More exponent bits provide a wider range. This is why BF16 has approximately the same numerical range as FP32: both utilize 8 bits for the exponent.

Conceptual number lines: fewer exponent bits cover a smaller range around zero; more exponent bits cover a wider range.
Read the diagram as text

The first number line illustrates a smaller range, with endpoints labeled −2N and 2N. The second illustrates a wider range, labeled −2M and 2M, where M > N. These are conceptual ranges: more exponent bits allow a wider span of magnitudes.

2. Fraction bits provide precision

Two number lines from 0.9 to 1.0. More fraction bits provide many nearby values; fewer fraction bits leave wider gaps. Values between available points must be rounded.
Read the diagram as text

Both number lines span 0.9 to 1.0. More fraction bits provide many closely spaced representable values. Fewer fraction bits leave wider gaps. If the exact value falls between two available points, it must be rounded.

The FP16 format stores 10 fraction bits, while BF16 stores seven. So BF16 instead spends more of its limited bit budget on exponent range.

  • FP16: more fraction bits, narrower range.
  • BF16: fewer fraction bits, wider range.

3. Total bits determine storage

The total width (bits) determines how much memory each stored value occupies:

Storage per value, using the formats discussed in these notes
FormatBitsBytes per value
FP32324
FP16 / BF16162
FP8 / INT881
INT44½, when packed

8-bit floating point formats (FP8) further push the same tradeoff as FP16 and BF16. For example, FP8 E4M3 (M denotes mantissa bits, still corresponding to the stored fraction field) has more bits for precision than E5M2. I’ll leave FP8 for now, so I can further explain the basics of quantization from here!

CHAPTER 03What quantization means

Now for the fun juicy stuff: quantization!

Quantization maps values from a larger set of possible numerical values onto a smaller set of representable values.

This can happen in many ways:

  • An FP32 value can be rounded to a value representable in FP16, BF16, or FP8.
  • A floating point can also be represented by an integer and a shared scale.
  • Weights can be restricted to an EVEN smaller set, such as binary values {−1, +1} or ternary values {−1, 0, +1}.
FP32 → FP16: reducing floating-point precision

Consider 0.93. Its FP32 representation is

0 | 01111110 | 11011100001010001111011
s | exponent | fraction

This stored FP32 exponent is 126. Subtract bias of 127; the unbiased exponent is now 126 − 127 = −1.

FP16 must now represent the same exponent using its bias of 15: −1 + 15 = 14. 14 in five bits is 01110.

FP16 stores only 10 fraction bits instead of FP32’s 23. The fraction is shortened and rounded to 1101110001.

The resulting FP16 representation is:

0 | 01110    | 1101110001
s | exponent | fraction

This bit pattern now represents 0.93017578125. This conversion maps the FP32 approximation of 0.93 to the nearest available FP16 value. Next, let’s see how integer quantization works differently.

Floating point → INT8: integer codes and a shared scale

INT8 representation is different. Before applying a scale, adjacent representable INT8 values differ by 1 (−2, −1, 0, 1, 2, …). Signed INT8 stores integers from −128 to 127.

However, neural network weights often look like 0.037, −0.62, or of course 0.93 lol. So if hardware only supports INT8, how can an integer format store them?

We can’t just convert small weights to integers, that would just destroy most of the information: 0.93 → 1, 0.26 → 0, −0.26 → 0. Many different values will just collapse to a small integer.

The key idea here is an integer does not have to mean “one whole unit.”

If you have a scale s, then one integer step can instead represent a smaller real-valued interval.

Here, I assume symmetric quantization over approximately [−1, 1], using integer codes from −127 to 127. To map these endpoints, we’ll set the scale s to

s = 1 / 127 ≈ 0.00787

Now we can interpret integer 1 as approximately 0.00787, 10 as 0.0787, 127 as 1.0.

To store 0.93, the q representation for INT8 would be

q = round(0.93 / (1 / 127)) = 118

And to reconstruct we do

x̂ = 118 × (1 / 127) ≈ 0.929
A real-value ruler from minus 1 to 1 maps to an integer ruler from minus 127 to 127. The real value 0.93 maps to integer 118 using a shared scale.
Read the diagram as text

The real-value ruler runs from −1 to 1, while the integer-code ruler runs from −127 to 127. With a shared scale of 1/127, the value 0.93 maps to code 118.

Individual weights no longer need their own exponent and fraction fields. The drawing highlights approximation error; shared scale metadata is also required.

With floating point, each value gets its own exponent. In this INT8 example, a group of values shares a scale instead. We save space, but we still have to store that scale, and we don’t get exactly 0.93 back.

Reconstruction and quantization error

Given an original weight w and scale s, we can calculate the integer to store as

q = round(w / s)

For this example, zero stays zero, and I’m assuming the rounded code fits in our integer range. If it falls outside that range, we need to clip it. You may also see quantization formulas with an extra offset called a zero-point; I’m leaving that at 0 here.

Here, w is the original value, s is the scale, and q is the integer we store. To get an approximation of w back:

ŵ = sq

Reconstruction opens up one issue, which is quantization error: the difference between an original value and its reconstructed approximation, where

ε = w − ŵ
|ε| = |w − ŵ|

For a simple example, choose s = 0.1 with zero-point 0, and round to the nearest integer code:

Reconstruction with a scale of 0.1
Original wCode qReconstructed ŵ|ε|
−0.26−3−0.300.04
0.0400.000.04
0.7370.700.03
Three number-line examples with levels spaced 0.1 apart. Minus 0.26 rounds to minus 0.30; 0.04 rounds to 0; and 0.73 rounds to 0.70. Arrows show the displacement caused by rounding.
Each value snaps to its nearest representable level. The distance moved is the absolute quantization error.

Quantization loses information because multiple original values can map to the same stored code. The scale determines the spacing between representable values and the range they cover: smaller steps can reduce rounding error, but cover a narrower range for a fixed integer width.

How widely should a scale be shared?
A linear layer has weight matrix W in R to the power d-out by d-in. Each row corresponds to one output feature, and each column to one input feature. The example shows a four-by-four matrix.
Read the diagram as text

Let’s consider a linear layer with weight matrix W ∈ ℝdout × din. Each row corresponds to one output feature, and each column to one input feature.

        Input features →
w₁₁  w₁₂  w₁₃  w₁₄
w₂₁  w₂₂  w₂₃  w₂₄
w₃₁  w₃₂  w₃₃  w₃₄
w₄₁  w₄₂  w₄₃  w₄₄

Output features run down the rows.

A scale can be applied at different levels of scale granularity:

Per-tensor scaling

All tensor weights share scale 1, which must accommodate the complete value range.
Read the diagram as text

One scale is shared across the entire weight tensor. The scale must accommodate the tensor’s complete value range.

Per-channel scaling

First row of weights uses scale 1; second row uses scale 2.
Read the diagram as text

Each channel receives a separate scale. In this row-wise example, [w₁₁, w₁₂, w₁₃] uses scale 1, while [w₂₁, w₂₂, w₂₃] uses scale 2.

We can share a scale down a column (an input channel) or across a row (an output channel). Many weight-quantization kernels use one scale per output channel. Which layout you can actually use depends on the runtime and hardware.

Group-wise scaling

One row is divided into two smaller one-dimensional groups, each with a scale.
Read the diagram as text

Each channel is divided into smaller one-dimensional groups, each with its own scale. In the illustrated row, [w₁₁, w₁₂] is one group and [w₁₃, w₁₄] is another. The colored groups can have different scales, although both handwritten labels say “scale 2.”

Tile-wise scaling

Tile-wise scaling partitions a matrix in two dimensions, so one scale spans multiple rows and columns. This four-by-four matrix is split into four colored two-by-two tiles, each with its own scale.
Read the diagram as text

Tile-wise scaling partitions the matrix in two dimensions. One scale spans multiple rows and columns. The illustrated four-by-four matrix has four two-by-two tiles:

  • Scale 1: rows 1–2, columns 1–2.
  • Scale 2: rows 1–2, columns 3–4.
  • Scale 3: rows 3–4, columns 1–2.
  • Scale 4: rows 3–4, columns 3–4.

Smaller groups let us choose a scale that better fits the values in each group. That can reduce error, but now we have more scales to store and apply.

  • Larger groups → less metadata, potentially more error.
  • Smaller groups → more metadata, potentially less error.
What parts of a model can be quantized?

During inference, three major categories are commonly quantized:

  1. Weights: learned parameters stored with the model.
  2. Activations: temporary intermediate tensors produced while processing the current input.
  3. KV cache: attention keys and values retained from earlier tokens so they do not need to be recomputed.

KV-cache memory grows with sequence length, batch size, and number of layers; it also depends on the model’s attention dimensions and the cache datatype.

Weight and activation precision

Quantized models are often described using WxAy notation, where W gives the weight bit width and A gives the activation bit width. Using different formats for different model components is a form of mixed-precision inference.

For example, in W8A16, weights can use INT8 to reduce storage and memory traffic, while activations may remain FP16 or BF16 because their input-dependent ranges can make low-bit integer quantization more difficult. Activations can also be quantized, as in the W8A8 example below.

Handwritten notation and common interpretation table: W8A16 means INT8 weights with FP16 or BF16 activations; W8A8 can mean INT8 or FP8 weights and activations; W4A16 means INT4 weights with FP16 or BF16 activations.
My handwritten guide to weight and activation notation. Tap to view it larger.
Read the table as text
Weight and activation notation
NotationCommon interpretation
W8A16INT8 weights with FP16 or BF16 activations
W8A8INT8 weights and activations, or FP8 weights and activations
W4A16INT4 weights with FP16 / BF16 activations

The notation alone doesn’t specify the specific format; it only provides the bit width (8-bit, 16-bit, etc.).

So a system could use:

  • Weights: INT8
  • Activations: FP16
  • KV cache: FP8
  • Selected sensitive operations or accumulations: higher precision

CHAPTER 04The costs and benefits of fewer bits

During inference, a model’s learned parameters are normally loaded into system / accelerator memory and repeatedly read during execution.

For one billion parameters stored in FP32:

109 parameters × 4 bytes per parameter ≈ 4 GB

I’m counting weights and biases here, using decimal GB. These estimates cover parameter storage only; scales, activations, KV cache, temporary buffers, and runtime overhead add to the total.

Storage size for a model with one billion parameters
FormatParameter storage
FP32≈ 4 GB
FP16 / BF16≈ 2 GB
INT8≈ 1 GB
INT4≈ 0.5 GB

KV cache is created separately during inference, and stores keys and values from previously processed tokens. As the cache grows with sequence length, batch size, and number of layers, quantizing the KV cache reduces this request-dependent memory, but this is separate from the one billion parameter calculation above.

A simple breakdown is:

Total active inference memory = parameters + KV cache + temporary activations + runtime workspaces

Connecting quantization to memory hierarchy

CS61C introduces us to the memory hierarchy:

Storage to DRAM to cache/SRAM to registers to compute units. The storage end is slower and larger; the register end is faster and smaller.
Moving data toward the compute units.
Read the diagram as text

Storage → DRAM → cache / SRAM → registers → compute units.

The storage end is slower and larger; the register end is faster and smaller. Data must move through the hierarchy to reach the compute units.

Before a processor can operate on model parameters, the required data must reach its compute units, but moving all this data can take up a lot of time and energy.

Let’s say we have a 64-byte cache line. It can hold either 16 FP32 parameters or 64 INT8 parameters. The same cache line holds 4× as many INT8 parameters. So smaller representations can provide:

  1. Less parameter storage.
  2. Less data transferred from DRAM.
  3. More values fitting in each cache line.
  4. Less energy spent moving data.
However, a smaller representation DOES NOT automatically lead to faster execution.
The cost of using fewer bits

The runtime must be able to use the representation efficiently. Some costs include:

  • Unpacking and dequantization: extracting packed low-bit values and reconstructing their approximate values when needed.
  • Scale metadata and application: storing, loading, and applying shared scales.
  • Datatype conversions: converting between the storage format and the format used for computation.
  • Unsupported low-precision instructions: hardware may lack native operations for the chosen format.
  • Inefficient kernels: an implementation may fail to use the hardware efficiently.
  • Padding and alignment: extra storage or work may be needed to satisfy hardware-layout requirements.
  • Higher-precision fallbacks: some operations may run in FP16, BF16, or FP32 instead.
FP32 moves fewer parameters from DRAM directly to compute. The INT8 example moves more parameters but adds an unpack-and-scale step before compute.
An example of where the extra work can appear. The exact steps depend on the kernel; native INT8 computation may take a different path.
Read the diagram as text

In the FP32 illustration, a transfer carries two 32-bit parameters from DRAM toward compute. In the INT8 illustration, the same 64 bits carry eight parameters, followed by an unpack-and-scale step before compute. This extra step illustrates possible overhead, rather than a required path for every INT8 kernel.

This is the part I find interesting: making the model smaller doesn’t finish the job. We still have to check whether moving fewer bytes and using lower-precision arithmetic saves more time than unpacking, scaling, and conversion take.

CHAPTER 05Closing

As a CS61C TA, I want students to see what those floating-point exercises connect to. Quantization is one real-world use of the ideas we teach in class, and it’s the kind of connection I wanted when I was a student.

My interest in quantization grew from trying to understand why a model’s theoretical bit width does not always translate into proportional real-world speedups. A four-bit model may be smaller, but its performance still depends on packing, scales, kernels, memory movement, and hardware support. That’s the question I want to keep exploring.

The original handwritten notes

I wrote and drew these notes by hand on my iPad. Here are compressed copies of the original handwritten PDFs: