代码示例 / 快速Keras食谱 / Float8训练与推理的简单Transformer模型

Float8训练与推理的简单Transformer模型

作者: Hongyu Chiu
创建日期: 2024/05/14
最后修改日期: 2024/05/14
描述: 使用float8量化训练一个简单的Transformer模型。

在Colab中查看 GitHub源代码


介绍

随着Transformer模型中参数数量的不断增加,训练和推理变得非常消耗内存和计算资源。因此,引入了8位浮点数(FP8),在几乎没有精度下降的情况下提供了比16位浮点数更好的性能。

具体来说,FP8有两种不同类型:E4M3和E5M2,在训练的不同部分有不同的用途。

  • E4M3:由1个符号位、4个指数位和3个尾数位组成。它可以存储值高达+/-448和nan。
  • E5M2:由1个符号位、5个指数位和2个尾数位组成。它可以存储值高达+/-57344、+/-无穷大和nan。增加动态范围的权衡是存储值的精度降低。

通常,E4M3在前向传播过程中最好使用,因为激活值和权重需要更高的精度。然而,在反向传播过程中,由于梯度对精度损失的不敏感,E5M2被利用,但需要更高的动态范围。

值得注意的是,FP8推理部署大大简化,因为推理和训练使用相同的数据类型。这与使用32位或16位浮点数训练的网络进行INT8推理形成对比,这需要后训练量化(PTQ)校准,甚至量化感知训练(QAT)以维持模型精度。

在这个例子中,我们将构建一个简单的Transformer模型,并使用FP16和FP8精度对其进行训练。你将观察到,精度不会因为较低的精度而降低。

注意:你需要一块支持FP8张量核心的不错GPU才能获得预期的性能提升。


设置

我们将使用KerasNLP库简化模型实现。此外,使用混合精度训练来减少训练时间。

注意:对TensorFlow的依赖仅在于数据处理。

!pip install -q --upgrade keras-nlp
!pip install -q --upgrade keras  # 升级到Keras 3.
import os

os.environ["KERAS_BACKEND"] = "jax"
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"

import re

import keras
import keras_nlp
import tensorflow as tf

keras.config.set_dtype_policy("mixed_bfloat16")

定义一些超参数。

EPOCHS = 3
BATCH_SIZE = 32
VOCABULARY_SIZE = 20000
MAX_SEQUENCE_LENGTH = 200
MODEL_KWARGS = dict(
    vocabulary_size=VOCABULARY_SIZE,
    max_sequence_length=MAX_SEQUENCE_LENGTH,
    hidden_dim=32,  # 每个token的隐藏层大小
    num_heads=2,  # 注意力头的数量
    intermediate_dim=32,  # 前馈网络中的中间层大小
    dropout=0.1,  # 丢弃率
)

数据集

首先,让我们下载IMDB数据集并解压缩。

!mkdir -p datasets
!wget http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz -q -O datasets/aclImdb_v1.tar.gz
!mkdir -p datasets/aclImdb
!tar -xzf datasets/aclImdb_v1.tar.gz -C datasets
!rm -rf datasets/aclImdb/train/unsup

我们将使用keras.utils.text_dataset_from_directory工具从文本文件生成标记的tf.data.Dataset数据集。

train_ds = keras.utils.text_dataset_from_directory(
    "datasets/aclImdb/train",
    batch_size=BATCH_SIZE,
    validation_split=0.2,
    subset="training",
    seed=42,
)
val_ds = keras.utils.text_dataset_from_directory(
    "datasets/aclImdb/train",
    batch_size=BATCH_SIZE,
    validation_split=0.2,
    subset="validation",
    seed=42,
)
test_ds = keras.utils.text_dataset_from_directory(
    "datasets/aclImdb/test", batch_size=BATCH_SIZE
)
找到25000个属于2个类的文件。

使用20000个文件进行训练。

找到25000个属于2个类的文件。

