استفاده از جرم‌شناسی و کار کارآگاهی برای حل معماهای عملکرد جاوا اسکریپت

مقدمه

در سال‌های اخیر، سرعت برنامه‌های وب به‌طور قابل‌توجهی افزایش یافته است. اکنون بسیاری از برنامه‌ها به‌اندازه کافی سریع اجرا می‌شوند که شنیده‌ام برخی‌از توسعه‌دهندگان با صدای بلند می‌پرسند «آیا وب به‌اندازه کافی سریع است؟». شاید برای برخی برنامه‌ها این‌طور باشد، اما برای توسعه‌دهندگانی که روی برنامه‌های با عملکرد بالا کار می‌کنند، می‌دانیم که به‌اندازه کافی سریع نیست. باوجود پیشرفت‌های شگفت‌انگیز در فناوری ماشین مجازی جاوا اسکریپت، مطالعه‌ای جدید نشان داد که برنامه‌های Google بین ۵۰٪ تا ۷۰٪ از زمان خود را در V8 می‌گذرانند. برنامه شما زمان محدودی دارد، کاهش چرخه‌ها از یک سیستم به این معنی است که سیستم دیگری می‌تواند کارهای بیشتری انجام دهد. به‌یاد داشته باشید که برنامه‌هایی که با سرعت ۶۰ فریم در ثانیه اجرا می‌شوند، فقط ۱۶ میلی‌ثانیه برای هر فریم فرصت دارند، درغیراین‌صورت پرش رخ می‌دهد. برای آشنایی با بهینه‌سازی جاوا اسکریپت و نمایه برنامه‌های جاوا اسکریپت، داستان کارآگاهان عملکرد در تیم V8 را بخوانید که یک مشکل عملکرد مبهم را در Find Your Way to Oz ردیابی می‌کنند.

جلسه Google I/O ۲۰۱۳

این مطالب را در Google I/O 2013 ارائه کردم. ویدیو زیر را ببینید:

چرا عملکرد مهم است؟

چرخه‌های واحد پردازش مرکزی بازی مجموع صفر است. اگر کاری کنید که بخشی از سیستم از منابع کمتری استفاده کند، می‌توانید از منابع بیشتری در بخش دیگری استفاده کنید یا عملکرد کلی را روان‌تر کنید. اجرای سریع‌تر و انجام کارهای بیشتر اغلب اهداف رقابتی هستند. کاربران ویژگی‌های جدید را می‌خواهند و درعین‌حال انتظار دارند برنامه‌تان روان‌تر اجرا شود. ماشین‌های مجازی جاوا اسکریپت همچنان سریع‌تر می‌شوند، اما این دلیلی برای نادیده گرفتن مشکلات عملکردی که می‌توانید امروز برطرف کنید نیست، همان‌طور که بسیاری از توسعه‌دهندگان که با مشکلات عملکردی در برنامه‌های وب خود مواجه هستند، از قبل می‌دانند. در برنامه‌های هم‌زمان با نرخ فریم بالا، فشار برای عاری بودن از لرزش بسیار مهم است. Insomniac Games مطالعه‌ای انجام داد که نشان داد نرخ فریم ثابت و پایدار برای موفقیت بازی مهم است: «نرخ فریم ثابت همچنان نشانه‌ای از محصول حرفه‌ای و خوش‌ساخت است.» توسعه‌دهندگان وب توجه کنند.

حل مشکلات عملکرد

حل کردن مشکل عملکرد مثل حل کردن یک پرونده جنایی است. باید شواهد را به‌دقت بررسی کنید، علت‌های احتمالی را بررسی کنید، و راه‌حل‌های مختلف را امتحان کنید. درطول مسیر باید اندازه‌گیری‌هایتان را مستند کنید تا مطمئن شوید که واقعاً مشکل را برطرف کرده‌اید. تفاوت بسیار کمی بین این روش و نحوه حل پرونده توسط کارآگاهان جنایی وجود دارد. کارآگاهان شواهد را بررسی می‌کنند، از مظنونان بازجویی می‌کنند، و آزمایش‌هایی انجام می‌دهند به این امید که مدرک جرم را پیدا کنند.

