मुख्य मजकूराकडे जा
OpenAI

३० जून, २०२६

इंजिनिअरिंग

कोर डंप एपिडेमियोलॉजी: 18 वर्षांचा बग दुरुस्त करणे

आमच्या डेटा इन्फ्रास्ट्रक्चरमधील गुंतागुंतीच्या क्रॅशचे निराकरण करण्यासाठी लोकसंख्या-स्तरीय विश्लेषणाचा वापर करणे.

लोड होत आहे...

OpenAI चे मॉडेल्स आणि एजंट्स, इन्फरन्सच्या वेळी संबंधित डेटा शोधण्यासाठी, स्केलेबल डेटा इन्फ्रास्ट्रक्चरवर अधिकाधिक अवलंबून असतात. यापैकी काही सेवा C++ मध्ये लिहिलेल्या आहेत, ज्यामुळे सिस्टीमवरील त्याचे लो-लेव्हल नियंत्रण आम्हाला कार्यक्षमता वाढवण्यास आणि मेमरीचा वापर कमी करण्यास मदत करते. जसजसे आम्ही स्केलिंग करतो, तसतसे हे कार्यक्षमतेचे फायदे महत्त्वाचे ठरतात, परंतु C++ मधील मेमरी सुरक्षिततेच्या अभावामुळे, बग्स चुकीच्या किंवा अस्तित्वात नसलेल्या मेमरी ॲड्रेसवर राईट करून क्रॅश होऊ शकतात.

काही महिन्यांपूर्वी आम्ही Rockset सर्व्हिसमध्ये काही क्रॅश पाहिले. ही सर्व्हिस आमच्या ChatGPT डेटा इन्फ्रास्ट्रक्चरचा एक खास भाग आहे, जी अनेक डेटा प्लगइन्ससाठी आणि संभाषणे शोधण्यासाठी अत्यंत महत्त्वाची आहे. या प्रत्येक क्रॅशमध्ये, एक सामान्य C++ फंक्शन पूर्ण होऊन एका चुकीच्या ॲड्रेसवर परत येत असल्याचे दिसत होते, ज्यामुळे कर्नल प्रोग्राम थांबवत होता, कारण इन्स्ट्रक्शन पॉइंटर आता कोडकडे निर्देश करत नव्हता. कधीकधी स्टॅक फ्रेममधील रिटर्न ॲड्रेसची जागा NULL असायची. कधीकधी स्टॅक पॉइंटर सीपीयू रजिस्टर स्वतःच ८ बाइट्सने चुकलेला दिसत होता, जणू काही सामान्य एक्झिक्युशनच्या मध्यातच %rsp ची किंमत कमी झाली होती. दोन्ही प्रकरणांमध्ये, प्रोग्राम परत आल्यावर क्रॅश होत होता.

ॲप्लिकेशन कोडमधील हे सामान्य बिघाडाचे प्रकार नाहीत. केवळ सेव्ह केलेल्या रिटर्न ॲड्रेसवर होणारे एखादे अनपेक्षित लेखन शक्य आहे, पण ते अत्यंत असंभव आहे. इनलाइन असेंब्ली, setcontext, किंवा longjmp (ज्यापैकी आम्ही काहीही वापरत नाही) यांचा वापर न करता %rsp ला 8 ने चुकीच्या स्थितीत आणणारा बग तर त्याहूनही विचित्र आहे, कारण कंपाईल्ड कोड तो रजिस्टर केवळ फंक्शनच्या प्रोलॉग आणि एपिलॉगमध्ये थेट समायोजित करतो. आम्ही (किंवा ChatGPT) विचार करू शकलेल्या प्रत्येक गृहितकाच्या विरोधात भक्कम पुरावे होते, त्यामुळे तो बग अशक्य वाटत होता.

आम्हाला जी एकच समस्या वाटली होती, ती प्रत्यक्षात दोन परस्पर-असंबंधित बग्स असल्याचे नंतर आढळून आले; योगायोगाने हे दोन्ही दोष एकाच वेळी समोर आले. पहिला दोष म्हणजे एका Azure होस्टवरील सायलेंट हार्डवेअर करप्शन, जिथे CPU गणिताची प्रक्रिया योग्यरित्या करत नव्हता. दुसरा दोष म्हणजे GNU libunwind मधील 18 वर्षे जुनी रेस कंडिशन जो मोठ्या प्रमाणावर वापरल्या जाणाऱ्या एका 'ओपन सोर्स लायब्ररी'मधील, कोणाच्याही लक्षात न आलेला दोष होता.

या पोस्टमध्ये, आम्ही एका साथरोग तज्ज्ञाप्रमाणे विचार करून आणि अपघातांच्या संपूर्ण समूहाबद्दल एक उच्च-गुणवत्तेचा डेटा सेट तयार करून, वरवर पाहता अनाकलनीय वाटणारे अपघात कसे ओळखले आणि दुरुस्त केले, याची कहाणी आहे.

डीबगिंगचा पहिला प्रयत्न: काही 'कोअर डम्प्स'ची काळजीपूर्वक तपासणी करणे

सर्वप्रथम, आपण Rockset बद्दल अधिक सविस्तर जाणून घेऊया. ही सर्च आणि 'रिअल-टाइम ॲनालिटिक्स'साठी वापरली जाणारी एक 'क्लाउड-नेटिव्ह' डेटा प्रणाली आहे; OpenAI मध्ये आम्ही 'सिंक कनेक्टर्स'सारख्या अनेक अंतर्गत कामांसाठी याचा वापर करतो (Rockset चे अधिग्रहण OpenAI ने 2024 मध्ये केले होते). 'वर्कस्पेस'च्या नॉलेज बेसचा अद्ययावत इंडेक्स राखण्यासाठी स्ट्रीमिंग अपडेट्स'चा वापर केला जातो, जेणेकरून प्रश्नांची उत्तरे देताना किंवा विविध कृती करताना ChatGPT संबंधित माहिती शोधू शकेल.

