Quy trình dữ liệu đa phương thức hiệu quả
Bài viết này thảo luận về một quy trình dữ liệu đa phương thức hiệu quả.
- 11 min read
Quy trình Dữ liệu Đa phương thức Hiệu quả
Bạn đã chuẩn bị mọi thứ - dữ liệu, mô hình, một hệ thống GPU mạnh mẽ. Bạn nhấn “run” và… chờ. Và chờ đợi nhiều hơn nữa. GPU của bạn hầu như không hoạt động trong khi ví của bạn ngày càng vơi đi theo giờ.
Nghe quen thuộc không? Chúng tôi đã từng trải qua điều đó. Sau một số công việc điều tra trên dự án nanoVLM của mình, chúng tôi phát hiện thủ phạm thực sự không phải là mô hình hoặc phần cứng của chúng tôi, mà là quy trình dữ liệu của chúng tôi cực kỳ lãng phí.
Đây là những gì chúng tôi tìm thấy:
- GPU nhàn rỗi: Mô hình của chúng tôi thực sự đang chờ dữ liệu xuất hiện.
- Padding quá nhiều: Mỗi batch chứa đầy các token padding vô dụng, không đóng góp gì vào quá trình huấn luyện.
Trong bài đăng này, chúng tôi xây dựng một quy trình hiệu quả trong năm giai đoạn. Trong mỗi giai đoạn, chúng tôi thêm hoặc loại bỏ khỏi bước trước đó và nhận xét về những gì đã đúng và những gì không.
Mục lục:
- Giai đoạn 0: Chuẩn bị
- Giai đoạn 1: Trực quan hóa Tập dữ liệu
- Giai đoạn 2: Padding ngây thơ
- Giai đoạn 3: Padding có ràng buộc
- Giai đoạn 4: Đóng gói thông minh hơn với Knapsacks
- Giai đoạn 5: Knapsack cho Dữ liệu Đa phương thức
- Kết luận
[Giai đoạn 0] Chuẩn bị
Để giúp bạn dễ dàng theo dõi các tác vụ chuẩn bị dữ liệu, chúng tôi đã tạo một kho lưu trữ riêng biệt chỉ tập trung vào quy trình dữ liệu. Chúng tôi hy vọng điều này sẽ dễ hiểu hơn nhiều so với việc đọc mã sau khi tích hợp với kho lưu trữ nanoVLM. Ngoài ra, điều này có thể hữu ích để khởi động các quy trình dữ liệu khác!
Kho lưu trữ: https://github.com/ariG23498/mmdp
Để làm theo, tất cả những gì bạn cần làm là clone kho lưu trữ. Nó chứa các tác vụ chuẩn bị dữ liệu cuối cùng, nhưng nó được thiết kế để giới thiệu từng bước.
$ git clone https://github.com/ariG23498/mmdp.git
[Giai đoạn 1] Trực quan hóa Tập dữ liệu
Trước khi tối ưu hóa bất cứ điều gì, chúng ta cần hiểu những gì chúng ta đang làm việc. Tập dữ liệu đa phương thức của chúng tôi có hình ảnh, lời nhắc văn bản và phản hồi.
$ uv run 01_check_dataset.py
Làm quen với dữ liệu huấn luyện của bạn là rất quan trọng để thành công. Tập lệnh trước hiển thị một mẫu ngẫu nhiên mỗi khi bạn chạy nó; bạn có thể muốn sao chép đoạn mã vào một sổ ghi chép và chạy nó nhiều lần để có cảm giác về dữ liệu.
[Giai đoạn 2] Padding ngây thơ
Nỗ lực huấn luyện đầu tiên của chúng tôi đã sử dụng phương pháp rõ ràng (và rất thường xuyên):
- Token hóa mọi thứ
- Tìm chuỗi dài nhất trong mỗi batch
- Padding mọi thứ khác cho phù hợp
$ uv run 02_naive_pad_dataloader.py
Kết quả thật đau đớn. Hãy nhìn vào hình ảnh trực quan này:
Bạn có thấy tất cả màu xám đó không? Đó là padding. Đó là GPU đang xử lý hoàn toàn không có gì trong khi bạn trả tiền cho thời gian tính toán. Chúng tôi đã lãng phí khoảng 60% batch của mình cho các token trống.
[Giai đoạn 3] Padding có ràng buộc
Bước tiếp theo của chúng tôi rất đơn giản. Đặt độ dài tối đa toàn cục và tuân thủ nó. Nếu một mẫu quá dài, chúng tôi sẽ loại bỏ nó.
Như bạn có thể nhận thấy rằng batch hiện có ít hơn một mẫu. Điều này là do quá trình lọc. Điều này đã giúp, nhưng chúng tôi vẫn đang padding mọi thứ theo cùng một độ dài cố định bất kể nội dung thực tế. Tốt hơn trước, nhưng vẫn lãng phí.
[Giai đoạn 4]: Đóng gói thông minh hơn với Knapsacks
Bây giờ chúng ta đã sẵn sàng để suy nghĩ lại về việc tạo batch hoàn toàn. Padding là kẻ thù và chúng ta cần một chiến lược để giảm thiểu nó trong khi tối đa hóa lượng dữ liệu chúng ta có thể đưa vào mỗi batch. Hãy nhập bài toán xếp ba lô, một bài toán cổ điển từ khoa học máy tính, hoàn hảo cho việc này.
Hãy tưởng tượng bạn đang đóng gói một chiếc ba lô cho một chuyến đi bộ đường dài. Nó chỉ có thể chứa rất nhiều trọng lượng và bạn muốn nhồi nhét càng nhiều vật dụng hữu ích càng tốt. Trong trường hợp của chúng ta:
- Ba lô là một batch huấn luyện có giới hạn token tối đa (
max_length). - Mỗi vật dụng là một chuỗi (một cặp lời nhắc-phản hồi được token hóa) và trọng lượng của nó là số lượng token.
- Mục tiêu của chúng tôi là đóng gói càng nhiều chuỗi càng tốt vào batch mà không vượt quá giới hạn token, giảm thiểu không gian lãng phí.
Để kiểm tra ý tưởng này, chúng tôi bắt đầu với một tập dữ liệu đồ chơi: chỉ một danh sách các số từ 1 đến 25, mỗi số đại diện cho độ dài chuỗi. Điều này cho phép chúng tôi thử nghiệm mà không cần sự phức tạp của hình ảnh và văn bản.
Chuyển sang Bộ dữ liệu có thể lặp lại
Hầu hết các bộ dữ liệu PyTorch là kiểu bản đồ (bạn truy cập chúng bằng dataset[i]). Nhưng đối với việc tạo batch động, chúng ta cần thứ gì đó linh hoạt hơn. Vì vậy, chúng tôi đã xây dựng một bộ dữ liệu kiểu có thể lặp lại bằng cách tạo lớp con torch.utils.data.IterableDataset. Điều này cho phép chúng tôi tạo batch nhanh chóng và xử lý các thủ thuật như phân chia dữ liệu trên nhiều worker:
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
Phép thuật Nhà sản xuất-Người tiêu dùng
Đóng gói chuỗi có thể chậm, đặc biệt nếu chúng ta đang sắp xếp hoặc xáo trộn. Để mọi thứ tiếp tục di chuyển, chúng tôi sử dụng mẫu nhà sản xuất-người tiêu dùng bằng cách sử dụng hàng đợi 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)
Luồng nhà sản xuất đóng gói batch và đưa chúng vào hàng đợi, trong khi luồng chính kéo chúng ra khi cần. Sự trùng lặp này giúp quy trình hoạt động trơn tru.
Đóng gói tham lam
Đầu tiên, chúng ta thử một chiến lược đóng gói tham lam đơn giản:
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
Điều này đi qua dữ liệu tuần tự, thêm các mục vào một gói cho đến khi nó đầy, sau đó bắt đầu một gói mới. Nó nhanh nhưng không hoàn hảo. Đây là những gì batch trông như thế nào:
=== 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])]
Bạn có nhận thấy rằng các batch sau trở nên thưa thớt như thế nào không? Chúng ta đang bỏ lại những khoảng trống.
Đóng gói Bin để phù hợp hơn
Hãy thử một cách tiếp cận thông minh hơn: đóng gói bin (cụ thể là First Fit Decreasing):
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])
Điều này sắp xếp các chuỗi theo độ dài (dài nhất trước) và cố gắng phù hợp mỗi chuỗi vào gói đầu tiên có chỗ. Nếu không có cái nào phù hợp, nó sẽ bắt đầu một gói mới. Kết quả?
=== 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])]
Các batch này chặt chẽ hơn nhiều, với ít không gian lãng phí hơn. Nó giống như chơi Tetris với dữ liệu của bạn, lắp các mảnh lại với nhau một cách vừa vặn.
[Giai đoạn 5] Knapsacks cho Dữ liệu Đa phương thức
Bây giờ cho thỏa thuận thực sự, áp dụng đóng gói knapsack cho tập dữ liệu đa phương thức của chúng tôi.
Chúng ta quay lại hình ảnh, lời nhắc và phản hồi, và chúng ta cần đóng gói chúng một cách hiệu quả trong khi vẫn tôn trọng cả giới hạn token và ngân sách hình ảnh. Ngân sách hình ảnh được thực hiện để cân bằng số lượng hình ảnh trên mỗi mẫu. Chúng tôi muốn tránh trường hợp một GPU cần xử lý nhiều hình ảnh hơn GPU khác.
ConstantLengthDataset mới của chúng tôi xử lý công việc nặng nhọc. Đây là cách nó hoạt động, so với Giai đoạn 4:
| Khái niệm | Giai đoạn 4 (Dữ liệu Đồ chơi) | Giai đoạn 5 (Dữ liệu Đa phương thức) | Chức năng |
|---|---|---|---|
| Vật phẩm | Số nguyên (độ dài chuỗi) | Mẫu đầy đủ (hình ảnh, lời nhắc, phản hồi) | VQADataset.__getitem__ |
| Trọng lượng | Chính số nguyên đó | Số lượng token (len(input_ids)) |
— |
| Knapsack | Batch số nguyên ≤ max_length |
Batch mẫu ≤ seq_length và giới hạn hình ảnh |
_balanced_greedy_knapsack |
| Chiến lược đóng gói | Tham lam hoặc Binpack | Đóng gói tham lam với token và ràng buộc hình ảnh | _balanced_greedy_knapsack |
| Nhà sản xuất-Người tiêu dùng | Nhà sản xuất lấp đầy hàng đợi | Giống như ví dụ đồ chơi, nhưng với các mẫu đa phương thức | _producer, __iter__ |
| Lọc mẫu | Bỏ qua số nguyên > max_length |
Bỏ qua các mẫu có quá nhiều token hoặc hình ảnh | _producer |
| Phân mảnh | Chia phạm vi số nguyên | Phân mảnh chỉ số tập dữ liệu | make_base_iterator() |
| Tạo Batch | Nhóm số nguyên | Nối và căn chỉnh token/hình ảnh | _pack_one_group |
| Đầu ra | Danh sách số nguyên | Từ điển với input_ids, labels, attention_mask, images |
yield từ __iter__ |
ConstantLengthDataset thực hiện tất cả:
- Đọc mẫu (hình ảnh và văn bản).
- Lọc ra các mẫu quá dài hoặc có quá nhiều hình ảnh.
- Đóng gói các mẫu vào batch bằng chiến lược knapsack tham lam, cân bằng số lượng token và số lượng hình ảnh.
- Padding các batch cuối cùng đến một độ dài cố định, nhưng với ít padding hơn nhiều so với trước đây.
Đây là kết quả:
Hãy nhìn vào đó! Màu xám (padding) là tối thiểu và các batch dày đặc với dữ liệu hữu ích. Nó giống như đóng gói một chiếc vali rất tốt đến nỗi bạn vẫn có thể kéo khóa nó lên mà không cần ngồi lên nó.
Hình ảnh có vẻ không trực quan ngay từ cái nhìn đầu tiên, nhưng hãy xem hình ảnh cạnh nhau với padding bị ràng buộc.
| Knapsack | Có ràng buộc |
|---|---|
Ở đây bạn sẽ nhận thấy rằng các mẫu trong knapsack được phân phối đồng đều hơn. Chúng tôi cũng không gặp phải vấn đề có ít mẫu hơn trong batch do lọc.
Kết luận
Những gì bắt đầu như một cuộc điều tra đơn giản “tại sao quá trình huấn luyện lại chậm như vậy?” đã dẫn đến việc suy nghĩ lại hoàn toàn về cách chúng tôi xử lý dữ liệu đa phương thức.
Chiến lược knapsack cân bằng cho quy trình dữ liệu đến từ bài báo Eagle 2: Building Post-Training Data Strategies from Scratch for Frontier Vision-Language Models từ NVIDIA.
Các bài học quan trọng:
- Padding mọi thứ theo chuỗi dài nhất là một cách tiếp cận tốt đầu tiên (nhưng lãng phí)
- Hãy nghĩ về việc tạo batch như một bài toán đóng gói
- Xem xét tất cả các ràng buộc của bạn (độ dài văn bản, bộ nhớ hình ảnh, v.v.)
- Kiểm tra với dữ liệu đồ chơi trước để xác nhận cách tiếp cận của bạn
Muốn đào sâu hơn? Kiểm tra:
Chúc bạn huấn luyện vui vẻ (và cầu mong GPU của bạn luôn bận rộn)!
Link bài viết gốc
- Tags:
- Ai
- July 8, 2025
- Huggingface.co