← Back to DevBytes

BFloat16 vs Float16: Choosing the Right Precision for Training

BFloat16 vs Float16: Choosing the Right Precision for Training

Mixed precision training has become a cornerstone of modern deep learning. By reducing the precision of activations, gradients, and weights during training, practitioners can dramatically cut memory usage and accelerate computation on modern GPUs. But when you enable mixed precision, you're immediately faced with a choice: should you use Float16 or BFloat16? The decision is not trivial, and the wrong choice can lead to unstable training, silent NaNs, or wasted hardware. This tutorial explains what each format is, why the distinction matters, how to use them in practice, and how to choose the right one for your workload.

What Is Half-Precision Floating Point?

Both Float16 and BFloat16 use 16 bits to represent a floating-point number, half the 32 bits used by the standard Float32 format. However, they allocate those bits very differently. A floating-point number is composed of three parts: a sign bit, an exponent (which controls the range of representable numbers), and a mantissa or significand (which controls the precision of each number).

Float16, also known as IEEE half precision, uses 1 sign bit, 5 exponent bits, and 10 mantissa bits. This gives it roughly 3-4 decimal digits of precision and a dynamic range of about 5.96 × 10⁻⁸ to 65,504. BFloat16, short for Brain Floating Point and originally developed at Google for TensorFlow, uses 1 sign bit, 8 exponent bits, and only 7 mantissa bits. This gives it the same dynamic range as Float32 — roughly 1.18 × 10⁻³⁸ to 3.4 × 10³⁸ — but with less precision per number, around 2-3 decimal digits.

The key insight is this: BFloat16 trades precision for range, while Float16 offers more precision but a much smaller range. This single design difference drives almost every practical consideration when choosing between them.

Why Precision Choice Matters

During training, neural networks produce values across a huge dynamic range. Gradients can be extremely small, especially in early layers of deep networks or when using techniques like attention. Activations can grow large, particularly in recurrent or transformer architectures. Loss scaling and gradient values can swing by orders of magnitude across a single training step.

Float16's limited range creates two well-known problems. First, values above 65,504 overflow to infinity, which poisons downstream computations. Second, very small gradients underflow to zero, effectively disappearing before they can update weights. To work around this, practitioners use loss scaling: multiplying the loss by a large factor before backpropagation so small gradients stay representable, then dividing gradients back before the weight update. This works, but it adds complexity and can fail silently if the scale factor is wrong.

BFloat16 sidesteps both problems. Its 8 exponent bits match Float32, so it rarely overflows or underflows during normal training. You typically don't need loss scaling at all. The trade-off is that each individual number is less precise, which can matter for certain operations like reductions, accumulations, and small-magnitude computations.

Hardware Support Considerations

Not every GPU supports both formats equally. Float16 mixed precision has been supported since the Volta architecture (V100) through Tensor Cores. BFloat16 hardware support arrived later: it's available on NVIDIA Ampere (A100, RTX 30 series) and newer architectures, on Google TPUs since TPU v2, and on modern AMD and Intel accelerators. If you're training on older hardware like a V100 or GTX series card, BFloat16 may not be available or may fall back to slow emulation. Always check your hardware's compute capability before committing to a precision strategy.

How to Use Mixed Precision in Practice

Using Float16 in PyTorch

PyTorch provides an automatic mixed precision (AMP) context manager that handles Float16 conversion and loss scaling automatically. Here's a minimal training loop using Float16:

import torch
import torch.nn as nn
from torch.amp import autocast, GradScaler

model = nn.Linear(1024, 1024).cuda()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.MSELoss()

# GradScaler handles dynamic loss scaling for Float16
scaler = GradScaler('cuda')

data = torch.randn(64, 1024).cuda()
target = torch.randn(64, 1024).cuda()

for step in range(100):
    optimizer.zero_grad()

    # Forward pass in Float16
    with autocast('cuda', dtype=torch.float16):
        output = model(data)
        loss = criterion(output, target)

    # Backward pass with scaled loss
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

    if step % 10 == 0:
        print(f"Step {step}, loss: {loss.item():.6f}")

The GradScaler dynamically adjusts the loss scale. If it detects overflow (infinities or NaNs in gradients), it skips the optimizer step and reduces the scale. This is essential for Float16 stability.

Using BFloat16 in PyTorch

Switching to BFloat16 is straightforward and notably simpler because loss scaling is not required:

import torch
import torch.nn as nn
from torch.amp import autocast

model = nn.Linear(1024, 1024).cuda()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.MSELoss()