Rockset चा एक्झिक्युशन लेयर C++ मध्ये लिहिलेला आहे. C++ भाषा CPU ला निम्न-स्तरीय प्रवेश प्रदान करते, जे कार्यप्रदर्शन आणि कार्यक्षमतेसाठी चांगले आहे, परंतु याचा अर्थ असा आहे की ऍप्लिकेशन बगमुळे अवैध मेमरी ऍक्सेस आणि सेगफॉल्ट होऊ शकतात. याचा मागोवा घेण्यात मदत करण्यासाठी आम्ही क्रॅश झाल्यावर स्टॅक ट्रेस लॉग करण्यासाठी फॉलीचा घातक सिग्नल हँडलर वापरतो आणि नंतरच्या विश्लेषणासाठी आम्ही संबंधित कोर डंप (प्रोग्राम क्रॅश झाल्यानंतरच्या स्थितीचा स्नॅपशॉट) Azure ब्लॉब स्टोरेजवर अपलोड करतो. रॉकसेटच्या सर्व क्वेरी प्रोसेसिंग पाने प्रतिकृती बनविल्या जातात, ज्यामुळे क्रॅशचा क्लायंट प्रभाव कमी होतो. तथापि, प्रत्येक सेगफॉल्ट एका बगशी संबंधित आहे जो आमची विश्वासार्हता आणि गुणवत्ता उद्दिष्टे पूर्ण करण्यासाठी निश्चित करणे आवश्यक आहे.

आमचा सुरुवातीचा दृष्टिकोन या 'कोअर्स'कडे एका पारंपरिक 'डीबगिंग' समस्येप्रमाणे पाहण्याचा होता: काही 'कोअर डम्प्स'चे बारकाईने निरीक्षण करणे, गृहीतके मांडणे आणि ती एकेक करून खोडून काढणे.

बहुतेक क्रॅश DocumentTree::updateDocument या पद्धतीत (method) घडले. या क्रॅशच्या वेळी असे दिसून आले की, updateDocument ने 'X' या एखाद्या अज्ञात फंक्शनला कॉल केले होते; X कार्यरत असताना स्टॅक दूषित झाला आणि त्यानंतर 'X' अशा एका ॲड्रेसवर परतला जो एक्झिक्युटेबल कोड नव्हता. काही प्रकरणांमध्ये, X चा नुकताच पॉप झालेला फ्रेम वैध वाटत होता, फक्त त्याचा सेव्ह केलेला रिटर्न ॲड्रेस NULL होता. इतर प्रकरणांमध्ये स्टॅक पॉइंटरच चुकीचा वाटत होता, तरीही त्यानंतरचा वैध फ्रेम updateDocument चाच असल्याचे दिसत होते.

स्टॅक नेमका कधी दूषित होत होता हे आम्हाला माहीत नव्हते, ज्यामुळे शोधासाठी खूप मोठा वाव उरला होता. updateDocument ही एक मोठी पद्धत आहे जिच्यावर मोठ्या प्रमाणावर इनलाइनिंग केले जाते; त्यामुळे X साठी संभाव्य पर्यायांची संख्या प्रचंड होती.

ही आमच्या C++ कोडमधील एखादी बग होती का? कंपायलर किंवा लिंकेजशी संबंधित समस्या? आमच्या रनटाइम लायब्ररीजपैकी एखाद्यामधील दोष? सिग्नल डिलिव्हरी किंवा कॉन्टेक्स्ट स्विचिंगशी संबंधित लिनक्स कर्नलची त्रुटी? की त्याहूनही दुर्मिळ असे काही? जर ही एखादी अनपेक्षित राईट क्रिया होती, तर आमच्या ASAN स्टेजिंग एन्व्हायर्नमेंटमध्ये ती का पकडली गेली नाही?

समस्येच्या सर्व घटना शोधण्यासाठी आम्ही आमच्या ॲप्लिकेशन-स्तरीय लॉग्सचा वापर करण्याचा प्रयत्न केला; परंतु स्टॅक-करप्शन बग्स केवळ लॉग्सवरून वर्गीकृत करणे कठीण असते, कारण त्यातील स्टॅक ट्रेसेस स्वतःच दूषित किंवा अपूर्ण असतात. आम्हाला अशी कोणतीही लॉग क्वेरी तयार करता आली नाही जिच्यात फॉल्स पॉझिटिव्ह्ज आणि फॉल्स निगेटिव्ह्ज या दोन्ही त्रुटी नसतील. आम्ही अधिक 'कोर्स'ची प्रत्यक्ष तपासणी करून काही अतिरिक्त उदाहरणे शोधली, परंतु विश्वासार्ह डेटा संच मिळवण्यासाठी ती प्रक्रिया अत्यंत श्रमसाध्य ठरली.

तपासाच्या या टप्प्यावर, आम्ही 'हार्डवेअर बग'ची शक्यता (चुकीने) फेटाळून लावली; कारण आम्हाला विविध क्षेत्रांत आणि विविध प्रकारच्या हार्डवेअरवर सिस्टीम क्रॅश झाल्याचे दिसून आले होते, त्यामुळे आम्ही केवळ सॉफ्टवेअरशी संबंधित कारणांचाच शोध घेत होतो. काही दिवस आम्ही 'misaligned-%rsp' (स्टॅक पॉइंटरची चुकीची अलाइनमेंट) मुळे झालेल्या एका विशिष्ट क्रॅशचा अत्यंत सखोल अभ्यास केला आणि 'स्टॅक' व 'रजिस्टर'मधील माहितीचा वापर करून क्रॅशपूर्वीच्या घटनाक्रम पुन्हा उभा केला. यातून काही संभाव्य संकेत मिळाले, परंतु सर्व बग्सचे मूळ कारण एकच आहे या आमच्या सुरुवातीच्या निष्कर्षाचा आम्ही आग्रह धरल्यामुळे, या प्रक्रियेतून आम्हाला कोंडी फोडण्यास मदत झाली नाही.

