float32:

- fp32 is the default
Memory is determined by 1) the # of values in a tensor, 2) the data type of each value
x = torch.zeros(4, 8)
assert x.dtype == torch.float32 # default type
assert x.size() == torch.size([4, 8]) # shape of the tensor
assert x.numel() == 32 # total number of elements
assert x.element_size() == 4 # size of each element in bytes (float32 = 4 bytes)
assert get_memory_usage(x) == 4*8*4 == 128 # total memory in bytes (32 elements * 4 bytes each)- bfloat16 uses the same memory as float16 but has the same dynamic range as float32
- Resolution (part determined by fraction is worse)
- tldr: training with fp32 works, but requires lots of memory; training with fp8/fp16/bloat16 is riskier, can get instability
- See mixed precision training
Moving things to the GPU:

Tensor Operations
def tensor_matmul(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
"""
Perform matrix multiplication on two tensors.
"""
a = torch.ones(16, 32)
b = torch.ones(32, 2)
return a @ b
# jaxtyping
def jaxtyping_basics():
x = torch.ones(2,2,1,3) # batch, seq, heads, hidden
# new jaxtyping way:
x: Float[torch.Tensor, "batch seq heads hidden"] = torch.ones(2,2,1,3)
def einops_einsum():
x: Float[torch.tensor, "batch seq1 hidden"] = torch.ones(2,3,4)
y: Float[torch.tensor, "batch seq2 hidden"] = torch.ones(2,3,4)
# old way
z = x @ y.transpose(-2, -1) # batch, seq1, seq2
# new way
z = einsum(x, y, "batch seq1 hidden, batch seq2 hidden -> batch seq1 seq2")
def einops_reduce():
x: Float[torch.tensor, "batch seq hidden"] = torch.ones(2,3,4)
# old way
y = x.mean(dim=1) # batch, hidden
# new way
y = reduce(x, "batch seq hidden -> batch hidden", "mean")
tensor_operations_flops
- A FLOP is a floating-point operation (e.g.
+,*) - FLOPs = total number of operations
- FLOP/s = FLOPs per second (hardware speed, also written FLOPS)
Real-world intuition
- GPT-3 (2020): ~3.14e23 FLOPs
- GPT-4 (2023 est.): ~2e25 FLOPs
- U.S. EO (revoked 2025): report any model trained with ≥1e26 FLOPs
GPU Specs (dense matmuls)
a100_flop_per_sec = 312e12 # A100, bfloat16 peak
h100_flop_per_sec = 1979e12 / 2 # H100, bfloat16 denseExample: Linear Model
Let B = batch size, D = input dim, K = output dim
x = torch.ones(B, D)
w = torch.randn(D, K)
y = x @ w
actual_num_flops = 2 * B * D * K- Matrix multiplication:
2 * m * n * pFLOPs - Elementwise ops:
m * nFLOPs - Matrix addition:
m * nFLOPs - Matmuls dominate FLOPs in deep learning.
FLOP/s and MFU
actual_time = time_matmul(x, w)
actual_flop_per_sec = actual_num_flops / actual_time
promised_flop_per_sec = get_promised_flop_per_sec(device, x.dtype)
mfu = actual_flop_per_sec / promised_flop_per_secMFU ≥ 0.5 is considered good (model FLOPs utilization)
bfloat16 version
x = x.to(torch.bfloat16)
w = w.to(torch.bfloat16)
bf16_actual_time = time_matmul(x, w)
bf16_flop_per_sec = actual_num_flops / bf16_actual_time
bf16_promised = get_promised_flop_per_sec(device, x.dtype)
bf16_mfu = bf16_flop_per_sec / bf16_promisedgradients_basics
x = torch.tensor([1., 2, 3])
w = torch.tensor([1., 1, 1], requires_grad=True)
pred_y = x @ w
loss = 0.5 * (pred_y - 5).pow(2)
loss.backward()
# w.grad == tensor([1, 2, 3])gradients_flops
Model:
x → h1 = x @ w1 → h2 = h1 @ w2 → loss = mean(h2^2)
num_forward_flops = 2*B*D*D + 2*B*D*K
# Backward pass:
# dL/dw2
num_backward_flops = 2 * B * D * K
# dL/dh1
num_backward_flops += 2 * B * D * K
# dL/dw1
num_backward_flops += 4 * B * D * D→ Total: 6 * (# data points) * (# parameters) FLOPs
module_parameters
w = nn.Parameter(torch.randn(input_dim, output_dim) / np.sqrt(input_dim))- Xavier init (scales output ~N(0, 1))
- To avoid extreme values: use truncated normal:
nn.init.trunc_normal_(w, std=1/np.sqrt(input_dim), a=-3, b=3)custom_model
class Linear(nn.Module):
def __init__(self, input_dim, output_dim):
super().__init__()
self.weight = nn.Parameter(torch.randn(input_dim, output_dim) / np.sqrt(input_dim))
def forward(self, x):
return x @ self.weight
class Cruncher(nn.Module):
def __init__(self, dim, num_layers):
super().__init__()
self.layers = nn.ModuleList([Linear(dim, dim) for _ in range(num_layers)])
self.final = Linear(dim, 1)
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x.squeeze(-1)get_batch
def get_batch(data, batch_size, sequence_length, device):
start_indices = torch.randint(len(data) - sequence_length, (batch_size,))
x = torch.tensor([data[start:start + sequence_length] for start in start_indices])
if torch.cuda.is_available():
x = x.pin_memory()
return x.to(device, non_blocking=True)randomness
Set seeds for reproducibility:
torch.manual_seed(seed)
np.random.seed(seed)
random.seed(seed)data_loading
# Save
np.array([1,2,3]).tofile("data.npy")
# Load (lazy)
data = np.memmap("data.npy", dtype=np.int32)optimizers
class AdaGrad(torch.optim.Optimizer):
def step(self):
for group in self.param_groups:
lr = group["lr"]
for p in group["params"]:
grad = p.grad.data
g2 = self.state[p].get("g2", torch.zeros_like(grad))
g2 += grad**2
self.state[p]["g2"] = g2
p.data -= lr * grad / torch.sqrt(g2 + 1e-5)- AdaGrad: accumulates squared gradients
- RMSProp: AdaGrad + exp average
- Adam: RMSProp + momentum
memory accounting
num_parameters = D * D * num_layers + D
num_activations = B * D * num_layers
num_gradients = num_parameters
num_optimizer_states = num_parameters
total_memory = 4 * (params + acts + grads + opt_states)Assume float32 (4 bytes per value)
training loop
def train(...):
for t in range(num_train_steps):
x, y = get_batch(B)
pred = model(x)
loss = F.mse_loss(pred, y)
loss.backward()
optimizer.step()
optimizer.zero_grad(set_to_none=True)checkpointing
# Save
torch.save({
"model": model.state_dict(),
"optimizer": optimizer.state_dict(),
}, "model_checkpoint.pt")
# Load
state = torch.load("model_checkpoint.pt")mixed_precision_training
- Use lower precision (fp16, bf16, fp8) where possible
- Use float32 for weights, gradients
with torch.autocast(device_type='cuda', dtype=torch.bfloat16):
pred = model(x)→ Supported with torch.cuda.amp or NVIDIA’s Transformer Engine for FP8