V8 CSI: Oz

جادوگران شگفت‌انگیزی که Find Your Way to Oz را می‌ساختند، با یک مشکل عملکردی که خودشان نمی‌توانستند آن را حل کنند، به تیم V8 مراجعه کردند. گاهی Oz ثابت می‌ماند و باعث پرش تصویر می‌شد. توسعه‌دهندگان Oz بااستفاده از پانل خط زمان در Chrome DevTools بررسی‌های اولیه‌ای انجام داده بودند. با نگاه کردن به میزان استفاده از حافظه، با نمودار ترسناک دندان اره‌ای مواجه شدند. یک‌بار در ثانیه، زباله‌روب ۱۰ مگابایت زباله جمع‌آوری می‌کرد و مکث‌های زباله‌روبی با پرش تصویر مطابقت داشت. مشابه نماگرفت زیر از «خط زمان» در Chrome Devtools:

خط زمان Devtools

کارآگاهان V8، جیکوب و یانگ، پرونده را به‌دست گرفتند. آنچه اتفاق افتاد، رفت و برگشت طولانی بین Jakob و Yang از تیم V8 و تیم Oz بود. این مکالمه را به رویدادهای مهمی که به ردیابی این مشکل کمک کرد خلاصه کرده‌ام.

مدرک

اولین قدم جمع‌آوری و مطالعه شواهد اولیه است.

به چه نوع درخواستی نگاه می‌کنیم؟

نسخه نمایشی Oz یک برنامه سه‌بعدی تعاملی است. به همین دلیل، نسبت‌به توقف‌های ناشی از جمع‌آوری زباله بسیار حساس است. به‌یاد داشته باشید که یک برنامه تعاملی که با سرعت ۶۰ فریم در ثانیه اجرا می‌شود، ۱۶ میلی‌ثانیه فرصت دارد تا تمام کارهای جاوا اسکریپت را انجام دهد و باید بخشی از این زمان را برای Chrome باقی بگذارد تا فراخوانی‌های گرافیکی را پردازش کند و صفحه را ترسیم کند.

Oz محاسبات ریاضی زیادی روی مقادیر double انجام می‌دهد و به‌طور مکرر WebAudio و WebGL را فراخوانی می‌کند.

چه نوع مشکل عملکردی می‌بینیم؟

We are seeing pauses aka frame drops aka jank. این مکث‌ها با اجرای جمع‌آوری زباله مرتبط است.

آیا توسعه‌دهندگان روال‌های مطلوب را دنبال می‌کنند؟

بله، توسعه‌دهندگان Oz در تکنیک‌های بهینه‌سازی و عملکرد ماشین مجازی جاوا اسکریپت مهارت دارند. شایان ذکر است که توسعه‌دهندگان Oz از CoffeeScript به‌عنوان زبان منبع خود استفاده می‌کردند و کد جاوا اسکریپت را ازطریق کامپایلر CoffeeScript تولید می‌کردند. این امر باعث شد برخی‌از تحقیقات به‌دلیل قطع ارتباط بین کدی که توسط توسعه‌دهندگان Oz نوشته می‌شد و کدی که توسط V8 مصرف می‌شد، دشوارتر شود. Chrome DevTools اکنون از نگاشت‌های منبع پشتیبانی می‌کند که این کار را آسان‌تر می‌کند.

چرا زباله‌روب اجرا می‌شود؟

حافظه در جاوا اسکریپت به‌طور خودکار توسط VM برای توسعه‌دهنده مدیریت می‌شود. V8 از یک سیستم جمع‌آوری زباله مشترک استفاده می‌کند که در آن حافظه به دو (یا چند) نسل تقسیم می‌شود. نسل جوان اشیایی را نگه می‌دارد که اخیراً اختصاص داده شده‌اند. اگر شیء به‌اندازه کافی باقی بماند، به نسل قدیمی منتقل می‌شود.