स्टॅक कडून मिळणारे संकेत

आमच्या तपासातील निर्णायक टप्प्याकडे वळण्यापूर्वी, मुख्य फाइल्समधून आम्ही कोणत्या प्रकारची माहिती मिळवत होतो, हे स्पष्ट करणे महत्त्वाचे आहे.

Rockset हे -fno-omit-frame-pointer वापरून कंपाईल केले जाते, त्यामुळे सक्रिय स्टॅक फ्रेम नेहमी %rbp द्वारे ॲक्सेस करता येते आणि कॉलर हे फ्रेम पॉइंटर्सची एक लिंक्ड लिस्ट तयार करतात.

Linux x86_64 वर AMD64 System V ABI %rsp खालील 128 बाइट्स रेड झोन म्हणून राखून ठेवते. तो भाग 'युजरस्पेस' कोडसाठी उपलब्ध असतो आणि महत्त्वाचे म्हणजे, ABI कराराचा एक भाग म्हणून, सिग्नल पोहोचवताना कर्नल तो भाग ओव्हरराईट करणार नाही, असे वचन देते.

रिटर्ननंतरच्या क्रॅशच्या डीबगिंगमध्ये रेड झोन आमच्यासाठी महत्त्वाचा होता, कारण तो रिटर्नच्या आधीची काही माहिती जतन करून ठेवतो. जेव्हा SIGSEGV ट्रिगर होतो, तेव्हा फॉलीचा फेटल सिग्नल हँडलर क्रॅश होणाऱ्या थ्रेडच्या स्टॅकवर चालतो. ज्या स्टॅक फ्रेम्स आता सक्रिय नाहीत (कारण त्यांचे फंक्शन रिटर्न झाले आहे), त्या सिग्नल हँडलरद्वारे शेवटच्या 128 बाइट्स वगळता पूर्णपणे नष्ट केल्या जातात. म्हणूनच आपण असे म्हणू शकतो की, “X ची नुकतीच पॉप केलेली स्टॅक फ्रेम, NULL रिटर्न ॲड्रेस वगळता, वैध दिसत होती.” रेड झोन काही निष्क्रिय फ्रेम्स, किंवा कधीकधी फक्त एका निष्क्रिय फ्रेमचा शेवटचा भाग जतन करून ठेवतो.

सदोष स्टॅक फ्रेम्स दर्शवणारा स्टॅक डायग्राम, जो रिटर्न ॲड्रेस ओव्हरराइट करून क्रॅश घडवू शकतो.

आम्हाला एक चुकीच्या पद्धतीने संरेखित स्टॅक क्रॅश आढळला, ज्यामध्ये सामील असलेली सर्व फंक्शन्स खूप लहान होती. त्यामुळे आम्हाला दिसले की, एका तुलनेने सोप्या फंक्शनच्या अंमलबजावणीदरम्यान%rsp चुकीच्या पद्धतीने संरेखित झाले होते आणि त्यानंतर अधिक कॉल्स यशस्वी झाले होते. जेव्हा सक्रिय फंक्शनने शेवटी रिटर्न करण्याचा प्रयत्न केला, तेव्हाच प्रोग्राम क्रॅश झाला. त्यापैकी कोणत्याही कोड पाथमध्ये एक्सेप्शन्स, इनलाइन असेंब्ली, setcontext किंवा longjmp यांचा वापर केला नव्हता, त्यामुळे जर कोअरने सुचवल्याप्रमाणे स्टॅक पॉइंटर खरोखरच बदलला असेल, तर युझरस्पेस कोडमधील कोणताही संभाव्य बग या समस्येचे स्पष्टीकरण देऊ शकत नव्हता.

त्यामुळे आम्ही कर्नलच्या दिशेने वळलो.

Rockset बहुतेक प्रोग्रॅम्सपेक्षा सिग्नल्सचा अधिक आक्रमकपणे वापर करतो. क्वेरी एक्झिक्यूशन अनेक हलक्याफुलक्या टास्क्समध्ये विभागलेले असते, जे डेटाची देवाणघेवाण करतात. उच्च-QPS वर्कलोड्स कार्यक्षमतेने हाताळण्यासाठी हे महत्त्वाचे आहे, परंतु यामुळे प्रत्येक क्वेरीसाठी CPU अकाउंटिंग करणे अवघड होते, कारण अनेक क्वेरींचे काम एकाच थ्रेड पूलवर मल्टिप्लेक्स केले जाते.

आमच्या उपायाला आम्ही coarse_thread_cputime_clock म्हणतो, जे प्रत्येक टास्कच्या सीमेवर सॅम्पल घेण्यासाठी clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) चा पुरेसा कमी खर्चात अंदाज घेते. timer_create API चा वापर वेळेच्या ओघाच्या अनेक संकल्पनांवर आधारित नियतकालिक सिग्नल वितरणाचे वेळापत्रक तयार करण्यासाठी केला जाऊ शकतो, ज्यामध्ये CPU वेळेच्या संचयाचा समावेश आहे. आम्ही प्रत्येक काही मिलिसेकंद CPU वेळेनंतर एक सिग्नल (SIGUSR2) वितरित करण्यासाठी वेळापत्रक तयार करतो, ज्यावेळी सिग्नल हँडलर थ्रेड-लोकल व्हॅल्यू अपडेट करतो. जरी अनेक टास्क कार्यान्वित होत असताना कोअर्स क्लॉकची ही वाढ पाहत नसले तरी, सर्व डेल्टांची बेरीज केल्याने क्वेरीसाठी लागणाऱ्या वास्तविक CPU वेळेचा एक निःपक्षपाती अंदाज मिळतो.

