التكنولوجيا والويب

تحليل أداء نموذج PyTorch وتحسينه – الجزء الثالث | بواسطة حاييم راند

[ad_1]

كيفية تقليل أحداث “Cuda Memcpy Async” ولماذا يجب عليك الحذر من عمليات القناع المنطقي

حاييم رند
نحو علم البيانات
الصورة بواسطة برادن جارفيس على Unsplash

هذا هو الجزء الثالث من سلسلة المنشورات حول موضوع تحليل نماذج PyTorch وتحسينها باستخدام PyTorch Profiler و TensorBoard. كان هدفنا هو تسليط الضوء على مزايا تحديد الأداء وتحسينه من أعباء العمل التدريبية القائمة على وحدة معالجة الرسومات وتأثيرها المحتمل على سرعة التدريب وتكلفته. على وجه الخصوص ، نرغب في إثبات إمكانية الوصول إلى أدوات التنميط مثل PyTorch Profiler و TensorBoard لجميع مطوري ML. لست بحاجة إلى أن تكون خبيرًا في CUDA من أجل جني مكاسب مجدية في الأداء من تطبيق التقنيات التي نناقشها في منشوراتنا.

في أول مشاركة لنا أوضحنا كيف مختلفة الآراء من المكوّن الإضافي PyTorch Profiler TensorBoard يمكن استخدامه لتحديد مشكلات الأداء ومراجعة بعض التقنيات الشائعة لتسريع التدريب. في المنشور الثاني أظهرنا كيف أن المكوّن الإضافي TensorBoard عرض التتبع يمكن استخدامها لتحديد وقت نسخ الموترات من وحدة المعالجة المركزية إلى وحدة معالجة الرسومات ، والعكس. مثل هذه الحركة للبيانات – التي يمكن أن تسبب نقاط التزامن وتبطئ من سرعة التدريب إلى حد كبير – غالبًا ما تكون غير مقصودة ويمكن تجنبها بسهولة في بعض الأحيان. سيكون موضوع هذا المنشور هو المواقف التي نواجه فيها نقاط التزامن بين وحدة معالجة الرسومات ووحدة المعالجة المركزية لا المرتبطة بنسخ موتر. كما في حالة نسخ الموتر ، يمكن أن تتسبب هذه في ركود في خطوة التدريب الخاصة بك وإبطاء الوقت الإجمالي للتدريب بشكل كبير. سوف نوضح وجود مثل هذه الأحداث ، وكيف يمكن التعرف عليها باستخدام PyTorch Profiler والمكوّن الإضافي PyTorch Profiler TensorBoard عرض التتبع، ومزايا الأداء المحتملة لبناء نموذجك بطريقة تقلل أحداث المزامنة هذه.

كما في منشوراتنا السابقة ، سنحدد نموذج لعبة PyTorch ثم تكرارا لمحة عن أدائها وتحديد الاختناقات ومحاولة إصلاحها. سنجري تجاربنا على مثيل Amazon EC2 g5.2xlarge (يحتوي على NVIDIA A10G GPU و 8 vCPUs) وباستخدام صورة AWS PyTorch 2.0 Docker الرسمية. ضع في اعتبارك أن بعض السلوكيات التي نصفها قد تختلف بين إصدارات PyTorch.

في الكتل التالية ، نقدم نموذج لعبة PyTorch الذي ينفذ تجزئة دلالية على صورة إدخال 256 × 256 ، أي يأخذ صورة 256 × 256 RGB ويخرج خريطة 256 × 256 لملصقات “لكل بكسل” من فئة عشر فئات دلالية.

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim
import torch.profiler
import torch.utils.data
from torch import Tensor

class Net(nn.Module):
def __init__(self, num_hidden=10, num_classes=10):
super().__init__()
self.conv_in = nn.Conv2d(3, 10, 3, padding='same')
hidden = ()
for i in range(num_hidden):
hidden.append(nn.Conv2d(10, 10, 3, padding='same'))
hidden.append(nn.ReLU())