نسل جوان با بسامد بسیار بالاتری نسبت به نسل قدیمی جمع‌آوری می‌شود. این امر عمدی است، زیرا جمع‌آوری نسل جوان بسیار ارزان‌تر است. اغلب می‌توان فرض کرد که مکث‌های مکرر GC به‌دلیل جمع‌آوری نسل جوان است.

در V8، فضای حافظه جوان به دو بلوک حافظه مجاور با اندازه مساوی تقسیم می‌شود. در هر زمان فقط یکی از این دو بلوک حافظه درحال استفاده است و به آن فضای مقصد گفته می‌شود. تا زمانی که حافظه باقی‌مانده در فضای مقصد وجود دارد، اختصاص دادن شیء جدید ارزان است. مکان‌نما در فضای مقصد به‌اندازه تعداد بایت‌های موردنیاز برای شیء جدید به جلو حرکت می‌کند. این کار تا زمانی ادامه می‌یابد که فضای مقصد تمام شود. در این مرحله، برنامه متوقف می‌شود و جمع‌آوری شروع می‌شود.

حافظه جوان V8

در این مرحله، فضای مبدأ و فضای مقصد جابه‌جا می‌شوند. آنچه فضای مقصد بود و اکنون فضای مبدأ است، از ابتدا تا انتها اسکن می‌شود و هر شیئی که هنوز زنده است در فضای مقصد کپی می‌شود یا به توده نسل قدیمی ارتقا می‌یابد. اگر جزئیات را می‌خواهید، پیشنهاد می‌کنم درباره الگوریتم چنی مطالعه کنید.

به‌طور شهودی باید بدانید که هربار که شیئی به‌طور ضمنی یا صریح (ازطریق فراخوانی به new،‏ []، یا {}) اختصاص داده می‌شود، برنامه شما به جمع‌آوری زباله و توقف وحشتناک برنامه نزدیک‌تر می‌شود.

آیا ۱۰ مگابایت در ثانیه زباله برای این برنامه انتظار می‌رود؟

به‌طور خلاصه، خیر. توسعه‌دهنده کاری انجام نمی‌دهد که انتظار ۱۰ مگابایت در ثانیه داده‌های بی‌ارزش را داشته باشد.

مظنونین

مرحله بعدی تحقیقات، تعیین مظنونان احتمالی و سپس کاهش تعداد آن‌ها است.

مظنون شماره ۱

فراخوانی جدید درطول قاب. به‌یاد داشته باشید که هر شیء اختصاص‌یافته شما را به مکث GC نزدیک‌تر می‌کند. برنامه‌هایی که با نرخ فریم بالا اجرا می‌شوند، به‌ویژه باید تلاش کنند تا تخصیص در هر فریم را به صفر برسانند. معمولاً این کار به یک سیستم بازیافت شیء خاص برنامه که با دقت طراحی شده باشد نیاز دارد. کارآگاهان V8 با تیم Oz تماس گرفتند و آن‌ها گفتند که تماس جدیدی برقرار نکرده‌اند. درواقع تیم Oz ازقبل به‌خوبی از این الزام آگاه بود و گفت «این مایه شرمساری است». این یکی را از فهرست خط بزنید.

مظنون شماره ۲

اصلاح «شکل» شیء خارج از سازنده. این اتفاق زمانی می‌افتد که ویژگی جدیدی به شیء خارج از سازنده اضافه شود. با این کار، کلاس پنهان جدیدی برای شیء ایجاد می‌شود. وقتی کد بهینه‌سازی‌شده این کلاس پنهان جدید را می‌بیند، یک deopt راه‌اندازی می‌شود، کد بهینه‌سازی‌نشده اجرا می‌شود تا زمانی که کد به‌عنوان داغ طبقه‌بندی شود و دوباره بهینه‌سازی شود. این ریزش بهینه‌سازی‌زدایی و بهینه‌سازی مجدد منجر به لرزش می‌شود اما ارتباط دقیقی با ایجاد بیش‌ازحد زباله ندارد. پس‌از بررسی دقیق کد، تأیید شد که شکل‌های شیء ثابت بودند، بنابراین مظنون شماره ۲ رد شد.