आम्ही वारंवार सिग्नल्स पाठवत असल्यामुळे, कॉन्टेक्स्ट स्विचिंग किंवा सिग्नल डिलिव्हरीशी संबंधित एखादा दुर्मिळ कर्नल बग असण्याची शक्यता वाटली. आम्ही बग रिपोर्ट्स, कर्नल सोर्स कोड आणि अझूर-विशिष्ट कर्नल पॅचेस वाचण्यात वेळ घालवला. आम्ही स्ट्रेस टेस्ट्स करून पाहिल्या. आम्हाला संबंधित वाटणारे काहीही सापडले नाही.

तेव्हा आम्ही मागे हटून वेगळा मार्ग वापरायचे ठरवले.

डॉक्टर की महामारीतज्ज्ञ?

अशा प्रकारची समस्या डीबग करण्याचे दोन मुख्य मार्ग आहेत.

एक मार्ग म्हणजे एखाद्या डॉक्टराप्रमाणे काम करणे: एका रुग्णावर लक्ष केंद्रित करणे, अनेक चाचण्या करणे आणि सविस्तर पुराव्यांच्या आधारे एखाद्या विशिष्ट प्रकरणाचे निदान करण्याचा प्रयत्न करणे.

दुसरा मार्ग म्हणजे एखाद्या महामारीतज्ज्ञ वागणे: संपूर्ण लोकसंख्येचे निरीक्षण करा आणि विचारा की असे काही नमुने आहेत का जे एका प्रकरणामधून उघड होऊ शकत नाहीत. हा दोष एखाद्या विशिष्ट रिलीझपासून सुरू झाला का? त्याचा संबंध एखाद्या विशिष्ट हार्डवेअर SKU (विशिष्ट CPU आणि सर्व्हर मॉडेल), एखाद्या प्रदेशाशी किंवा एखाद्या कर्नल आवृत्तीशी आहे का? वरवर पाहता एकच सिंड्रोम वाटणाऱ्या गोष्टीमागे अनेक वेगवेगळे समूह लपलेले आहेत का?

आम्ही प्रामुख्याने 'डॉक्टरच्या भूमिकेत' होतो. लोकसंख्येविषयक उच्च-गुणवत्तेचा डेटा गोळा करणे आवश्यक आहे, हा निर्णय घेणे हा त्यातील एक महत्त्वाचा बदल होता.

डेटा साफ करणे

समस्येच्या सर्व घटना आपोआप शोधण्याचे आमचे यापूर्वीचे प्रयत्न अयशस्वी ठरले, कारण आम्ही लॉग्समध्ये मजकूर-आधारित शोध घेण्याचा प्रयत्न करत होतो. 'कोअर डम्प्स'मध्ये अधिक सविस्तर माहिती उपलब्ध असते, परंतु त्यांचे प्रत्यक्ष हाताने (मॅन्युअली) विश्लेषण करणे हे मोठ्या प्रमाणावरील कामासाठी व्यवहार्य नव्हते. त्यामुळे, कोअर डम्प्सचे आपोआप विश्लेषण करू शकेल अशी एक 'पाइपलाइन' विकसित करण्यासाठी प्रयत्न करण्याचे आम्ही ठरवले.

आम्ही ChatGPT कडून एक स्क्रिप्ट लिहून घेतली, जी प्रत्येक कोअर फाईलचा प्रीफिक्स डाउनलोड करायची, त्यातील रजिस्टर्स काढायची, लॉग्ज वापरून ज्ञात फॉल्स पॉझिटिव्ह्ज फिल्टर करायची, आणि क्रॅशला आपोआप रिटर्न-टू-नल, मिसअलाइन्ड-स्टॅक किंवा 'इतर' असे लेबल लावायची. त्यानंतर आम्ही ती स्क्रिप्ट मागील वर्षातील प्रत्येक प्रोडक्शन रॉकसेट कोअर डंपवर समांतरपणे चालवली.

हा वळणबिंदू होता.

एकदा आमच्याकडे स्वच्छ डेटा सेट आला की, सहसंबंध लगेच दिसून आले. ज्याला आम्ही एक विचित्र बग समजत होतो, ते प्रत्यक्षात क्रॅशचे दोन वेगवेगळे गट होते.

रिटर्न-टू-नल कोअर्स अनेक क्लस्टर्स आणि भौगोलिक क्षेत्रांमध्ये विखुरलेले होते. अलीकडच्या काळात त्यांची वारंवारता वाढली होती, परंतु त्यांच्या सुरुवातीची कोणतीही निश्चित तारीख किंवा पायाभूत सुविधांची स्पष्ट सीमा नव्हती.

मिसअलाईन्ड-स्टॅक मुळे होणारे क्रॅशेस पूर्णपणे वेगळे होते. ते सर्व एकाच विभागातून उद्भवले होते, त्यांची सुरुवात झाल्याची निश्चित तारीख होती आणि बऱ्याच काळापासून कार्यरत असलेल्या नोड्सवर ते कधीच घडले नाहीत. जरी यात अनेक Azure VMs (क्लाउडवर होस्ट केलेल्या व्हर्च्युअल मशीन्स) समाविष्ट होत्या, तरीही त्यातील पॅटर्नवरून असे वाटत होते की, एखादी खराब हार्डवेअर असलेली भौतिक मशीन समस्या निर्माण करत आहे आणि त्यावर जी कोणती VM कार्यरत असेल, तिला त्या समस्येचा सामना करावा लागत आहे.

कालावधीनुसार क्लस्टरनिहाय अपघातांच्या दरांचा 'डॉट प्लॉट' (बिंदू आलेख); यात प्रामुख्याने क्लस्टर 2, 3 आणि 6 मध्ये अपघात केंद्रित असल्याचे, तसेच कालावधीच्या अखेरीस क्लस्टर 1 मध्ये अपघातांच्या संख्येत अचानक वाढ झाल्याचे दिसून येते.