self.hidden = nn.Sequential(*hidden)
self.conv_out = nn.Conv2d(10, num_classes, 3, padding='same')

def forward(self, x):
x = F.relu(self.conv_in(x))
x = self.hidden(x)
x = self.conv_out(x)
return x

لتدريب نموذجنا ، سنستخدم خسارة الانتروبيا المعيارية مع بعض التعديلات:

  1. سنفترض أن التسميات المستهدفة تتضمن ملف يتجاهل تشير القيمة إلى وحدات البكسل التي نريد استبعادها من حساب الخسارة.
  2. سنفترض أن إحدى التسميات الدلالية تحدد وحدات بكسل معينة على أنها تنتمي إلى “خلفية” الصورة. نحدد وظيفة الخسارة لدينا للتعامل معها على أنها يتجاهل تسميات.
  3. سنقوم بتحديث أوزان النموذج الخاصة بنا فقط عندما نواجه دفعات ذات موترات مستهدفة تتضمن قيمتين فريدتين على الأقل.

بينما اخترنا هذه التعديلات لأغراض العرض التوضيحي الخاص بنا ، فإن هذه الأنواع من العمليات ليست غير شائعة ويمكن العثور عليها في العديد من نماذج PyTorch “القياسية”. نظرًا لأننا بالفعل “خبراء” في تحديد سمات الأداء ، فقد تقدمنا ​​بالفعل وقمنا بلف كل عملية من العمليات في وظيفة الخسارة الخاصة بنا باستخدام مدير سياق torch.profiler.record_function ، (كما هو موضح في مقالتنا الثانية).

class MaskedLoss(nn.Module):
def __init__(self, ignore_val=-1, num_classes=10):
super().__init__()
self.ignore_val = ignore_val
self.num_classes = num_classes
self.loss = torch.nn.CrossEntropyLoss()

def cross_entropy(self, pred: Tensor, target: Tensor) -> Tensor:

# create a boolean mask of valid labels
with torch.profiler.record_function('create mask'):
mask = target != self.ignore_val

# permute the logits in preparation for masking
with torch.profiler.record_function('permute'):
permuted_pred = torch.permute(pred, (0, 2, 3, 1))

# apply the boolean mask to the targets and logits
with torch.profiler.record_function('mask'):
masked_target = target(mask)
masked_pred = permuted_pred(mask.unsqueeze(-1).expand(-1, -1, -1,
self.num_classes))
masked_pred = masked_pred.reshape(-1, self.num_classes)

# calculate the cross-entropy loss
with torch.profiler.record_function('calc loss'):
loss = self.loss(masked_pred, masked_target)
return loss

def ignore_background(self, target: Tensor) -> Tensor:

# discover all indices where target label is "background"
with torch.profiler.record_function('non_zero'):
inds = torch.nonzero(target == self.num_classes - 1, as_tuple=True)

# reset all "background" labels to the ignore index
with torch.profiler.record_function('index assignment'):
target(inds) = self.ignore_val
return target

def forward(self, pred: Tensor, target: Tensor) -> Tensor:

# ignore background labels
target = self.ignore_background(target)

# retrieve a list of unique elements in target
with torch.profiler.record_function('unique'):
unique = torch.unique(target)

# check if the number of unique items pass the threshold
with torch.profiler.record_function('numel'):
ignore_loss = torch.numel(unique) < 2

# calculate the cross-entropy loss
loss = self.cross_entropy(pred, target)

# zero the loss in the case that the number of unique elements
# is below the threshold
if ignore_loss:
loss = 0. * loss

return loss

تبدو وظيفة الخسارة لدينا بريئة بما فيه الكفاية ، أليس كذلك؟ خطأ! كما سنرى أدناه ، تتضمن وظيفة الخسارة عددًا من العمليات التي تؤدي إلى أحداث مزامنة الجهاز المضيف التي تؤدي إلى إبطاء سرعة التدريب بشكل كبير – ولا يتضمن أي منها نسخ الموترات داخل وحدة معالجة الرسومات أو الخروج منها. كما في مقالنا السابق ، نتحداك لمحاولة تحديد ثلاث فرص لتحسين الأداء قبل القراءة.

