重要提示

您正在查看 NeMo 2.0 文档。此版本引入了 API 的重大更改和一个新的库,NeMo Run。我们目前正在将 NeMo 1.0 的所有功能移植到 2.0。有关先前版本或 2.0 中尚不可用的功能的文档,请参阅 NeMo 24.07 文档

序列打包#

本节介绍如何在监督微调 (SFT) 和参数高效微调 (PEFT) 中使用序列打包训练技术。

SFT/PEFT 的序列打包#

概述#

当微调大型语言模型或视觉语言模型时,无论是使用 SFT 还是 PEFT 方法,由于低效的输入数据结构,通常会出现 GPU 利用率不足的情况。这种低效性产生的原因是,许多微调数据集的序列长度分布存在偏差,其中许多是短序列,而少数是长序列,这符合齐普夫定律。由于 Transformer 模型需要固定长度的输入,因此较短的序列必须填充许多填充标记。这导致了两个主要效率低下问题

  • 对填充标记执行的计算最终会被屏蔽掉,从而导致 GPU 计算资源的浪费。

  • 微批量大小通常受包含较长序列的批次限制,因此大多数其他微批量都存在 GPU 内存利用率不足的情况。

序列打包是一种训练技术,其中将多个训练序列(示例)连接成一个长序列(包)。此技术大大减少了填充标记的数量,从而允许在每个微批量中处理更多有意义的标记。因此,它可以最大限度地提高 GPU 计算和 GPU 内存利用率。

虽然预训练的序列可以天真地连接起来,而无需仔细注意序列边界,但对于监督式和指令式微调来说,情况通常并非如此。这是因为微调工作负载中的数据质量要高得多,因此每个输入序列都应单独处理。

传统的解决方案是构建自定义注意力掩码(特别是,块三角掩码)以屏蔽序列之间的注意力值。但是,这会将注意力的复杂性从 \(\sum_i {s_i}^2\) 增加到 \(\Big({\sum_i {s_i}}\Big)^2\),其中 \(s_i\) 是第 \(i\) 个子序列的长度。在实践中,传统的解决方案限制了打包序列的大小。相反,NeMo 提供了一种高度优化的序列打包版本,该版本利用 FlashAttention 和 TransformerEngine 中的可变长度注意力内核。NeMo 没有提供自定义注意力掩码,而是使用 cu_seq_lens 变量(cumulative sequence length 的缩写)[1] 传入有关序列边界的信息。使用这种方法,永远不会计算序列之间的注意力值,因此注意力的复杂性仍然保持在 \(\sum_i {s_i}^2\)。这允许打包序列的大小增加到任意长度而不会影响内存复杂性,从而可以充分利用 GPU 内存。

综合考虑,NeMo 的序列打包实现提供了 [2]

  • 在 FLOP 方面,性能提升高达 10 倍

  • 在训练时间方面,性能提升高达 6 倍

  • 对模型收敛没有影响

在 LLM 中使用打包序列运行 SFT/PEFT#

准备数据集#

在 NeMo 2.0 中,打包数据集在训练前自动准备,无需任何其他步骤。

使用预定义的微调配方进行训练#

使用打包序列开始微调模型的最快方法是使用 NeMo-Run 配方。只需在配方函数中设置 packed_sequence=True。以下是使用 Llama 3 8B 模型的示例。

from nemo.collections import llm

recipe = llm.llama3_8b.finetune_recipe(
    name="llama3_8b_finetuning",
    dir=f"/path/to/checkpoints",
    num_nodes=1,
    num_gpus_per_node=8,
    packed_sequence=True,
)

迁移您自己的配方#

