سریعترین الگوریتم برای ضرب ماتریس چیست؟
ضرب ماتریس یکی از اعمال پایه در بسیاری از الگوریتمهای آنالیز عددی محسوب میشود به همین دلیل در راستای بهبود زمان آن تلاشهای بسیاری انجام شدهاست. کاربردهای ضرب ماتریس در بسیاری از زمینههای مختلف همچون علم محاسبه، بازشناخت الگو ، پردازش تصویر ، کار با نرم افزارهای ۳ بعدی و حتی زمینههای به ظاهر بیربط مانند شمردن تعداد گشتها در یک گراف دیده میشود. الگوریتمهای بسیاری برای این کار روی سیستمهای رایانش موازی طراحی شده است که در آن چند هسته به صورت همزمان و موازی عملیات را انجام میدهند.
اگر از تعریف ضرب ماتریس به صورت مستقیم استفاده کنیم برای ضرب دو ماتریس n × n زمان n3 طول خواهد کشید که به صورت (O(n3 میتوانیم نمایش دهیم. الگوریتمهایی با زمان اجرای بهتری برای اینکار ارائه شدهاند. برای مثال در ده سال ۱۹۶۹ استراسن در این زمینه الگوریتمی بر پایه ماتریس های 2×2 ارائه داد اما بهطور کلی هنوز نمیدانیم بهترین الگوریتم برای این کار چیست. (در واقع پیچیدگی زمانی آن مشخص نیست)[۱]
طبق تعریف ضرب ماتریس میتوانیم یک الگوریتم برای این کار ارائه دهیم. به ازای ماتریس که یک ماتریس است و ماتریس که یک ماتریس است میخواهیم ماتریس که ماتریسی است را محسابه کنیم. طبق تعریف ضرب ماتریس بنابراین به ازای هر دوتایی میتوانیم با مقدار را محاسبه کنیم. یعنی در کل الگوریتمی با زمان اجرای خواهیم داشت. اگر فرض کنیم هر سه ماتریس هستند آنگاه الگوریتم خواهد بود.
1 Input: matrices A and B 2 Let C be a new matrix of the appropriate size 3 For i from 1 to n: 4 For j from 1 to p: 5 Let sum = 0 6 For k from 1 to m: 7 Set sum ← sum + Aik × Bkj 8 Set Cij ← sum 9 Return C
در الگوریتم بالا ترتیب حلقهها را میتوانیم جابهجا کنیم. اگر چه این جابهجایی در مدت زمان اجرای الگوریتم تأثیری نخواهد داشت اما این ترتیب در بحثهای مربوط به دسترسی حافظه (access pattern) و مسائل مربوط به استفاده از حافظه نهان پردازنده مهم است. مثلاً اینکه ماتریسها به ترتیب سطری یا ستونی (یا ترکیبی از این دو) ذخیره شوند در زمان حافظهٔ نهان پردازنده تأثیر گذار خواهد بود.
حتی اگر حالت بهینه را در نظر بگیریم که حافظهٔ کش شرکت پذیر کامل با سطر حافظهٔ بیتی باشد و ماتریسهای و به صورت سطری ذخیره شدهباشند، این الگوریتم بهینه نخواهد بود. هنگامی که از آنجایی که ماتریسها به صورت سطری ذخیره شدهاند هر پیمایش حلقهٔ داخلی در الگوریتم (یک پیمایش روی سطر ماتریس اول و ستون ماتریس دوم) یک خطای کش به هنگام دسترسی به خانهیهای ماتریس دوم به همراه خواهد داشت؛ و این به این معناست که الگوریتم در بدترین حالت حاوی خطای کش خواهد بود. امروزه -یعنی از سال ۲۰۱۰- خطای کشها به نسبت اعمال پردازنده تأثیر بیشتری روی زمان اجرا میگذارند بنابراین بهتر است با روشی این خطای کشها را کاهش دهیم.
برای حل این مشکل ماتریسها را به بلوکهایی از اردر تایی تقسیم میکنیم. با اینکار کل یک زیرجدول در حافظهٔ کش قرار میگیرد و این بلوکها در هم ضرب میشوند. ضرب هر بلوکی هیچ خطای کشی به همراه نخواهد داشت.[۲]
01 Input: matrices A and B 02 Let C be a new matrix of the appropriate size 03 Pick a tile size T = Θ(√M) 04 For I from 1 to n in steps of T: 05 For J from 1 to p in steps of T: 06 For K from 1 to m in steps of T: 07 Multiply AI:I+T, K:K+T and BK:K+T, J:J+T into CI:I+T, J:J+T, that is: 08 For i from I to min(I + T, n): 09 For j from J to min(J + T, p): 10 Let sum = 0 11 For k from K to min(K + T, m): 12 Set sum ← sum + Aik × Bkj 13 Set Cij ← Cij + sum 14 Return C
تعداد خطاهای کش در این مدل برابر خواهدبود. مخرج باعث میشود در پردازندههای جدید خطاهای کش در زمان اجرا تأثیر گذار نشوند و صرفاً تحلیل زمانی الگوریتم تأثیر گذار باشد.[۳]
حال سعی میکنیم روشی تقسیم و حل برای ضرب ماتریسها ارائه دهیم. ابتدا فرض کنید ماتریسهایمان هستند. در این روش مطابق زیر ماتریسها را به چهار بلوک تقسیم میکنیم که اندازهٔ آنها تقریباً برابرند.
اگر ماتریسهایمان اندازهشان توانی از دو باشد (یعنی ماتریسی با ابعاد ) میتوانیم این الگوریتم را به کار بگیریم. به صورت زیر دو ماتریس را در هم ضرب میکنیم.
این الگوریتم شامل ۸ ضرب ماتریسهای کوچکتر خواهد بود که بهصورت بازگشتی محاسبه میشود و پایهٔ آن ضرب اسکالر است. همچنین جمع کردن ماتریسها به صورتی که در بالا گفتهشده طول خواهد کشید.
توجه کنید اگر ماتریس ما ابعادش توانی از ۲ نبود باز هم میتوانیم این الگوریتم را به کار ببریم. کافیست با اضافه کردن سطرهایی تمام صفر و ستونهایی تمام صفر به پایین و راست ماتریس ابعادش را توانی از دو کنیم. با اضافه کردن آنها اندازهٔ ماتریس حداکثر ۴ برابر خواهد شد (تعداد ستونها و سطرها هر کدام ۲ برابر خواهند شد) بنابراین تفاوتی در تحلیل زمانی ایجاد نمیشود بهعلاوه افزودن سطر و ستون تمام صفر تأثیری در حاصلضرب نخواهدگذاشت.
با توجه به مطالب گفتهشده برای تحلیل زمانی آن میتوانیم رابطهٔ زیر را بنویسیم:
بر طبق قضیهی اصلی [۴] میتوانیم این الگوریتم را تحلیل زمانی کنیم. میدانیم بنابراین خواهد بود.
در نتیجه این الگوریتم با الگوریتم ابتداییای که بررسی کردیم تفاوتی از نظر زمانی ندارد.
این الگوریتم در عمل برای ماتریسهای غیر مربعی سریعتر عمل میکند. کافیست به جای تقسیم کردن هر دو ماتریس به چهار تکه یکی از ماتریسها را به دو تکهٔ برابر (یا تقریباً برابر) با تقسیم کردن سطرها یا ستونها تقسیم کنیم. در زیر میتوانید الگوریتم چنین کاری را ببینید:
Inputs: matrices A of size n × m, B of size m × p. Base case: if max(n, m, p) is below some threshold, use an unrolled version ofthe iterative algorithm. Recursive cases: If max(n, m, p) = n, split A horizontally: Else, If max(n, m, p) = p, split B vertically: Otherwise, max(n, m, p) = m. Split A vertically and B horizontally:
الگوریتم گفتهشده در این بخش تقسیمبندی را تا جایی میتواند ادامه دهد که کل ماتریس در حافظهٔ کش جا شوند و بنابراین از نظر تعداد خطاهای کش مانند روش تقسیمبندی بلوکی عمل میکند. با این تفاوت که در آن الگوریتم خود پیادهسازی الگوریتم با توجه به اندازهٔ کش پردازندهٔ هدف انجام میشود (پارامتر ای را باید در خود متن الگوریتم تعیین کنیم) در حالیکه این الگوریتم برای کشهای پویا با اندازههای مختلف بهینهتر عمل خواهد کرد.
تعداد خطاهای کش در این الگوریتم با خط حافظهٔ کش که هر خط بیت دارد به صورت زیر خواهد بود:
الگوریتمهایی وجود دارند که زمان اجرای بهتری از الگوریتمهای فوق دارند. اولین الگوریتم کشف شده که اینگونه بود الگوریتم استراسن بود که در سال ۱۹۶۹ توسط وولکر استراسن (Volker Strassen) کشف شد. این الگوریتم به «الگوریتم سریع ضرب ماتریس» نیز معروف است. این الگوریتم بر مبنای ضرب دو ماتریس با ۷ عملیات ضرب است که در عوض تعداد بیشتری جمع و عملیات ریاضی اینچنینی لازم دارد. با استفاده از این ایده بهصورت بازگشتی الگوریتمی از به ما میدهد. این الگوریتم پیچیدهاست و ضرایب ثابت آن در تحلیل زمانی به اندازهای زیاد است که تنها برای ماتریسهای بزرگ کارامدتر از الگوریتمهای قبلی عمل خواهد کرد.
سریعترین الگوریتم با الگوریتمی است که از تعمیم الگوریتم کوپراسمیت–وینوگارد بهدست آمده و از نظر زمانی میباشد. این الگوریتم توسط François Le Gall کشف شد و به قدری ضرایب زیادی دارد و سربار الگوریتم بالاست که تنها برای ماتریسهای بسیار بزرگی که هماکنون در علوم کامپیوتر کاربردی ندارند، کارامد خواهد بود.
با توجه به اینکه باید حداقل روی همهٔ اعضای دو ماتریس پیمایش انجام بشود کرانپایین برای الگوریتمهای ضرب ماتریس وجود دارد. راز (Raz) ثابت کرد که کران پایین نیز برای هر الگوریتم ضرب ماتریس نیز وجود دارد.[۵][۶][۷]
همچنین الگوریتم الگوریتم فریوالد یک الگوریتم احتمالی و مونت کارلو است که در چک میکند که آیا ضرب دو ماتریس برابر هست یا نه.