त्याच क्षणी आमच्या लक्षात आले की आम्ही मानसिकरित्या दोन त्रुटींची गल्लत करत होतो. आम्ही दोन्ही बग्समधील प्रति-उदाहरणे एकत्र मिसळत असल्यामुळे, आम्हाला एकही सुसंगत स्पष्टीकरण सापडत नव्हते.

बग #1: बॅड-होस्ट

कुबर्नेटीस नोड्स आणि टाइमस्टॅम्पची स्वच्छ यादी मिळाल्याने, आम्ही चुकीच्या पद्धतीने संरेखित केलेल्या स्टॅकमुळे होणारे क्रॅश एकाच फिजिकल होस्टपर्यंत शोधू शकलो, ज्याला डिनायलिस्ट (denylist) करणे सोपे होते.

अनेक आठवड्यांच्या स्ट्रेस टेस्टिंगनंतरही, आम्हाला नियंत्रित वातावरणात त्या होस्टवरील रजिस्टर करप्शनची पुनरावृत्ती करता आली नाही. तथापि, एकदा तो समस्याग्रस्त होस्ट सेवेतून काढून टाकल्यावर, मिसअलाइन्ड-स्टॅक क्रॅशेस नाहीसे झाले.

खराब होस्ट काढून टाकणे हा कायमस्वरूपी उपाय नाही, कारण त्यामुळे त्याच समस्येची पुनरावृत्ती टळत नाही. तथापि, आपण सॉफ्टवेअरमध्ये बदल करू शकतो, जेणेकरून तशीच समस्या पुन्हा उद्भवल्यास, ती सहजपणे शोधून हाताळता येईल. आम्ही आमच्या फेटल सिग्नल हँडलरमध्ये रजिस्टर स्टेटचा समावेश करून सुधारणा केली आहे, जेणेकरून आम्ही केवळ लॉग्समधूनच समस्येची पुनरावृत्ती शोधू शकू (कोअर डंपची आवश्यकता नाही). आम्ही कंट्रोल प्लेनमध्ये बदल केला आहे, जेणेकरून व्हीएम्स (VMs) सहसा रिसायकल करण्याऐवजी पुन्हा वापरले जातील, ज्यामुळे इन्फ्रास्ट्रक्चर स्टॅकच्या आमच्या स्तरावर खराब नोड शोधणे खूप सोपे होते. आम्ही आमच्या रनबुक्समध्ये (आणि आमच्या टीमच्या मानसिक मॉडेल्समध्ये) या शक्यतेचा समावेश करण्यासाठी ते अद्ययावत केले आहे.

बॅड-होस्ट क्रॅश वेगळे केल्यामुळे, उर्वरित रिटर्न-टू-नल कोअर्सबद्दल विचार करणे खूप सोपे झाले. पूर्वी आम्ही एक्सेप्शन अनवाइंडिंगला वगळले होते, कारण आम्हाला वाटले की आमच्याकडे प्रति-उदाहरणे आहेत: अशा कोड पाथमधील क्रॅश जिथे एक्सेप्शन्सचा निश्चितपणे वापर केला गेला नव्हता. पण ती सर्व प्रति-उदाहरणे हार्डवेअर-करप्शन क्लस्टरमधून आली होती.

ती बाब लक्षात घेऊन जेव्हा आम्ही उर्वरित कोअर्सची पुन्हा तपासणी केली, तेव्हा आम्हाला आढळले की हा निष्कर्ष अगदी उलट होता: ते सर्व क्रॅशेस एक्सेप्शन अनवाइंडिंग दरम्यान होत होते.

एक्सेप्शन अनवाइंडिंग हे नियंत्रणाचे एक गतिशील हस्तांतरण आहे

जेव्हा C++ एखादा अपवाद निर्माण करते, तेव्हा रनटाइमला हे शोधावे लागते की तो अपवाद कोणत्या 'कॅच ब्लॉक'मध्ये स्वीकारावा आणि त्यादरम्यान कोणते डिस्ट्रक्टरकिंवा क्लीनअप हँडलर' चालवावेत. कंपायलर हा मेटाडेटा तयार करतो, परंतु प्रत्यक्ष जुळणी रनटाइमवर गतिमानपणे होते.

एक्सेप्शन अनवाइंडिंग प्रत्यक्षात थ्रो चालवणाऱ्या फंक्शनद्वारे केले जात नाही, परंतु परिणामी संकलित कोडद्वारे कॉल केलेल्या हेल्पर फंक्शनद्वारे केले जाते. ते रनटाइम रूटीन स्टॅकचे परीक्षण करतात, स्टॅकवर आढळलेल्या फंक्शन्सबद्दल मेटाडेटा आणतात, क्लीनअप हँडलर आणि कॅच ब्लॉक्स डायनॅमिकपणे शोधतात आणि नंतर त्यातील एका स्थानावर नियंत्रण हस्तांतरित करतात. हस्तांतरण नियंत्रणामध्ये मध्यंतरी सर्व स्टॅक फ्रेम्स (हेल्पर फंक्शन्ससह) अनवाइंड करणे समाविष्ट आहे.

कार्यपद्धतीच्या दृष्टीने, हे सामान्य 'कॉल आणि रिटर्न' प्रक्रियेपेक्षा longjmp किंवा 'फायबर स्विच'च्या अधिक जवळचे आहे. यामध्ये 'कॅली-सेव्ह रजिस्टर्स' तसेच स्टॅक फ्रेम रजिस्टर्स %rbp आणि %rsp ची स्थिती पूर्ववत करणे आवश्यक असते.

