FP32, FP16, BF16 и FP8 — разбираемся в основных типах чисел с плавающей запятой

723e456e38b21c9abd66a7319dde2356.jpg

Привет, Хабр! Сегодня давайте поговорим о том, как современные вычисления на GPU стали более гибкими и эффективными благодаря различным форматам чисел с плавающей запятой (FP64, FP32, FP16, BFLOAT16 и FP8). Эти форматы не просто числа — за каждым из них стоит конкретная область применения. В разных ситуациях мы сталкиваемся с задачами, где важны либо скорость, либо точность, и правильно выбранный тип floating point помогает оптимизировать ресурсы. Давайте разберём всё это на примерах и поймём, в каких задачах каждый из этих форматов будет наиболее полезен.

Введение

В предыдущей статье мы рассказали о истории появления чисел с плавающей запятой, но лишь частично рассказали о их применении на практике. Сегодня же мы хотим сконцентрировать ваше внимание на каждом из популярных форматов вычислений и рассказать, почему же существует такое количество разнообразных форматов вычислений. Для корректного формирования выборки типов вычислений, мы будем обращаться к спецификации топового ускорителя для обучения ИИ — Nvidia Tesla H100.

dad54b4264fc30b5894c39e0024abb5f.png

FP64: максимальная точность для научных расчётов

FP64, или 64-битные числа с плавающей запятой, применяются там, где малейшая ошибка может привести к неверным результатам. В таких областях, как космическая индустрия, моделирование траекторий спутников или расчёты по гидродинамике, отклонение даже на малую величину может иметь самые серьёзные последствия. 

Задача: Рассмотрим задачу численного интегрирования для расчёта траектории космического объекта, движущегося под воздействием гравитации Земли. Для таких задач крайне важно использовать максимальную точность, чтобы не допустить отклонений в расчётах, которые могут привести к неправильному предсказанию орбиты.

Пример кода для численного интегрирования с использованием FP64:

import torch

# Положение и скорость спутника
initial_position = torch.tensor([7.0e6, 0.0, 0.0], dtype=torch.float64)
initial_velocity = torch.tensor([0.0, 7.12e3, 0.0], dtype=torch.float64)
mass_earth = 5.972e24
g = 6.67430e-11

# Функция для вычисления ускорения
def compute_acceleration(position, mass, g=6.67430e-11):
   distance = torch.norm(position)
   return -g * mass / distance**3 * position

# Метод Рунге-Кутты для численного интегрирования
def runge_kutta_step(position, velocity, mass, time_step):
   k1_v = compute_acceleration(position, mass) * time_step
   k1_x = velocity * time_step
   k2_v = compute_acceleration(position + k1_x / 2, mass) * time_step
   k2_x = (velocity + k1_v / 2) * time_step
   return position + (k1_x + 2 * k2_x) / 3, velocity + (k1_v + 2 * k2_v) / 3

# Применение метода
time_step = 1.0  # шаг времени в секундах
position, velocity = runge_kutta_step(initial_position, initial_velocity, mass_earth, time_step)

print(f"Положение: {position}, Скорость: {velocity}")

Зачем здесь нужен FP64? В этой задаче важно, чтобы даже минимальные погрешности не привели к значительным отклонениям в расчётах, поскольку расчёт орбиты требует максимальной точности. FP64 позволяет минимизировать ошибки округления и обеспечивает высокую точность при выполнении численных методов, таких как метод Рунге-Кутты.

FP32: баланс между точностью и скоростью

9dcdb330926dd69b6457cd1f045fda0d.jpg

FP32 — это стандартный 32-битный формат, который используется в большинстве повседневных задач, таких как рендеринг графики, обработка изображений и обучение нейросетей. Он обеспечивает достаточную точность при высокой производительности, что делает его оптимальным выбором для задач, где скорость важнее точности.

Задача: Умножение матриц для обработки изображений в реальном времени. В таких задачах требуется быстрое выполнение вычислений, а точность FP32 достаточна для получения качественных результатов.

Пример кода для умножения матриц с использованием FP32:

import torch


# Инициализация матриц в формате float32
matrix_a = torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtype=torch.float32)
matrix_b = torch.tensor([[5.0, 6.0], [7.0, 8.0]], dtype=torch.float32)


# Выполнение умножения матриц
result = torch.matmul(matrix_a, matrix_b)


print(f"Результат умножения матриц с FP32: \n{result}")

Зачем здесь нужен FP32? В ряде задач, связанных с рендерингом или обработкой графики, критически важно выполнять расчёты в реальном времени, например, при создании графических эффектов или обработке видео. FP32 обеспечивает хорошее соотношение между точностью и производительностью. Конечно, крайне маловероятно что кто-то будет использовать Python для задач требующих исполнения в реальном времени, но для однородности он тут выбран как и для остальных примеров, хотя C/C++/Rust/Zig подошли бы лучше.

FP16: ускорение обработки данных

acc2b8d2eac12458f76f31d14a8875b5.png

FP16 — это 16-битный формат, который позволяет значительно ускорить вычисления за счёт уменьшения точности, но без существенного ущерба для качества результата. Этот формат активно используется в задачах машинного обучения и нейросетей, где важна высокая скорость обработки больших объёмов данных.