如果您已经有一个未使用打包序列的微调配方,请按如下方式修改您的配方以开始使用打包序列进行训练。

  1. 在您的数据模块中添加 packed_sequence_specs=PackedSequenceSpecs(...)。例如

    data = llm.DollyDataModule(
            seq_length=2048,
            micro_batch_size=1,
            global_batch_size=8,
            packed_sequence_specs=PackedSequenceSpecs(
                packed_sequence_size=2048,
                tokenizer_model_name="a_unique_tokenizer_name",
            ),
        )
    

    有关 PackedSequenceSpecs 中允许的字段的说明,请参阅此处

  2. 调整批量大小。

    • 微批量大小必须设置为 1。出现此约束的原因是微批量中的样本不再堆叠;它们现在在数据准备步骤中连接。因此,在使用打包序列时,微批量大小变得无关紧要,因此我们要求用户将其设置为 1 以确认这一事实。为了提高 GPU 内存利用率,您可以增加 packed_sequence_size,从而达到与增加微批量大小相同的效果。

    • 必须调整全局批量大小以维护训练配方。由于每个包现在包含多个序列,因此全局批量大小需要按每个包的平均序列数 n 减少,其中 n = num_sequences_in_dataset / num_packs(等效地,n = packed_sequence_size / average_seq_len)。这确保了每个梯度迭代平均看到相同数量的标记。n 的值在数据准备步骤中打印出来。您可能需要运行一次训练,从日志中获取 n 的值,然后使用更新后的全局批量大小再次运行您的训练脚本。或者,您可以从可能的最小全局批量大小开始,以便 data_parallel_size = num_gpus(即,没有梯度累积),并逐渐调整您的全局批量大小。

现在,您已准备好使用大大提高的吞吐量来微调您的模型!

在 NeVA 中使用打包序列运行 SFT/PEFT#

在 NeMo 2.0 中,您不再需要预处理数据集以进行序列打包,然后再使用。相反,打包是动态执行的。根据您使用的数据集类型,实现方式会略有不同。查看我们的示例微调脚本

data = vlm.NevaMockDataModule(
    seq_length=2048,
    global_batch_size=128,
    micro_batch_size=4,
    tokenizer=None,
    image_processor=None,
    num_workers=4,
    packed_sequence=True,
)

packed_sequence 设置为 True 以指示您是否要在 NevaMockDataModule 中使用打包序列。如果启用,则整个微批量(包含 micro_batch_size 随机生成的样本)将打包到一个具有 THD 布局的序列中 [1]。模拟数据集旨在用于快速测试和基准测试。

data_config = vlm.ImageDataConfig(
    image_folder="/path/to/image_folder",
    conv_template="v1",
)
data = vlm.NevaLazyDataModule(
    paths="/path/to/data_json_file",
    data_config=data_config,
    seq_length=2048,
    decoder_seq_length=None,
    global_batch_size=128,
    micro_batch_size=4,
    tokenizer=None,
    image_processor=None,
    num_workers=4,
    packed_sequence=True,
    num_image_embeddings_per_tile=576,
)

packed_sequence 设置为 True 以在 NevaLazyDataModule 中启用打包序列。与模拟模块类似,如果启用了打包,则整个微批量将打包到一个具有 THD 布局的序列中 [1]。请注意,未应用其他选择算法。由于每个微批量中的样本是从整个数据集中随机选择的,因此该行为等效于随机选择 micro_batch_size 个样本。启用序列打包后,不再需要在每个微批量中将填充序列填充到相同长度,从而可能提高处理速度。

启用 packed_sequence 时,您可以增加 micro_batch_size。只要全局批量大小 (global_batch_size) 保持一致,收敛行为应该相似。请注意,当启用流水线并行 (PP) 时,打包序列仍将被截断为 seq_length 以方便 PP 通信。

config = vlm.MultiModalSampleConfig(
    image_token=vlm.ImageToken(token_str="<image>", token_id=-200),
    ignore_place_holder=-100,
    conversation_template_config=LLaVATemplateConfig(),
)

data = vlm.EnergonMultiModalDataModule(
    path="/path/to/energon_data",
    tokenizer=tokenizer,
    image_processor=image_processor,
    seq_length=2048,
    micro_batch_size=1,
    global_batch_size=32,
    num_workers=0,
    multimodal_sample_config=config,
    task_encoder=MultiModalTaskEncoder(
        tokenizer=tokenizer,
        image_processor=image_processor,
        multimodal_sample_config=config,
        packed_sequence=True,
        packed_sequence_size=8192,
        num_image_embeddings_per_tile=576,
    ),
    packing_buffer_size=200 if packed_sequence else None,
)

要使用 Energon 数据集,请按照此处的说明将您的数据集处理为 Energon 格式。设置 packed_sequence=True 并指定 packing_buffer_size 以启用序列打包。Energon 数据集使用动态打包,其中每个工作程序读取 packing_buffer_size 个样本,并将它们打包成大小为 packed_sequence_size 的序列。有关打包的更多详细信息,请参阅Energon 用户指南

使用此数据集时,将微批量大小 (micro_batch_size) 设置为 1,并通过将全局批量大小 (global_batch_size) 除以每个包的平均序列数 (n) 来调整全局批量大小。