आमची बायनरी अशा दोन लायब्ररीजशी लिंक होते ज्यांमध्ये C++ एक्सेप्शन अनवाइंडिंग करणाऱ्या फंक्शन्सची अंमलबजावणी समाविष्ट आहे: libgcc आणि GNU libunwind. डायनॅमिक लिंकरने GNU libunwind मधील डेफिनिशन्सची निवड केली. यामुळे आम्हाला आश्चर्य वाटले; 'सिम्बॉल व्हर्जनिंग'च्या नियमांमुळे libgcc ची अंमलबजावणी निवडली जाईल अशी आम्हाला अपेक्षा होती, परंतु चालू असलेल्या बायनरीजची तपासणी केल्यावर असे दिसून आले की तसे घडले नाही.

शेवटचे गृहितक मागे घेणे

या टप्प्यावर आमची कार्य-गृहितके बदलली; कारण, केवळ एकच बग आहे असे समजून आम्ही जे एक गृहितक मानले होते, ते आम्ही आता शिथिल केले.

कदाचित आपण एका सामान्य फंक्शनचे NULL मध्ये परत येणे पाहत नव्हतो. कदाचित आपण एक अनवाइंड ट्रान्सफर पाहत होतो—प्रभावीपणे setcontext-शैलीतील रजिस्टर रिस्टोअर—जिथे नियंत्रण हस्तांतरित होण्यापूर्वीच डेस्टिनेशन इन्स्ट्रक्शन पॉइंटर NULL झाला होता. दुसऱ्या शब्दांत सांगायचे तर, स्टॅकवरील चुकीच्या रिटर्न ॲड्रेस स्लॉटऐवजी, अनवाइंड लायब्ररीमधून आलेला चुकीचा डेटा.

यामुळे समस्या खूपच अरुंद झाली. किंवा GNU libunwind चुकीचे गंतव्य स्थितीची गणना करत होते, किंवा ती योग्य स्थितीची गणना करत होती, पण ती लागू होण्यापूर्वीच काहीतरी त्यात बिघाड करत होते.

आम्ही GNU libunwind चा स्रोत वाचला आणि आम्हाला असे आढळले की, ती लायब्ररी स्टॅकवर एक ucontext_t तयार करते, 'क्लीनअप हँडलर'च्या फ्रेमसाठी आवश्यक असलेली रजिस्टर स्थिती त्यात भरते आणि त्यानंतर त्या स्ट्रक्टचा (struct) पॉइंटर एका अंतर्गत असेंब्ली रूटीनकडे सोपवते: _Ux86_64_setcontext.

या टप्प्यावर आमच्याकडे सर्व तुकडे होते.

तयार केलेला ucontext_tहा त्या स्टॅक फ्रेम्सपैकी एका फ्रेममध्ये असतो, जीy _Ux86_64_setcontext, च्या अंमलबजावणीदरम्यान उंवाऊंड केली जाते. _Ux86_64_setcontext ने %rsp, बदलल्यानंतर त्या स्ट्रक्ट मधून माहिती वाचली होती का? कारण त्या क्षणी तो 'स्ट्रक्ट' सक्रिय स्टॅकचा भाग राहिलेला नसेल. अशा परिस्थितीत, सिग्नलच्या (उदा. वारंवार येणाऱ्या SIGUSR2 च्या) आगमनामुळे त्यातील डेटा पुसला जाण्याचा किंवा बदलला जाण्याचा धोका निर्माण होऊ शकतो.

बग #2: libunwind बग

उत्तर होय होते.

आम्ही वापरत असलेल्या GNU libunwind च्या आवृत्तीमधील _Ux86_64_setcontext च्या शेवटच्या सहा सूचना खालीलप्रमाणे आहेत; यामध्ये प्रामुख्याने अशा mov सूचनांचा समावेश आहे ज्या मेमरीमधून माहिती (डेटा) गंतव्य रजिस्टरमध्ये लोड करतात:

साधा मजकूर

1
74: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
2
75:
3
76: /* push the return address on the stack */
4
77: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
5
78: push %rcx
6
79:
7
80: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx
8
81: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi
9
82: retq

(%rdi हे स्टॅकवर अलोकेट केलेल्या ucontext_t कडे निर्देश करते आणि UC_MCONTEXT_* मॅक्रोजचा विस्तार केवळ त्या निश्चित ऑफसेटमध्ये होतो जिथे एखादा विशिष्ट रजिस्टर साठवलेला असतो.)

पहिली सूचना ही 'रेस विंडो'ची सुरुवात दर्शवते. ती %rsp ला अद्ययावत करून सक्रिय स्टॅकच्या नवीन तळाकडे निर्देशित करते. ही क्रिया होताच, %rdi ज्या 'स्ट्रक्ट'कडे निर्देश करत आहे, तो भाग सक्रिय स्टॅकचा (किंवा 'रेड झोन'चा) भाग राहत नाही आणि तो कर्नलसाठी 'ऑफ-लिमिट्स' (अगम्य किंवा प्रतिबंधित) राहत नाही.

साधारणपणे यामुळे समस्या उद्भवत नाहीत, परंतु जर एखादा सिग्नल अगदी योग्य (किंवा अयोग्य?) क्षणी आला, तर कर्नल %rsp-128 या ठिकाणी सिग्नल फ्रेम तयार करेल. यामुळे %rdi ज्या मेमरीला निर्देशित करते, ती मेमरी ओव्हरराईट होऊ शकते.

जर पुढील सूचना UC_MCONTEXT_GREGS_RIP(%rdi) वाचण्यापूर्वी तसे घडले, तर पुनर्स्थापित झालेला 'इन्स्ट्रक्शन पॉइंटर' दूषित होऊ शकतो. आमच्या बाबतीत, क्रॅश दरम्यान तो NULL झाला होता.

हाच बग आहे.

कोअर सामान्य खराब परतावा सारखे का दिसले

ही रचना आम्हाला गोंधळात टाकणाऱ्या एका निरीक्षणाचे स्पष्टीकरणही देते: 'फंक्शन X' च्या आधीच्या स्टॅक फ्रेममधील 'रिटर्न ॲड्रेस स्लॉट'मध्ये 'NULL' का होता.

