高效的多模態資料管道

釋出於 2025 年 7 月 8 日
在 GitHub 上更新

你已經準備好了一切——資料、模型、強大的 GPU 配置。你點選“執行”,然後……等待。再等一會兒。你的 GPU 幾乎沒有出什麼力,而你的錢包卻在每小時變輕。

聽起來很熟悉?我們也經歷過。在對我們的 nanoVLM 專案進行了一些調查後,我們發現真正的罪魁禍首不是我們的模型或硬體,而是我們的資料管道效率極其低下。

以下是我們的發現

  1. GPU 空閒:我們的模型實際上在等待資料送達
  2. 填充地獄:每個批次都塞滿了無用的填充令牌,這些令牌對訓練毫無貢獻

在這篇文章中,我們將分五個階段構建一個高效的管道。在每個階段,我們都會在前一步的基礎上進行增刪,並評論哪些地方做得對,哪些地方做得不好。

目錄:

【第 0 階段】準備工作

為了更容易地跟進資料準備任務,我們建立了一個專門關注資料管道的獨立倉庫。我們希望這比在與 nanoVLM 倉庫整合後閱讀程式碼更容易理解。此外,這對於啟動其他資料管道也可能很有用!

倉庫:https://github.com/ariG23498/mmdp

要跟上進度,您只需克隆該倉庫即可。它包含了最終的資料準備任務,但其設計旨在展示過程中的每一步。

$ git clone https://github.com/ariG23498/mmdp.git

【第 1 階段】視覺化資料集

在最佳化任何東西之前,我們需要了解我們正在處理什麼。我們的多模態資料集包含影像、文字提示和響應。

$ uv run 01_check_dataset.py

Dataset Sample

熟悉你的訓練資料對於成功至關重要。前面的指令碼每次執行時都會顯示一個隨機樣本;你可能需要將程式碼片段複製到筆記本中並多次執行,以便對資料有一個感覺。

【第 2 階段】樸素填充

我們第一次訓練嘗試採用了顯而易見(且非常常見)的方法

  • 對所有內容進行分詞
  • 找到每個批次中最長的序列
  • 將其他所有內容填充以匹配長度
$ uv run 02_naive_pad_dataloader.py

結果令人痛苦。看看這個視覺化圖

Naive Padding Waste

看到那些灰色的部分了嗎?那就是填充。那是 GPU 在你支付計算時間的同時,卻在處理絕對空無一物的東西。我們大約浪費了 60% 的批次在空令牌上。

【第 3 階段】約束填充

我們的下一步很簡單。設定一個全域性最大長度並遵守它。如果一個樣本太長,我們就直接丟棄它。

Constrained Padding

你可能已經注意到,批次現在少了一個樣本。這是由於過濾過程。這有所幫助,但我們仍然將所有內容填充到相同的固定長度,而不管實際內容如何。比以前好,但仍然很浪費。

【第 4 階段】:使用揹包演算法進行更智慧的打包

現在我們準備完全重新思考批處理。填充是敵人,我們需要一個策略來最小化它,同時最大化每個批次中可以容納的資料量。進入揹包問題,這是計算機科學中的一個經典問題,非常適合這個場景。

想象一下你正在為一次徒步旅行打包揹包。它的承重量有限,而你希望儘可能多地塞進有用的物品。在我們的情況下

  • 揹包是一個具有最大令牌限制(max_length)的訓練批次。
  • 每個物品是一個序列(一個分詞後的提示-響應對),其重量是令牌的數量。
  • 我們的目標是在不超過令牌限制的情況下,將盡可能多的序列打包到批次中,從而最大限度地減少浪費的空間。

為了測試這個想法,我們從一個玩具資料集開始:只是一個從 1 到 25 的數字列表,每個數字代表一個序列長度。這讓我們可以在不涉及影像和文字複雜性的情況下進行實驗。

切換到可迭代資料集

大多數 PyTorch 資料集是 map-style 的(你用 dataset[i] 訪問它們)。但對於動態批處理,我們需要更靈活的東西。因此,我們透過子類化 torch.utils.data.IterableDataset 構建了一個 iterable-style 資料集。這讓我們能夠動態生成批次,並處理像在多個工作程序之間分片資料這樣的技巧。