مظنون شماره ۳

حساب در کد بهینه‌سازی‌نشده. در کد بهینه‌سازی‌نشده، همه محاسبات منجر به اختصاص اشیاء واقعی می‌شود. برای مثال، این قطعه کد:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

نتیجه آن ایجاد ۵ شیء HeapNumber است. سه مورد اول برای متغیرهای a،‏ b، و c است. چهارمی برای مقدار ناشناس (a * b) است و پنجمی از #4 * c است؛ پنجمی درنهایت به point.x اختصاص داده می‌شود.

Oz هزاران مورد از این عملیات‌ها را در هر فریم انجام می‌دهد. اگر هرکدام از این محاسبات در توابعی رخ دهد که هرگز بهینه‌سازی نشده‌اند، ممکن است علت ایجاد زباله باشند. زیرا محاسبات در تخصیص بهینه‌نشده حتی برای نتایج موقت هم حافظه اختصاص می‌دهد.

مظنون شماره ۴

ذخیره کردن عدد با دقت مضاعف در یک ویژگی. باید یک شیء HeapNumber برای ذخیره کردن عدد ایجاد شود و خصوصیت تغییر کند تا به این شیء جدید اشاره کند. تغییر دادن ویژگی برای اشاره به HeapNumber زباله تولید نخواهد کرد. بااین‌حال، ممکن است تعداد زیادی عدد با دقت مضاعف به‌عنوان ویژگی‌های شیء ذخیره شده باشد. کد پر از دستوراتی مانند دستور زیر است:

sprite.position.x += 0.5 * (dt);

در کد بهینه‌شده، هر بار که به x یک مقدار تازه محاسبه‌شده اختصاص داده می‌شود، یک عبارت ظاهراً بی‌ضرر، یک شیء HeapNumber جدید به‌طور ضمنی اختصاص داده می‌شود که ما را به توقف جمع‌آوری زباله نزدیک‌تر می‌کند.

توجه داشته باشید که بااستفاده از آرایه تایپ‌شده (یا آرایه معمولی که فقط دارای مقادیر دوتایی است) می‌توانید به‌طور کامل از این مشکل خاص جلوگیری کنید زیرا فضای ذخیره‌سازی برای عدد با دقت مضاعف فقط یک‌بار اختصاص داده می‌شود و تغییر مکرر مقدار نیازی به اختصاص فضای ذخیره‌سازی جدید ندارد.

مظنون شماره ۴ یک احتمال است.

پزشکی قانونی

در این مرحله، کارآگاهان دو مظنون احتمالی دارند: ذخیره کردن اعداد پشته به‌عنوان ویژگی‌های شیء و محاسبات حسابی که در توابع بهینه‌نشده اتفاق می‌افتد. وقت آن بود که به آزمایشگاه بروند و به‌طور قطعی مشخص کنند کدام مظنون گناهکار است. توجه: در این بخش، از بازتولید مشکلی که در کد منبع واقعی Oz پیدا شده است استفاده خواهم کرد. این بازتولید چندین مرتبه کوچک‌تر از کد اصلی است، بنابراین استدلال درباره آن آسان‌تر است.

آزمایش شماره ۱

درحال بررسی مورد مشکوک شماره ۳ (محاسبه ریاضی در توابع بهینه‌سازی‌نشده). موتور جاوا اسکریپت V8 دارای یک سیستم ثبت گزارش داخلی است که می‌تواند بینش عالی درباره آنچه در پشت صحنه اتفاق می‌افتد ارائه دهد.

با Chrome که اصلاً اجرا نمی‌شود شروع کنید، Chrome را با پرچم‌ها راه‌اندازی کنید:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

و سپس خروج کامل از Chrome منجر به ایجاد فایل v8.log در دایرکتوری فعلی می‌شود.