لأغراض العرض التوضيحي الخاص بنا ، نستخدم صورًا تم إنشاؤها عشوائيًا وخرائط تسمية لكل بكسل ، على النحو المحدد أدناه.

from torch.utils.data import Dataset

# A dataset with random images and label maps
class FakeDataset(Dataset):
def __init__(self, num_classes=10):
super().__init__()
self.num_classes = num_classes
self.img_size = (256, 256)

def __len__(self):
return 1000000

def __getitem__(self, index):
rand_image = torch.randn((3)+self.img_size, dtype=torch.float32)
rand_label = torch.randint(low=-1, high=self.num_classes,
size=self.img_size)
return rand_image, rand_label

train_set = FakeDataset()
train_loader = torch.utils.data.DataLoader(train_set, batch_size=256,
shuffle=True, num_workers=8, pin_memory=True)

أخيرًا ، نحدد خطوة التدريب الخاصة بنا مع PyTorch Profiler الذي تم تكوينه حسب رغبتنا:

device = torch.device("cuda:0")
model = Net().cuda(device)
criterion = MaskedLoss().cuda(device)

optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
model.train()

# training loop wrapped with profiler object
with torch.profiler.profile(
schedule=torch.profiler.schedule(wait=1, warmup=4, active=3, repeat=1),
on_trace_ready=torch.profiler.tensorboard_trace_handler('/tmp/prof'),
record_shapes=True,
profile_memory=True,
with_stack=True
) as prof:
for step, data in enumerate(train_loader):
inputs = data(0).to(device=device, non_blocking=True)
labels = data(1).to(device=device, non_blocking=True)
if step >= (1 + 4 + 3) * 1:
break
outputs = model(inputs)
loss = criterion(outputs, labels)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
prof.step()

إذا كنت ستقوم بتشغيل هذا البرنامج النصي التدريبي بسذاجة ، فربما ترى استخدام GPU مرتفعًا (~ 90 ٪) ولا تعرف أن هناك أي خطأ فيه. فقط من خلال التنميط يمكننا تحديد الاختناقات الكامنة في الأداء والفرص المحتملة لتسريع التدريب. لذلك ، بدون مزيد من اللغط ، دعنا نرى كيف يعمل نموذجنا.

في هذا المنشور سوف نركز على عرض التتبع من المكوّن الإضافي PyTorch Profiler TensorBoard. يرجى الاطلاع على منشوراتنا السابقة للحصول على نصائح حول كيفية استخدام البعض الآخر الآراء بدعم من البرنامج المساعد.

في الصورة أدناه نعرض ملف عرض التتبع لخطوة تدريب واحدة من نموذج لعبتنا.

عرض تتبع للنموذج الأساسي (تم التقاطه بواسطة المؤلف)

يمكننا أن نرى بوضوح أن خطوتنا التدريبية الطويلة البالغة 1.3 ثانية هي بالكامل يسيطر عليها مشغل torch.nonzero في السطر الأول من وظيفة الخسارة لدينا. تظهر جميع العمليات الأخرى مجمعة معًا على جانبي الضخامة cudaMemcpyAsyn حدث. ما الذي يجري؟؟!! لماذا قد تتسبب مثل هذه العملية التي تبدو بريئة في إحداث مثل هذا القدر الهائل من البصر؟

ربما لا ينبغي أن نتفاجأ ، مثل توثيق torch.nonzero يفعل قم بتضمين الملاحظة التالية: “متى input موجود على CUDA ، torch.nonzero() يتسبب في مزامنة الجهاز المضيف “. تنشأ الحاجة إلى التزامن من حقيقة أنه ، على عكس عمليات PyTorch الشائعة الأخرى ، فإن حجم الموتر الذي يتم إرجاعه بواسطة المصباح. لا محددة مسبقا. لا تعرف وحدة المعالجة المركزية عدد العناصر غير الصفرية الموجودة في موتر الإدخال مسبقًا. يحتاج إلى انتظار حدث المزامنة من وحدة معالجة الرسومات من أجل إجراء تخصيص ذاكرة GPU المناسب وإعداد عمليات PyTorch اللاحقة بشكل مناسب.