使用5000个文件进行验证。

找到25000个属于2个类的文件。

我们现在将文本转换为小写字母。

train_ds = train_ds.map(lambda x, y: (tf.strings.lower(x), y))
val_ds = val_ds.map(lambda x, y: (tf.strings.lower(x), y))
test_ds = test_ds.map(lambda x, y: (tf.strings.lower(x), y))

让我们打印一些样本。

for text_batch, label_batch in train_ds.take(1):
    for i in range(3):
        print(f"文本: {text_batch.numpy()[i]}")
        print(f"标签: {label_batch.numpy()[i]}")
文本:b'"pandemonium"是一部恐怖电影的恶搞,给人感觉比搞笑更愚蠢。相信我,当我告诉你我喜欢喜剧时,尤其是喜剧恶搞。"空中大灌篮"、"赤裸裸的枪"三部曲、"火焰马鞍"、"高焦虑"和"太空大炮"是我最喜欢的恶搞特定类型的喜剧。"pandemonium"并不在那些电影之中。这部电影中的大部分场景让我坐在那里目瞪口呆,因为这部电影并不是那么搞笑。电影中有几处让人发笑的地方,但当你观看一部喜剧时,你期待更多的笑声,而这部电影只是略有几次。天哪,"尖叫"比这部电影笑得还多,而那部影片更像是一部恐怖片。这是多么离奇的事情呢?<br /><br />*1/2(满分四分)'
标签:0
文本:b'david mamet是一位非常有趣且非常不平等的导演。他的第一部电影《游戏之家》是我最喜欢的那一部,它设定了一系列角色在复杂情境中生活视角的变化,观众的视角也随之变化。<br /><br />所以《凶杀案》也是如此,从标题就试图将观众的思维引向常规的犯罪 drama。主要角色是两名警察,一个犹太人和一个爱尔兰人,他们处理一个种族关系紧张的地区。一个古老的犹太商人的谋杀,证明他是以色列独立战争的古老退伍军人,触发了犹太侦探心中的犹太身份。<br /><br />这就是电影缺陷最明显的地方。觉醒的过程戏剧化且难以置信,犹太激进分子的团体呈现歌剧化,而侦探最终走向最终暴力对抗的方式显得悲惨。电影的结尾聪明地具有mamet的风格,但从人类情感的角度来看让人失望。<br /><br />joe mantegna和william macy的表演都很出色,但故事的缺陷过于明显,无法轻易弥补。'
标签:0
文本:b'关于纽约消防员在有史以来最严重恐怖袭击中的生活的伟大纪录片……仅这一原因就足以让它成为必看收藏品。我感到震惊的不仅是袭击,还有一些消防员的"高脂饮食"和外貌。我认为很多医生会同意,在他们的身体状况下,一些消防员在背负超过60磅装备时,是无法到达79层的。话虽如此,我现在对消防员有了更大的尊重,我意识到成为一名消防员是一份改变生活的工作。法国有制作优秀纪录片的历史,而这就是一部伟大的纪录片.....'
标签:1

切分数据

我们将使用keras_nlp.tokenizers.WordPieceTokenizer层来切分文本。keras_nlp.tokenizers.WordPieceTokenizer接受一个WordPiece词汇并具备切分文本及重新组合令牌序列的功能。

在定义切分器之前,我们首先需要在我们拥有的数据集上训练它。WordPiece切分算法是一种子词切分算法;在语料库上训练它可以为我们提供一个子词词汇表。子词切分器是词切分器(词切分器需要非常大的词汇量以良好覆盖输入词)与字符切分器(字符并不能像词那样真正编码意义)之间的一种妥协。幸运的是,KerasNLP让在语料库上训练WordPiece变得非常简单,使用的是keras_nlp.tokenizers.compute_word_piece_vocabulary工具。