Задача: Обучение нейросети для классификации изображений. FP16 позволяет ускорить тренировку модели без значительной потери качества.

Пример кода для работы с FP16:

import torch
import torch.nn as nn
import torch.optim as optim

# Простая нейросеть для классификации
class SimpleNet(nn.Module):
   def __init__(self):
       super(SimpleNet, self).__init__()
       self.fc1 = nn.Linear(784, 128)
       self.fc2 = nn.Linear(128, 10)

   def forward(self, x):
       x = torch.relu(self.fc1(x))
       return self.fc2(x)

# Инициализация данных и модели
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleNet().to(device, dtype=torch.float16)
optimizer = optim.Adam(model.parameters(), lr=0.001)

# Фиктивные данные для обучения
data = torch.randn(64, 784, dtype=torch.float16).to(device)
target = torch.randint(0, 10, (64,), dtype=torch.long).to(device)

# Прогон обучения
optimizer.zero_grad()
output = model(data)
loss = nn.CrossEntropyLoss()(output, target)
loss.backward()
optimizer.step()

print(f"Потери: {loss.item()}")

Зачем здесь нужен FP16? При обучении больших нейросетей на огромных датасетах время обработки играет решающую роль. FP16 позволяет в несколько раз ускорить процесс обучения на GPU, уменьшив объём занимаемой памяти и снизив потребление ресурсов. Это особенно полезно при обучении глубоких моделей с большими объёмами данных.

BFLOAT16: оптимизация для инференса

e1d9c676acea73b55e192bd1ad67467d.png

BFLOAT16 — это формат, который чаще всего используется для инференса, то есть для выполнения уже обученных моделей. Он позволяет существенно ускорить обработку данных без значительных потерь в точности, что особенно полезно в задачах, связанных с анализом данных в реальном времени.

Задача: Инференс модели для обработки изображений с использованием BFLOAT16 для ускорения вычислений.

Пример кода для инференса с использованием BFLOAT16:

import torch

# Инициализация данных с использованием BFLOAT16
input_data = torch.randn(64, 784, dtype=torch.bfloat16).cuda()

# Фиктивная модель для инференса
class SimpleInferenceNet(nn.Module):
   def __init__(self):
       super(SimpleInferenceNet, self).__init__()
       self.fc = nn.Linear(784, 10)

   def forward(self, x):
       return self.fc(x)

# Выполнение инференса
model = SimpleInferenceNet().cuda().bfloat16()
output = model(input_data)

print(f"Результат инференса: {output}")

Зачем здесь нужен BFLOAT16? Этот формат используется для ускорения инференса, что особенно важно в задачах, связанных с реальным временем, таких как обработка потоков данных или систем искусственного интеллекта в автономных автомобилях. BFLOAT16 сохраняет точность экспоненциальной части числа, что позволяет работать с большими диапазонами значений, при этом экономя память и ресурсы.

FP8: максимальная производительность для инференса

Представление числа 0.3952 различными типами чисел с плавающей запятой.

FP8 — это новый формат, который используется для выполнения операций с максимальной скоростью при минимальных затратах ресурсов. Этот формат хорошо подходит для инференса, где точность не так важна, как скорость, например, в задачах, связанных с компьютерным зрением или распознаванием объектов в реальном времени.

Задача: Выполнение инференса с использованием FP8 для распознавания объектов на изображении.

Пример кода:

import torch
import torch.nn as nn # Import the torch.nn module

# Пример симуляции работы с FP8
def to_fp8(tensor):
   return torch.clamp(tensor, min=-128, max=127).to(torch.float32)

# Инициализация данных
input_data = torch.randn(64, 784).cuda()
input_data_fp8 = to_fp8(input_data)

# Простейшая модель для инференса
class SimpleFP8Net(nn.Module):
   def __init__(self):
       super(SimpleFP8Net, self).__init__()
       self.fc = nn.Linear(784, 10)

   def forward(self, x):
       return self.fc(x)

# Выполнение инференса
model = SimpleFP8Net().cuda()
output = model(input_data_fp8)


print(f"Результат с использованием FP8: {output}")

Зачем здесь нужен FP8? FP8 идеально подходит для задач, где точность может быть снижена в пользу максимальной скорости обработки данных. Это актуально в инференсе для систем искусственного интеллекта, где нужно мгновенно обрабатывать огромные объёмы данных с минимальными затратами ресурсов.

Заключение

Каждый тип float — будь то FP64, FP32, FP16, BFLOAT16 или FP8 — имеет своё применение и должен выбираться в зависимости от задачи. FP64 — для научных расчётов, FP32 — для баланса между производительностью и точностью, FP16 — для обучения нейросетей, а BFLOAT16 и FP8 — для инференса. Современные ускорители Nvidia Tesla, Radeon Instinct или Intel GPU Max поддерживают все эти форматы, что позволяет вам максимально эффективно использовать мощь GPU для каждой конкретной задачи.

А какой тип float используете вы в своих проектах и почему? Делитесь в комментариях, будет интересно обсудить!

© Habrahabr.ru