بناء أنبوب بيانات فعال لتحسين الأداء (50-60 حرف)
لقد قمت بكل ما يلزم - البيانات، النموذج، وإعداد GPU قوي. تضغط على "تشغيل" و... تنتظر. وتنتظر أكثر. GPUs الخاصة بك بالكاد تعمل بينما محفظتك تزداد خفة ساعة بعد ساعة. هل يبدو هذا مألوفًا؟ لقد مررنا بذلك. بعد بعض البحث في مشروع nanoVLM الخاص بنا، اكتشفنا أن الجاني الحقيقي لم يكن نموذجنا أو عتادنا، بل كانت أنبوب البيانات لدينا غير فعالة بشكل مذهل.
إليك ما وجدناه:
- GPUs غير نشطة: كان نموذجنا ينتظر حرفيًا ظهور البيانات.
- جحيم الحشو: كل دفعة كانت مليئة برموز حشو غير مفيدة لم تسهم في التدريب.
في هذا المنشور، سنبني أنبوب بيانات فعال في خمسة مراحل. في كل مرحلة، نضيف أو نزيل من الخطوة السابقة ونعلق على ما سار بشكل جيد وما لم يسير.
جدول المحتويات:
- المرحلة 0: المتطلبات الأساسية
- المرحلة 1: تصور مجموعة البيانات
- المرحلة 2: الحشو الساذج
- المرحلة 3: الحشو المقيد
- المرحلة 4: التعبئة بشكل أذكى باستخدام حقائب الظهر
- المرحلة 5: حقيبة الظهر للبيانات متعددة الوسائط
- الخاتمة
[المرحلة 0] التحضير
لتسهيل متابعة مهام إعداد البيانات، أنشأنا مستودعًا منفصلًا يركز فقط على أنبوب البيانات. نأمل أن يكون هذا أسهل بكثير للفهم من قراءة الكود بعد دمجه مع مستودع nanoVLM. بالإضافة إلى ذلك، قد يكون هذا مفيدًا لتأسيس أنابيب بيانات أخرى!
المستودع: https://github.com/ariG23498/mmdp
لمتابعة ذلك، كل ما تحتاجه هو استنساخ المستودع. يحتوي على مهام إعداد البيانات النهائية، ولكنه مصمم لعرض كل خطوة على طول الطريق.
$ git clone https://github.com/ariG23498/mmdp.git [المرحلة 1] تصور مجموعة البيانات
قبل تحسين أي شيء، نحتاج إلى فهم ما نعمل معه. تحتوي مجموعة البيانات متعددة الوسائط لدينا على صور، ونصوص، واستجابات.
$ uv run 01_check_dataset.py
التعرف على بيانات التدريب الخاصة بك أمر بالغ الأهمية للنجاح. يعرض البرنامج النصي السابق عينة عشوائية في كل مرة تقوم بتشغيله؛ قد ترغب في نسخ هذا المقتطف إلى دفتر ملاحظات وتشغيله عدة مرات للحصول على إحساس بالبيانات.
[المرحلة 2] الحشو الساذج
كانت محاولتنا الأولى للتدريب تستخدم النهج الواضح (والشائع جدًا):
- تجزئة كل شيء
- البحث عن أطول تسلسل في كل دفعة
- حشو كل شيء آخر ليتناسب
$ uv run 02_naive_pad_dataloader.py كانت النتائج مؤلمة. انظر إلى هذه التصوير:
هل ترى كل هذا الرمادي؟ هذا هو الحشو. إن GPU تعالج لا شيء بينما تدفع ثمن وقت الحوسبة. كنا نضيع حوالي 60% من دفعتنا على رموز فارغة.
[المرحلة 3] الحشو المقيد
كانت خطوتنا التالية بسيطة. نحدد طولًا أقصى عالميًا ونلتزم به. إذا كانت عينة طويلة جدًا، سنقوم بإسقاطها.
كما قد تلاحظ، تحتوي الدفعة الآن على عينة أقل. هذا بسبب عملية التصفية. ساعد ذلك، لكننا كنا لا نزال نحشو كل شيء إلى نفس الطول الثابت بغض النظر عن المحتوى الفعلي. أفضل من قبل، لكن لا يزال مضيعة.
[المرحلة 4]: التعبئة بشكل أذكى باستخدام حقائب الظهر
الآن نحن مستعدون لإعادة التفكير في التجميع تمامًا. الحشو هو العدو، ونحتاج إلى استراتيجية لتقليله مع زيادة كمية البيانات التي يمكننا وضعها في كل دفعة. أدخل مشكلة حقيبة الظهر، وهي كلاسيكية من علوم الكمبيوتر مثالية لذلك.
تخيل أنك تحزم حقيبة ظهر لنزهة. يمكن أن تحمل وزنًا معينًا فقط، وتريد أن تضغط فيها أكبر عدد ممكن من العناصر المفيدة. في حالتنا:
- حقيبة الظهر هي دفعة تدريب بحد أقصى من الرموز (
max_length). - كل عنصر هو تسلسل (زوج نصي-استجابة مجزأ)، ووزنه هو عدد الرموز.
- هدفنا هو تعبئة أكبر عدد ممكن من التسلسلات في الدفعة دون تجاوز حد الرموز، مما يقلل من المساحة المهدورة.
لاختبار هذه الفكرة، نبدأ بمجموعة بيانات بسيطة: مجرد قائمة من الأرقام من 1 إلى 25، كل منها يمثل طول تسلسل. هذا يسمح لنا بالتجربة دون تعقيد الصور والنصوص.
الانتقال إلى مجموعة بيانات قابلة للتكرار
معظم مجموعات بيانات PyTorch هي أسلوب خريطة (يمكن الوصول إليها باستخدام dataset[i]). ولكن للتجميع الديناميكي، نحتاج إلى شيء أكثر مرونة. لذلك، قمنا ببناء مجموعة بيانات قابلة للتكرار عن طريق فرع torch.utils.data.IterableDataset. هذا يسمح لنا بإنشاء دفعات على الطاير والتعامل مع حيل مثل تقسيم البيانات عبر عدة عمال:
def _get_data_range(self): worker_info = get_worker_info() if worker_info is None: # عامل واحد، ارجع إلى مجموعة البيانات بالكامل return self.start, self.end else: # عدة عمال، قسم تحميل البيانات 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 سحر المنتج-المستهلك
يمكن أن تكون تعبئة التسلسلات بطيئة، خاصة إذا كنا نقوم بالفرز أو الخلط. للحفاظ على الحركة، نستخدم نمط المنتج-المستهلك باستخدام قوائم انتظار بايثون:
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])]
هل لاحظت كيف تصبح الدفعات اللاحقة متفرقة؟ نحن نترك فجوات.
التعبئة باستخدام صناديق أكثر إحكامًا
دعنا نجرب نهجًا أكثر ذكاءً: التعبئة باستخدام صناديق (تحديدًا، أول ملائمة تناقصية):
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])]
هذه الدفعات أكثر تماسكًا، مع مساحة ضائعة أقل. إنه مثل لعبة تيتريس مع بياناتك، حيث يتم ملاءمة القطع بشكل محكم.
[المرحلة 5] الحقائب لبيانات متعددة الوسائط
الآن حان الوقت لتطبيق تعبئة الحقائب على مجموعة بياناتنا المتعددة الوسائط.
نعود إلى الصور، والمحفزات، والاستجابات، ونحتاج إلى تعبئتها بكفاءة مع احترام حدود الرموز و ميزانيات الصور. يتم إعداد ميزانية الصور بحيث تكون الصور لكل عينة متوازنة. نود تجنب الحالة التي تحتاج فيها وحدة معالجة الرسومات (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 | yield من __iter__
|