def _get_data_range(self):
    worker_info = get_worker_info()
    if worker_info is None:  # single worker, return the entire dataset
        return self.start, self.end
    else:  # multiple workers, split the data load
        per_worker = int(
            math.ceil((self.end - self.start) / worker_info.num_workers)
        )
        worker_id = worker_info.id
        iter_start = self.start + worker_id * per_worker
        iter_end = min(iter_start + per_worker, self.end)
        return iter_start, iter_end

生產者-消費者模式的魔力

打包序列可能會很慢,尤其是在我們進行排序或打亂時。為了保持流暢,我們使用生產者-消費者模式,藉助 Python 佇列

def _producer(self, data_iter, queue, stop_signal):
    if self.strategy == "greedy":
        for pack in self._greedy_packing(data_iter):
            queue.put(pack)
    elif self.strategy == "binpack":
        while True:
            buffer = list(itertools.islice(data_iter, self.buffer_size))
            if not buffer:
                break
            knapsacks = self._bin_packing(buffer)
            for pack in knapsacks:
                queue.put(pack)
    queue.put(stop_signal)

生產者執行緒打包批次並將其放入佇列,而主執行緒則根據需要從中取出。這種重疊使得管道能夠順暢流動。

貪婪打包

首先,我們嘗試一種簡單的貪婪打包策略

def _greedy_packing(self, iterator):
    pack, pack_sum = [], 0
    for item in iterator:
        if item > self.max_length:
            continue
        if pack_sum + item <= self.max_length:
            pack.append(item)
            pack_sum += item
        else:
            yield pack
            pack = [item]
            pack_sum = item
    if pack:
        yield pack

這種方法按順序遍歷資料,將專案新增到一個包中直到滿,然後開始一個新的包。它速度快但不完美。以下是批次的樣子

=== Strategy: GREEDY ===
[tensor([1]), tensor([2]), tensor([3]), tensor([4]), tensor([5]), tensor([6]), tensor([7]), tensor([8]), tensor([9]), tensor([10]), tensor([11]), tensor([12]), tensor([13])]
[tensor([14]), tensor([15]), tensor([16]), tensor([17]), tensor([18]), tensor([19])]
[tensor([20]), tensor([21]), tensor([22]), tensor([23])]
[tensor([24])]

Greedy Knapsack

注意到後面的批次變得稀疏了嗎?我們留下了空隙。

使用裝箱演算法實現更緊密的擬合

讓我們嘗試一種更智慧的方法:裝箱演算法(具體來說是首次適應遞減法)

def _bin_packing(self, buffer: List[int]):
    buffer = sorted(buffer, reverse=True)
    knapsacks = []
    for item in buffer:
        for pack in knapsacks:
            if sum(pack) + item <= self.max_length:
                pack.append(item)
                break
        else:
            knapsacks.append([item])

這種方法按長度對序列進行排序(最長的在前),並嘗試將每個序列裝入第一個有空間的包中。如果都裝不下,就開啟一個新的包。結果如何?

=== Strategy: BINPACK ===
[tensor([24]), tensor([23]), tensor([22]), tensor([21]), tensor([10])]
[tensor([20]), tensor([19]), tensor([18]), tensor([17]), tensor([16]), tensor([9]), tensor([1])]
[tensor([15]), tensor([14]), tensor([13]), tensor([12]), tensor([11]), tensor([8]), tensor([7]), tensor([6]), tensor([5]), tensor([4]), tensor([3]), tensor([2])]

Tight

這些批次要緊密得多,浪費的空間更少。這就像玩俄羅斯方塊一樣,把資料塊緊密地拼接在一起。

【第 5 階段】將揹包演算法應用於多模態資料

現在是動真格的時候了,將揹包打包演算法應用到我們的多模態資料集中。