لاحظ أن طول cudaMempyAsync لا يشير إلى مدى تعقيد torch.nonzero op ، ولكنه يعكس مقدار الوقت الذي تحتاجه وحدة المعالجة المركزية لانتظار وحدة معالجة الرسومات (GPU) لإنهاء جميع النواة السابقة التي أطلقتها وحدة المعالجة المركزية. على سبيل المثال ، هل يجب أن نجري مكالمة إضافية من torch.nonzero مباشرة بعد أول مكالمة لدينا ، ثم الثانية cudaMempyAsync قد يظهر الحدث أقصر بشكل ملحوظ من الأول نظرًا لأن وحدة المعالجة المركزية ووحدة معالجة الرسومات “متزامنتان” بالفعل. (ضع في اعتبارك أن هذا التفسير قادم من خبير غير تابع لـ CUDA ، لذا اجعله كما تريد …)

الآن بعد أن فهمنا مصدر عنق الزجاجة ، يصبح التحدي هو إيجاد تسلسل بديل للعمليات التي تؤدي نفس المنطق ولكن هذا يفعل لا تشغيل حدث مزامنة الجهاز المضيف. في حالة وظيفة الخسارة لدينا ، يمكننا بسهولة تحقيق ذلك باستخدام المشغل حيث يوجد عامل التشغيل كما هو موضح في كتلة الشفرة أدناه:

def ignore_background(self, target: Tensor) -> Tensor:
with torch.profiler.record_function('update background'):
target = torch.where(target==self.num_classes-1,
-1*torch.ones_like(target),target)
return target

في الصورة أدناه نعرض ملف عرض التتبع بعد هذا التغيير.

عرض التتبع بعد التحسين رقم 1 (تم التقاطه بواسطة المؤلف)

بينما نجحنا في إزالة cudaMempyAsync قادمًا من الشعلة. هنا تكون وثائق PyTorch أقل لطفًا ، ولكن بناءً على تجربتنا السابقة يمكننا أن نفترض أننا ، مرة أخرى ، نعاني من حدث مزامنة الجهاز المضيف بسبب استخدامنا لموترات ذات حجم غير محدد.

لا يمكن دائمًا استبدال المشغل الفريد من نوعه ببديل مكافئ. ومع ذلك ، في حالتنا لا نحتاج فعليًا إلى معرفة قيم الملصقات الفريدة ، نحتاج إلى معرفة فقط رقم من التسميات الفريدة. يمكن حساب ذلك من خلال تطبيق torch.sort op على بالارض هدف الموتر وحساب عدد الخطوات في دالة الخطوة الناتجة.

    def forward(self, pred: Tensor, target: Tensor) -> Tensor:

# ignore background labels
target = self.ignore_background(target)

# sort the list of labels
with torch.profiler.record_function('sort'):
sorted,_ = torch.sort(target.flatten())

# indentify the steps of the resultant step function
with torch.profiler.record_function('deriv'):
deriv = sorted(1:)-sorted(:-1)

# count the number of steps
with torch.profiler.record_function('count_nonzero'):
num_unique = torch.count_nonzero(deriv)+1

# calculate the cross-entropy loss
loss = self.cross_entropy(pred, target)

# zero the loss in the case that the number of unique elements
# is below the threshold
with torch.profiler.record_function('where'):
loss = torch.where(num_unique<2, 0.*loss, loss)

return loss

في الصورة أدناه نلتقط ملف عرض التتبع بعد التحسين الثاني لدينا:

عرض التتبع بعد التحسين رقم 2 (تم التقاطه بواسطة المؤلف)

مرة أخرى ، حللنا عنق زجاجة واحد فقط لنواجه مشكلة جديدة ، هذه المرة قادمة من روتين القناع المنطقي.