def train_word_piece(ds, vocab_size, reserved_tokens):
    word_piece_ds = ds.unbatch().map(lambda x, y: x)
    vocab = keras_nlp.tokenizers.compute_word_piece_vocabulary(
        word_piece_ds.batch(1000).prefetch(2),
        vocabulary_size=vocab_size,
        reserved_tokens=reserved_tokens,
    )
    return vocab

每个词汇中都有几个特殊的保留令牌。我们有两个这样的令牌:

  • "[PAD]" - 填充令牌。当输入序列的长度小于最大序列长度时,填充令牌会附加到输入序列长度后面。
  • "[UNK]" - 未知令牌。
reserved_tokens = ["[PAD]", "[UNK]"]
train_sentences = [element[0] for element in train_ds]
vocab = train_word_piece(train_ds, VOCABULARY_SIZE, reserved_tokens)

让我们看看一些令牌!

print("令牌: ", vocab[100:110])
令牌:  ['à', 'á', 'â', 'ã', 'ä', 'å', 'æ', 'ç', 'è', 'é']

现在,让我们定义切分器。我们将根据上述训练的词汇配置切分器。我们将定义一个最大序列长度,以便所有序列在长度小于指定序列长度时都填充到相同的长度。否则,序列将被截断。

tokenizer = keras_nlp.tokenizers.WordPieceTokenizer(
    vocabulary=vocab,
    lowercase=False,
    sequence_length=MAX_SEQUENCE_LENGTH,
)

让我们尝试对数据集中一个样本进行标记化!为了验证文本是否已正确标记化,我们还可以将标记列表反标记化为原始文本。

input_sentence_ex = train_ds.take(1).get_single_element()[0][0]
input_tokens_ex = tokenizer(input_sentence_ex)

print("句子: ", input_sentence_ex)
print("标记: ", input_tokens_ex)
print("反标记化后的文本: ", tokenizer.detokenize(input_tokens_ex))
句子:  tf.Tensor(b'great movie - especially the music - etta james - "at last". this speaks volumes when you have finally found that special someone.', shape=(), dtype=string)
标记:  

[  218   150    14   393   137   356    14  4917  2941   719    14     3
   164   370     3    15   145  2705 11670   186   155   160   557   391
   146   452   416    15     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0]
反标记化后的文本:  tf.Tensor(b'great movie - especially the music - etta james - " at last " . this speaks volumes when you have finally found that special someone . [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]', shape=(), dtype=string)

格式化数据集

接下来,我们将格式化我们的数据集,以便可以供模型使用。我们需要对文本进行标记化。

def format_dataset(sentence, label):
    sentence = tokenizer(sentence)
    return ({"input_ids": sentence}, label)


def make_dataset(dataset):
    dataset = dataset.map(format_dataset, num_parallel_calls=tf.data.AUTOTUNE)
    return dataset.shuffle(512).prefetch(tf.data.AUTOTUNE).cache()


train_ds = make_dataset(train_ds)
val_ds = make_dataset(val_ds)
test_ds = make_dataset(test_ds)

模型

让我们建立一个简单的 Transformer 模型。我们将使用 KerasNLP 库中的 TokenAndPositionEmbeddingTransformerDecoderTokenAndPositionEmbedding 表示句子中的单词及其顺序,而 TransformerDecoder 为输入序列的每个时间步输出一个向量。在这里,我们对所有时间步取平均,并在其上使用前馈神经网络来对文本进行分类。