setcontext सह सर्व रजिस्टर्स, %rdi सह, पूर्ववत करण्यासाठी लिहिलेले होते; म्हणून नियंत्रण हस्तांतरण च्या अंतिम क्षणी UC_MCONTEXT_GREGS_RIP(%rdi) वाचण्यासाठी तो रजिस्टर वापरू शकत नाही. त्याऐवजी तो मूल्य आधी वाचतो, स्टॅक वर ठेवा करतो, आणखी काही रजिस्टर्स पुनर्स्थापित करतो आणि मग retq साठवलेले मूल्य वाचण्यासाठी आणि नियंत्रण हस्तांतरित करण्यासाठी.

कोअरमध्ये जे “एखादे फंक्शन NULL वर परत आले” असे दिसत होते, ते प्रत्यक्षात “अनवाइंडरने स्टॅकवर एक टार्गेट रिटर्न ॲड्रेस तयार केला होता, परंतु ट्रान्सफर पूर्ण होण्यापूर्वीच तो टार्गेट दूषित झाला होता” असे होते. आम्ही असे गृहीत धरले होते की रिटर्न ॲड्रेस स्लॉटमधील दूषितीकरण हे इन-प्लेसच (जागेवरच) झाले असावे, कारण आम्हाला अशी कोणतीही जागा माहीत नव्हती जिथे मुद्दामहून रिटर्न ॲड्रेस स्लॉटमध्ये (दूषित होऊ शकणारा) डेटा लिहिला जात असे.

एका सूचनेपुरती मर्यादित रेस विंडो

या 'बग'मुळे निर्माण होणारी परिस्थिती हास्यास्पद वाटण्याचे कारण म्हणजे ती रेस विंडो अत्यंत अरुंद असणे. अशा प्रकारच्या 'रेस कंडिशन'मध्ये, बाह्य घटना (सिग्नल) ही दुसऱ्या थ्रेड'द्वारे केल्या जाणाऱ्या दोन पायऱ्यांच्या दरम्यान घडणे आवश्यक असते. त्या दोन पायऱ्या एकमेकांच्या जितक्या जवळ असतील, तितकी ही 'रेस कंडिशन' उद्भवण्याची शक्यता कमी असते.

या प्रकरणात, संवेदनशील कालावधी अक्षरशः केवळ एका 'इन्स्ट्रक्शन'इतकाच असतो!%rsp मध्ये बदल झाल्यानंतर आणि त्यानंतरची इन्स्ट्रक्शन %rsp लोड करण्यापूर्वीच्या काळातच सिग्नल मिळणे आवश्यक असते. आधुनिक 'सुपर-स्केलर आऊट-ऑफ-ऑर्डर' CPU वर अशा अनेक साध्या इन्स्ट्रक्शन्स एका सायकलमध्ये कार्यान्वित होऊ शकतात, त्यामुळे हा 'रेस विंडो'चा कालावधी साधारणपणे शंभर पिकोसेकंद इतका असतो.

जेव्हा आम्हाला ही शर्यत सापडली, तेव्हा आमची पहिली प्रतिक्रिया अशी होती की, निरीक्षित अपघातांच्या दराचे स्पष्टीकरण देण्यासाठी ती खूपच दुर्मिळ असली पाहिजे. संपूर्ण ताफ्यात आम्हाला दररोज डझनहून अधिक return-to-null अपघात दिसत होते. अपवादांच्या निवारणादरम्यानची एक-सूचना शर्यत खरोखरच यासाठी कारणीभूत असू शकते का?

आम्ही फर्माट अंदाजाचा आधार घेतला. जर असुरक्षित विंडो अंदाजे 101010^{-10} सेकंदांची असेल आणि SIGUSR2 प्रत्येक 10210^{-2} सेकंदांच्या CPU वेळेत पोहोचत असेल, तर प्रत्येक एक्सेप्शन क्लीनअप हँडलर किंवा कॅच ब्लॉकची रेस हरण्याची शक्यता अंदाजे 10810^{-8} असते.

Rockset आपल्या अंतर्गत इनजेस्ट बॅकप्रेसर यंत्रणेचा भाग म्हणून एक्सेप्शन्सचा वापर करते. एकच ओव्हरलोड झालेला होस्ट प्रति सेकंद सुमारे 10410^{4} एक्सेप्शन्स निर्माण करू शकतो. याचा अर्थ असा होतो की, बॅकप्रेसर वापरणाऱ्या होस्टच्या अपयशांमधील सरासरी वेळ 10410^{4} सेकंद आहे, म्हणजेच दर काही तासांनी एक क्रॅश होतो. फ्लीट स्तरावर, निरीक्षण केलेल्या क्रॅशच्या वारंवारतेचे स्पष्टीकरण देण्यासाठी हे प्रमाण पुरेसे आहे.

llibunwind मधील बग आताच का उद्भवला?

GNU libunwind मधील ही त्रुटी जुनी आहे — 18 वर्षांहून अधिक जुनी; C++ एक्सेप्शन अनवाइंडिंगला समर्थन देणाऱ्या पहिल्या x86_64 आवृत्तीमध्येच ती अस्तित्वात होती.

मग तो आता का दिसला?

क्रॅशचा दर हा साधारणपणे किती अपवाद (exceptions) फेकले जातात आणि किती सिग्नल्स दिले जातात याच्या प्रमाणात असतो. तसेच, सिग्नल हँडलर किती स्टॅक वापरतो यावरही तो अवलंबून असतो.