الإخفاء المنطقي هو روتين نستخدمه بشكل شائع لتقليل العدد الإجمالي لعمليات الآلة المطلوبة. في حالتنا ، كان هدفنا هو تقليل مقدار الحساب عن طريق إزالة وحدات البكسل “التجاهل” وقصر حساب الانتروبيا على وحدات البكسل ذات الأهمية. من الواضح أن هذا قد أدى إلى نتائج عكسية. كما هو الحال من قبل ، يؤدي تطبيق قناع منطقي إلى موتر بحجم غير محدد ، و cudaMempyAsync أنه يتسبب بشكل كبير في حجب أي من المدخرات الناتجة عن استبعاد وحدات البكسل “المتجاهلة”.

في حالتنا ، يعد إصلاح هذه المشكلة بسيطًا إلى حد ما حيث يحتوي PyTorch CrossEntropyLoss على خيار مدمج لإعداد تجاهل الفهرس.

class MaskedLoss(nn.Module):
def __init__(self, ignore_val=-1, num_classes=10):
super().__init__()
self.ignore_val = ignore_val
self.num_classes = num_classes
self.loss = torch.nn.CrossEntropyLoss(ignore_index=-1)

def cross_entropy(self, pred: Tensor, target: Tensor) -> Tensor:
with torch.profiler.record_function('calc loss'):
loss = self.loss(pred, target)
return loss

في الصورة أدناه نعرض النتيجة عرض التتبع:

عرض التتبع النهائي (تم التقاطه بواسطة المؤلف)

بقرة مقدسة!! انخفض وقت خطوتنا إلى 5.4 مللي ثانية. هذا 240 (!!) أسرع مما بدأنا به. من خلال التغيير ببساطة حول عدد قليل من استدعاءات الوظائف وبدون أي تعديل لمنطق وظيفة الخسارة ، تمكنا من تحسين أداء خطوة التدريب بشكل كبير.

ملاحظة مهمة: في مثال اللعبة الذي اخترناه ، الخطوات التي اتخذناها لتقليل العدد cudaMempyAsync الأحداث كان لها تأثير واضح على وقت خطوة التدريب. ومع ذلك ، قد تكون هناك مواقف حيث تؤدي نفس الأنواع من التغييرات إلى الإضرار بالأداء بدلاً من تحسينه. على سبيل المثال ، في حالة الإخفاء المنطقي ، إذا كان قناعنا متناثرًا للغاية وكانت الموترات الأصلية كبيرة للغاية ، فإن التوفير في الحساب من تطبيق القناع قد يفوق سعر مزامنة الجهاز المضيف. الأهم من ذلك ، يجب تقييم تأثير كل عملية تحسين على أساس كل حالة على حدة.

في هذا المنشور ، ركزنا على مشكلات الأداء في تطبيقات التدريب التي تسببها أحداث مزامنة الجهاز المضيف. لقد رأينا عدة أمثلة لمشغلي PyTorch الذين يطلقون مثل هذه الأحداث – الخاصية المشتركة لهم جميعًا هي أن مقاس من الموترات التي يخرجونها تعتمد على المدخلات. قد تواجه أيضًا أحداث مزامنة من مشغلين آخرين ، لم يتم تناولها في هذا المنشور. لقد أوضحنا كيف يمكن استخدام محللي الأداء مثل PyTorch Profiler والمكوِّن الإضافي TensorBoard المرتبط به لتحديد هذه الأنواع من الأحداث.

في حالة مثال لعبتنا ، تمكنا من إيجاد بدائل مكافئة للمشغلين الذين يعانون من مشاكل والذين يستخدمون موترات ذات حجم ثابت وتجنب الحاجة إلى أحداث التزامن. أدى ذلك إلى تحسن كبير في وقت التدريب. ومع ذلك ، من الناحية العملية ، قد تجد أنه من الصعب – بل من المستحيل – حل هذه الأنواع من الاختناقات. في بعض الأحيان ، قد يتطلب التغلب عليها إعادة تصميم أجزاء من نموذجك.

[ad_2]

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *

زر الذهاب إلى الأعلى