برای تفسیر محتوای v8.log، باید همان نسخه v8 را که Chrome شما استفاده می‌کند بارگیری کنید (about:version را بررسی کنید) و آن را بسازید.

پس‌از ساخت موفقیت‌آمیز v8، می‌توانید گزارش را بااستفاده از پردازنده تیک پردازش کنید:

$ tools/linux-tick-processor /path/to/v8.log

(بسته به پلاتفرمتان، mac یا windows را جایگزین linux کنید.) (این ابزار باید از دایرکتوری منبع سطح بالا در v8 اجرا شود.)

پردازنده تیک جدولی نوشتاری از توابع جاوا اسکریپت را که بیشترین تیک را داشته‌اند نمایش می‌دهد:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

می‌توانید ببینید demo.js سه تابع داشت: opt،‏ unopt، و main. توابع بهینه‌شده یک ستاره (*) در کنار نامشان دارند. توجه داشته باشید که تابع opt بهینه‌سازی شده است و unopt بهینه‌سازی نشده است.

ابزار مهم دیگری در کیف ابزار کارآگاه V8، plot-timer-event است. می‌توان آن را به این صورت اجرا کرد:

$ tools/plot-timer-event /path/to/v8.log

پس‌از اجرا، فایل png با نام timer-events.png در دایرکتوری فعلی قرار می‌گیرد. با باز کردن آن، باید چیزی شبیه به این ببینید:

رویدادهای زمان‌سنج

به‌جز نمودار پایین، داده‌ها در ردیف‌ها نمایش داده می‌شوند. محور X زمان (میلی‌ثانیه) است. سمت چپ شامل برچسب‌هایی برای هر ردیف است:

محور Y رویدادهای زمان‌سنج

ردیف V8.Execute دارای خط عمودی سیاهی است که در هر تیک نمایه که V8 درحال اجرای کد جاوا اسکریپت بوده است روی آن کشیده شده است. V8.GCScavenger یک خط عمودی آبی دارد که در هر تیک نمایه که V8 درحال انجام مجموعه نسل جدید بود، روی آن کشیده شده است. به‌طور مشابه برای بقیه حالت‌های V8.

یکی از مهم‌ترین ردیف‌ها «نوع کد درحال اجرا» است. هرگاه کد بهینه‌شده درحال اجرا باشد، این نوار سبز خواهد بود و وقتی کد بهینه‌نشده درحال اجرا باشد، ترکیبی از قرمز و آبی خواهد بود. نماگرفت زیر انتقال از کد بهینه‌سازی‌شده به کد بهینه‌سازی‌نشده و سپس بازگشت به کد بهینه‌سازی‌شده را نشان می‌دهد:

نوع کد درحال اجرا

در حالت ایده‌آل، اما نه بلافاصله، این خط سبز ممتد خواهد شد. یعنی برنامه‌تان به حالت پایدار بهینه‌شده‌ای منتقل شده است. کد بهینه‌سازی‌نشده همیشه کندتر از کد بهینه‌سازی‌شده اجرا می‌شود.

اگر تا این حد پیش رفته‌اید، شایان ذکر است که می‌توانید با بازسازی کد برنامه‌تان بسیار سریع‌تر کار کنید تا بتواند در پوسته اشکال‌زدایی v8 اجرا شود: d8. استفاده از d8 با ابزارهای tick-processor و plot-timer-event زمان تکرار سریع‌تری به شما می‌دهد. یکی دیگر از عوارض جانبی استفاده از d8 این است که جداسازی مشکل واقعی آسان‌تر می‌شود و میزان نوفه موجود در داده‌ها کاهش می‌یابد.

با نگاه کردن به نمودار رویدادهای زمان‌سنج از کد منبع Oz، انتقال از کد بهینه‌شده به کد بهینه‌نشده نشان داده شد و درحین اجرای کد بهینه‌نشده، مجموعه‌های نسل جدید زیادی راه‌اندازی شدند، مشابه نماگرفت زیر (توجه داشته باشید که زمان در وسط حذف شده است):