我們又回到了影像、提示和響應,我們需要在遵守令牌限制影像預算的同時高效地打包它們。影像預算的設定是為了平衡每個樣本的影像數量。我們希望避免一個 GPU 需要處理比另一個 GPU 多得多的影像的情況。

我們新的 ConstantLengthDataset 類負責繁重的工作。以下是它與第 4 階段相比的工作方式

概念 第 4 階段(玩具資料) 第 5 階段(多模態資料) 函式
專案 整數(序列長度) 完整樣本(影像、提示、響應) VQADataset.__getitem__
重量 整數本身 令牌數量 (len(input_ids))
揹包 整數批次 ≤ max_length 樣本批次 ≤ seq_length 和影像限制 _balanced_greedy_knapsack
打包策略 貪婪或裝箱演算法 帶有令牌和影像約束的貪婪打包 _balanced_greedy_knapsack
生產者-消費者 生產者填充佇列 與玩具示例相同,但處理多模態樣本 _producer, __iter__
樣本過濾 跳過 > max_length 的整數 跳過令牌或影像過多的樣本 _producer
分片 分割整數範圍 分片資料集索引 make_base_iterator()
批處理 分組整數 連線並對齊令牌/影像 _pack_one_group
輸出 整數列表 包含 input_ids, labels, attention_mask, images 的字典 __iter__yield

ConstantLengthDataset 包攬了一切

  • 讀取樣本(影像和文字)。
  • 過濾掉太長或影像太多的樣本。
  • 使用貪婪揹包策略將樣本打包成批次,平衡令牌數和影像數。
  • 將最終批次填充到固定長度,但填充量比以前少得多

這是結果

Knapsack Padding

看那個!灰色(填充)部分非常少,批次裡充滿了有用的資料。這就像打包行李箱一樣,裝得那麼好,你不用坐在上面就能拉上拉鍊。

這張圖乍一看可能不直觀,但讓我們把它和約束填充的圖並排比較一下。

揹包 約束填充
Knapsack Padding Constrained Padding

在這裡你會注意到,揹包演算法中的樣本分佈更均勻。我們也不會因為過濾而導致批次中的樣本數量減少。

結論

最初只是一個簡單的“為什麼訓練這麼慢?”的調查,最終導致我們徹底重新思考了處理多模態資料的方式。

用於資料管道的平衡揹包策略來自 NVIDIA 的論文 Eagle 2: Building Post-Training Data Strategies from Scratch for Frontier Vision-Language Models

關鍵經驗

  • 將所有內容填充到最長序列是一個不錯的初步方法(但很浪費)
  • 將批處理視為一個打包問題
  • 考慮所有約束(文字長度、影像記憶體等)
  • 首先用玩具資料測試以驗證你的方法

想深入瞭解嗎?請檢視

祝你訓練愉快(願你的 GPU 保持忙碌)!

社群

感謝你們出色的工作!我可以提幾個建議嗎?🤗

  1. 恕我直言,如果你的原始資料集是打亂的,這些圖(順便說一句,看起來很棒)會不那麼令人困惑,例如“貪婪打包”圖中的樣子並不像你在實踐中會得到的那樣。
  2. 除了在第 5 階段平衡影像數量外,平衡樣本數量也有助於訓練。例如——看你在第 4 階段的圖——序列 1 和最後一個序列之間每個序列(你稱之為批次)的樣本數量差異很大。NVIDIA 的 EAGLE 2 論文 (Li et al., 2025, https://arxiv.org/abs/2501.14818) 表明,使用一個平衡版本的揹包演算法有助於訓練!(見圖 9 和圖 10)。只是覺得和社群分享這個技術會很不錯!

Screenshot 2025-07-14 at 19.06.34.png

但再次強調,這篇部落格文章和 Picotron 的工作真的很棒!

·
文章作者

這些都是非常好的指點。你願意直接把你的建議新增到部落格文章中嗎?這樣我們就可以把你列為部落格文章的作者之一(並給予應有的致謝)。

你可以編輯這個頁面:https://github.com/huggingface/blog/blob/main/mmdp.md

註冊登入 以發表評論

© . This site is unofficial and not affiliated with Hugging Face, Inc.