Rockset या तिन्ही बाबतीत वैशिष्ट्यपूर्ण आहे. ओव्हरलोड नियंत्रणाच्या सामान्य प्रक्रियेचा भाग म्हणून आम्ही मोठ्या प्रमाणात एक्सेप्शन्स निर्माण करतो; coarse_thread_cputime_clock मुळे आम्ही SIGUSR2 सिग्नल असामान्यपणे वारंवार पाठवतो; आणि या वर्षाच्या सुरुवातीला, एकत्रित (merged) सिग्नलची नोंद घेता यावी यासाठी timer_getoverrun ला कॉल समाविष्ट करून आम्ही SIGUSR2 हँडलरचा स्टॅक वापर वाढवला.

तो शेवटचा बदल महत्त्वाचा ठरला आहे असे दिसते. जर हँडलरने खूप कमी स्टॅक वापरला, तर तो जुन्या ucontext_t मेमरीपर्यंत पोहोचून ती ओव्हरराइट करणार नाही. त्या बदलापूर्वी, आम्हाला हे क्रॅश अजिबात दिसून आले नाहीत. बदलानंतर, बॅकप्रेसर यंत्रणेवर ताण आणणाऱ्या काही युझ केसेससाठी आम्ही लोड वाढवेपर्यंत हा दर कमीच राहिला.

दुसऱ्या शब्दांत सांगायचे तर, 'libunwind' मधील बग सुरुवातीपासूनच अस्तित्वात होता; परंतु एक्सेप्शन दर, सिग्नल दर आणि हँडलर स्टॅकचा वापर यांचा एकत्रित परिणाम अशा एका टप्प्यावर पोहोचला, जिथे तो दोष प्रत्यक्ष कामकाजादरम्यान दिसून येऊ लागला.

ही यंत्रणा त्या योगायोगाचेही स्पष्टीकरण देते की हार्डवेअर बग आणि लिबअनविंड बग दोन्ही बहुतेक DocumentTree::updateDocument मध्येच क्रॅश झाले. लिबअनविंडमुळे होणारे क्रॅश प्रामुख्याने याच मेथडकडे झुकलेले होते, कारण ज्या क्षणी आम्ही इनजेस्ट बॅकप्रेसर लागू करण्यासाठी एक्सेप्शन थ्रो करतो, त्या क्षणी ती नेहमीच सक्रिय असते. rsp-मिसअलाइनमेंट क्रॅशसाठी देखील याच मेथडची मोठ्या प्रमाणावर निवड झाली होती, कारण सदोष हार्डवेअर नोड हा अशा SKU चा होता जो आम्ही बल्क इनजेस्टसाठी वापरतो, आणि तो आपला बहुतेक CPU वेळ त्याच मेथडमध्ये घालवतो.

या समस्येवर आमचा तात्काळ उपाय म्हणजे 'GNU libunwind' ऐवजी 'libgcc' च्या 'उनविंदेर' चा वापर करणे हा होता. हा स्वतःच एक फायदेशीर निर्णय ठरला; कारण 'libgcc' च्या अंमलबजावणीमध्ये 'lock contention' (लॉकसाठी होणारी स्पर्धा) कमी करण्यासाठी बरीच सुधारणा करण्यात आली आहे, जी बाब मोठ्या आकाराच्या VMs पर्यंत विस्तार करताना अत्यंत महत्त्वाची ठरते.

आम्ही GNU libunwind मध्ये एक स्वयंपूर्ण रिप्रोड्युसर (समस्या पुन्हा निर्माण करणारा कोड) आणि एक दुरुस्ती(नवीन विंडोमध्ये उघडेल) देखील समाविष्ट केली, तसेच इतर 'अनवाइंडर्स'मध्ये अशीच समस्या नाही ना, याची खात्री केली.

लोकसंख्येच्या पातळीवरील निदानाची ताकद

डीबगिंगच्या या प्रवासामुळे आम्हाला डायनॅमिक लिंकिंग, DWARF अनवाइंड मेटाडेटा, लिनक्स सिग्नल डिलिव्हरी, System V ABI आणि C++ एक्सेप्शन यंत्रणा यांच्या विशिष्ट तपशीलांबद्दल खूप काही शिकायला मिळाले. पण या सर्वांतून मिळालेला मुख्य धडा त्यापेक्षाही अधिक साधा होता.

सर्वात महत्त्वाचा टप्पा म्हणजे असेंब्लीचे हुशारीने वाचन करणे किंवा तपशिलांचे सखोल ज्ञान असणे हा नव्हता. तो होता एक उच्च-गुणवत्तेचा डेटा सेट तयार करणे. या डेटा सेटच्या अनुपस्थितीत, आम्ही दोन भिन्न घटनांना एकच गोष्ट बनवत होतो आणि त्या गोंधळातून तर्काने मार्ग काढण्याचा प्रयत्न करत होतो. एकदा आमच्याकडे अचूक आणि संपूर्ण पॉप्युलेशन डेटा आला की, समस्येचे स्वरूप स्पष्ट झाले: एक क्रॅश पॉप्युलेशन एका खराब होस्टशी संबंधित होते, आणि दुसरे लिबअनविंडमधील एका रेसशी संबंधित होते. एकदा डेटा अधिक चांगला झाल्यावर, डीबगिंग सोपे झाले.

Rockset सारख्या पायाभूत प्रणालींसाठी ही बाब अत्यंत महत्त्वाची आहे. या तपासणीमुळे सखोल इन्स्ट्रुमेंटेशन, स्वयंचलित तपासणी प्रक्रिया आणि आमच्या कार्यान्वयीन साधनांमध्ये सतत सुधारणा करण्याबाबतची आमची कटिबद्धता अधिक दृढ झाली. विश्वसनीयता म्हणजे केवळ समस्या उद्भवल्यानंतर त्यातील बग दूर करणे नव्हे; तर अशी माहिती, कार्यप्रवाह आणि कौशल्ये विकसित करणे होय, ज्यामुळे अशक्य वाटणाऱ्या समस्यांचे रूपांतर अशा समस्यांमध्ये होते ज्यांचे निदान आणि निराकरण करणे शक्य असते.

लेखक

By Nathan Bronson आणि Member of Technical Staff