data = torch.randn(64, 1024).cuda()
target = torch.randn(64, 1024).cuda()

for step in range(100):
    optimizer.zero_grad()

    # Forward pass in BFloat16 — no GradScaler needed
    with autocast('cuda', dtype=torch.bfloat16):
        output = model(data)
        loss = criterion(output, target)

    loss.backward()
    optimizer.step()

    if step % 10 == 0:
        print(f"Step {step}, loss: {loss.item():.6f}")

Notice the absence of GradScaler. This is one of the main practical advantages of BFloat16: simpler, more predictable training with fewer moving parts.

Using Mixed Precision in TensorFlow / Keras

TensorFlow has first-class support for both formats through its mixed precision policy. BFloat16 is the default on TPUs and is well-supported on modern GPUs:

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from tensorflow.keras.mixed_precision import experimental as mixed_precision

# Choose your dtype
policy = mixed_precision.Policy('mixed_bfloat16')
# Or for Float16: mixed_precision.Policy('mixed_float16')
mixed_precision.set_policy(policy)

model = keras.Sequential([
    layers.Dense(1024, activation='relu', input_shape=(1024,)),
    layers.Dense(1024, activation='relu'),
    layers.Dense(10, activation='softmax', dtype='float32')  # output in float32
])

model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
    metrics=['accuracy']
)

print(f"Compute dtype: {policy.compute_dtype}")
print(f"Variable dtype: {policy.variable_dtype}")

With mixed_float16 in TensorFlow, you need to wrap your optimizer with LossScaleOptimizer to handle dynamic loss scaling. With mixed_bfloat16, no loss scaling wrapper is needed — another reason BFloat16 is often preferred when hardware allows.

Comparing the Two Formats Directly

This snippet demonstrates the practical difference in representable range between the two formats:

import torch
import numpy as np

# Large value that overflows Float16 but is fine for BFloat16
large_val = torch.tensor([1e5], dtype=torch.float32)

f16 = large_val.to(torch.float16)
bf16 = large_val.to(torch.bfloat16)

print(f"Original:    {large_val.item()}")
print(f"Float16:     {f16.item()}")   # likely 'inf'
print(f"BFloat16:    {bf16.item()}")  # preserved

# Small gradient-like value
small_val = torch.tensor([1e-7], dtype=torch.float32)

f16_small = small_val.to(torch.float16)
bf16_small = small_val.to(torch.bfloat16)

print(f"\nSmall original: {small_val.item()}")
print(f"Float16:        {f16_small.item()}")   # may underflow to 0
print(f"BFloat16:       {bf16_small.item()}")  # preserved

# Precision comparison
precise_val = torch.tensor([1.23456789], dtype=torch.float32)
print(f"\nPrecise original: {precise_val.item()}")
print(f"Float16:          {precise_val.to(torch.float16).item()}")   # more digits kept
print(f"BFloat16:         {precise_val.to(torch.bfloat16).item()}")  # fewer digits kept

Running this, you'll see Float16 preserve more decimal digits but fail on extreme values, while BFloat16 handles the extremes but rounds more aggressively. This encapsulates the entire trade-off.

Best Practices

When to Choose BFloat16

When to Choose Float16

General Recommendations

Common Pitfalls

One frequent mistake is forgetting to use loss scaling with Float16. Without it, small gradients silently become zero and the model stops learning effectively, often表现为 as a loss plateau that looks like a hyperparameter problem. Another pitfall is applying BFloat16 to operations that genuinely need precision, such as numerical solvers or accumulation-heavy reductions — in these cases, the lower mantissa width can introduce meaningful error. Finally, mixing precisions inconsistently across model components (for example, BFloat16 in the forward pass but Float16 in a custom loss) can produce confusing bugs. Pick one reduced precision per training run and let the framework's AMP logic handle the boundaries.

Conclusion

Choosing between BFloat16 and Float16 comes down to a trade-off between dynamic range and per-value precision. BFloat16, with its Float32-matching exponent, offers robust, no-loss-scaling training that is ideal for large modern models and is the default choice on contemporary hardware. Float16, with its larger mantissa, offers more precision per number but requires careful loss scaling and is more prone to overflow and underflow. In practice, if your hardware supports BFloat16 and you're training deep or transformer-style models, it is usually the safer and simpler choice. If you're on older hardware or have specific precision requirements, Float16 with proper loss scaling remains a proven, effective option. Whichever you choose, keep master weights in Float32, monitor for numerical issues, and validate on a small scale before committing to a full training run.

— Ad —

Google AdSense will appear here after approval

← Back to all articles