def build_model(
    vocabulary_size=20000,
    max_sequence_length=200,
    hidden_dim=32,
    num_heads=2,
    intermediate_dim=32,
    dropout=0.1,
):
    token_id_input = keras.layers.Input(shape=(None,), dtype="int32", name="input_ids")
    x = keras_nlp.layers.TokenAndPositionEmbedding(
        vocabulary_size=vocabulary_size,
        sequence_length=max_sequence_length,
        embedding_dim=hidden_dim,
    )(token_id_input)
    x = keras.layers.Dropout(rate=dropout)(x)
    x = keras_nlp.layers.TransformerDecoder(
        intermediate_dim=intermediate_dim,
        num_heads=num_heads,
        dropout=dropout,
    )(x)
    x = keras.layers.GlobalAveragePooling1D()(x)
    x = keras.layers.Dropout(dropout)(x)
    x = keras.layers.Dense(intermediate_dim, activation="relu")(x)
    x = keras.layers.Dropout(dropout)(x)
    outputs = keras.layers.Dense(1, activation="sigmoid")(x)
    return keras.Model(inputs=token_id_input, outputs=outputs)

训练和评估我们的模型

首先,我们使用混合精度("mixed_bfloat16")训练和评估模型。之后,我们将结果与 FP8 训练/推理进行比较。

model = build_model(**MODEL_KWARGS)
model.summary()
model.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
history = model.fit(train_ds, epochs=EPOCHS, validation_data=val_ds)
result = model.evaluate(test_ds)
print(f"准确率(mixed_bfloat16): {result[1]:.2%}")
模型: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ 层 (类型)                      输出形状                   参数 # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_ids (输入层)          │ (, )           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ token_and_position_embedding    │ (, , 32)       │       646,400 │
│ (TokenAndPositionEmbedding)     │                        │               │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout (丢弃层)               │ (, , 32)       │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ transformer_decoder             │ (, , 32)       │         6,464 │
│ (TransformerDecoder)            │                        │               │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ global_average_pooling1d        │ (, 32)             │             0 │
│ (全局平均池化1D)        │                        │               │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout_2 (丢弃层)             │ (, 32)             │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (全连接层)                   │ (, 32)             │         1,056 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout_3 (丢弃层)             │ (, 32)             │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 1)              │            33 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 总参数: 653,953 (2.49 MB)
 可训练参数: 653,953 (2.49 MB)
 不可训练参数: 0 (0.00 B)
准确率 (mixed_bfloat16): 75.56%

我们可以通过一行API启用FP8训练/推理: model.quantize("float8")

model = build_model(**MODEL_KWARGS)
model.quantize("float8")

要检查FP8训练是否发生,我们可以打印一些与FP8训练相关的变量:

  • *_scale: 将输入、权重和梯度的分布移入FP8可表示范围的缩放因子。默认为1.0
  • *_amax_history: 用于缩放因子计算的amax历史窗口。默认为0.0,长度为1024。
pattern = r"(transformer).+(multi_head).+(query).+(scale|amax_history)"
for v in model.trainable_variables:
    if re.findall(pattern, v.path):
        print(v.path)
        print(keras.ops.convert_to_numpy(v.value))

FP8层的dtype策略也已被修改。

for layer in model._flatten_layers(recursive=True):
    if "float8" in str(layer.dtype_policy):
        print(f"{layer.name}: {layer.dtype_policy}")
feedforward_output_dense: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">
feedforward_intermediate_dense: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">
attention_output: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">
value: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">
key: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">
query: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">
dense_2: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">
dense_3: <QuantizedFloat8DTypePolicy "float8_from_mixed_bfloat16">

让我们训练模型并查看结果。我们可以验证用FP8训练时准确率并没有下降,并且包含FP8信息的变量在拟合后发生了变化。

model.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
history = model.fit(train_ds, epochs=EPOCHS, validation_data=val_ds)
result = model.evaluate(test_ds)
print(f"准确率 (float8): {result[1]:.2%}")

for v in model.trainable_variables:
    if re.findall(pattern, v.path):
        print(v.path)
        print(keras.ops.convert_to_numpy(v.value))
准确率 (float8): 74.16%

备忘录

  • 如果模型不够大,训练速度的提升相对较小。建议使用参数>5B的模型进行训练。
  • 您需要支持FP8 Tensor Cores的硬件,如NVIDIA H100,以获得速度提升。

参考文献