نمودار رویدادهای زمان‌سنج

اگر از نزدیک نگاه کنید، می‌توانید ببینید که خطوط سیاهی که نشان می‌دهند V8 چه زمانی کد جاوا اسکریپت را اجرا می‌کند، دقیقاً در همان زمان‌های تیک نمایه به‌عنوان مجموعه‌های نسل جدید (خطوط آبی) وجود ندارند. این به‌وضوح نشان می‌دهد که وقتی زباله درحال جمع‌آوری است، اسکریپت موقتاً متوقف می‌شود.

با نگاه کردن به خروجی پردازنده تیک از کد منبع Oz، تابع برتر (updateSprites) بهینه‌سازی نشده بود. به عبارت دیگر، تابعی که برنامه بیشترین زمان را در آن صرف کرده است نیز بهینه‌سازی نشده بود. این موضوع به‌شدت نشان می‌دهد که مظنون شماره ۳ مقصر است. منبع updateSprites شامل حلقه‌هایی بود که شبیه به این‌ها بودند:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

با شناختی که از V8 داشتند، فوراً متوجه شدند که ساختار حلقه for-i-in گاهی توسط V8 بهینه‌سازی نمی‌شود. به‌عبارت دیگر، اگر تابعی حاوی ساختار حلقه for-i-in باشد، ممکن است بهینه‌سازی نشود. این یک مورد خاص درحال‌حاضر است و احتمالاً در آینده تغییر خواهد کرد، یعنی ممکن است V8 روزی این ساختار حلقه را بهینه‌سازی کند. ازآنجایی‌که ما کارآگاه V8 نیستیم و V8 را مثل کف دستمان نمی‌شناسیم، چطور می‌توانیم تعیین کنیم که چرا updateSprites بهینه‌سازی نشده است؟

آزمایش شماره ۲

درحال اجرای Chrome با این پرچم:

--js-flags="--trace-deopt --trace-opt-verbose"

گزارش پرجزئیاتی از داده‌های بهینه‌سازی و لغو بهینه‌سازی را نمایش می‌دهد. با جستجو در داده‌های updateSprites، موارد زیر را پیدا می‌کنیم:

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

درست همان‌طور که کارآگاهان فرض کرده بودند، ساختار حلقه for-i-in دلیل آن بود.

پرونده بسته شد

پس‌از کشف دلیل بهینه نبودن updateSprites، اصلاح آن ساده بود، کافی بود محاسبات را به تابع خودش منتقل کنیم، یعنی:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite بهینه‌سازی خواهد شد و درنتیجه تعداد اشیای HeapNumber بسیار کمتر خواهد شد و درنتیجه وقفه‌های GC کمتر خواهد شد. با انجام همان آزمایش‌ها با کد جدید، به‌راحتی می‌توانید این موضوع را تأیید کنید. خواننده دقیق متوجه خواهد شد که اعداد اعشاری با دقت مضاعف همچنان به‌عنوان ویژگی ذخیره می‌شوند. اگر نمایه‌سازی نشان دهد که ارزشش را دارد، تغییر موقعیت به آرایه‌ای از اعداد اعشاری با دقت مضاعف یا آرایه داده‌های نوع‌دار، تعداد اشیای ایجادشده را بیشتر کاهش می‌دهد.

مؤخره

توسعه‌دهندگان Oz به همین‌جا بسنده نکردند. آن‌ها بااستفاده از ابزارها و تکنیک‌هایی که کارآگاهان V8 با آن‌ها به‌اشتراک گذاشته بودند، توانستند چند تابع دیگر را که در جهنم بهینه‌زدایی گیر کرده بودند پیدا کنند و کد محاسباتی را به توابع برگ تبدیل کنند که بهینه‌سازی شده بودند و درنتیجه عملکرد بهتری داشتند.

دست‌به‌کار شوید و جرایم عملکردی را حل کنید!