प्रोडक्शन में Rust: हमने अपना सिग्नल प्रोसेसर फिर से क्यों लिखा
हमने अपने सिग्नल प्रोसेसिंग इंजन को Go से Rust में दोबारा लिखा। यहाँ बताया गया है कि क्यों, हमने क्या सीखा, और थ्रूपुट में 4 गुना सुधार हमने कैसे हासिल किया।
छह महीने पहले, हमने अपने सिग्नल प्रोसेसिंग इंजन को — वह घटक जो कच्चे ब्राउज़र सिग्नलों को सामान्यीकृत, हैश-योग्य फ़ीचर वेक्टरों में बदलता है — Go से Rust में दोबारा लिखने का निर्णय लिया। यह निर्णय हमने हल्के में नहीं लिया। हमारा Go कार्यान्वयन काम करता था। वह परीक्षित था। वह तैनात था। लेकिन वह हमारी पाइपलाइन में बाधा बन गया था, और हमें थ्रूपुट में एक बड़ी छलांग वाला सुधार चाहिए था। यहाँ बताया गया है कि क्या हुआ।
हम Go से आगे क्यों बढ़ गए
हमारा सिग्नल प्रोसेसर गणनात्मक रूप से भारी काम करता है: JSON पेलोड को पार्स करना, 130+ सिग्नलों पर सामान्यीकरण फ़ंक्शन लागू करना, स्वामित्व वाले हैश की गणना करना, और पहचान हैश वेक्टर बनाना। Go में, यह काम CPU-बाउंड था, और Go का garbage collector पैमाने पर एक समस्या बन गया। हर सिग्नल प्रोसेसिंग चक्र मध्यवर्ती ऑब्जेक्ट आवंटित करता था — पार्स किए गए JSON नोड, सामान्यीकृत स्ट्रिंग मान, हैश बफ़र — जो GC दबाव पैदा करते थे।
30K इवेंट/सेकंड पर, हमारा Go सिग्नल प्रोसेसर हर कुछ सेकंड में 2-5ms के GC ठहराव प्रदर्शित करता था। ये ठहराव स्वीकार्य थे। 50K इवेंट/सेकंड पर, GC ठहराव बढ़कर 8-15ms हो गए और अधिक बार होने लगे। 80K इवेंट/सेकंड पर — Q3 के लिए हमारा अनुमानित भार — GC ठहराव के कारण p99 विलंबता हमारे SLA से अधिक हो जाती। हमें या तो अधिक सर्वर (महँगा) या एक अधिक कुशल कार्यान्वयन चाहिए था।
Rust क्यों
हमने तीन विकल्पों का मूल्यांकन किया: Go कार्यान्वयन को ऑप्टिमाइज़ करना (sync.Pool, arena allocation, GOGC ट्यूनिंग), C++ में दोबारा लिखना, और Rust में दोबारा लिखना। Go ऑप्टिमाइज़ेशन ने 30% सुधार दिया लेकिन GC समस्या को मूल रूप से हल नहीं किया। एक सुरक्षा-महत्वपूर्ण प्रणाली में मेमोरी सुरक्षा चिंताओं के कारण C++ को अस्वीकार कर दिया गया। Rust ने zero-cost abstractions, कोई garbage collector नहीं, और compile time पर लागू की गई मेमोरी सुरक्षा गारंटी की पेशकश की।
Rust ईकोसिस्टम में भी हमारी हर ज़रूरत के लिए परिपक्व लाइब्रेरी थीं: JSON पार्सिंग के लिए serde, उच्च-प्रदर्शन हैशिंग crates, और async I/O के लिए tokio। सीखने की चुनौती वास्तविक थी — हमारी टीम के पास गहरा Go अनुभव था लेकिन सीमित Rust अनुभव — लेकिन प्रदर्शन विशेषताएँ ठीक वही थीं जो हमें चाहिए थीं।
दोबारा लिखने की प्रक्रिया
हमने सिग्नल प्रोसेसर को एक स्टैंडअलोन सेवा के रूप में दोबारा लिखा जो हमारी बाकी पाइपलाइन के साथ gRPC के माध्यम से संवाद करती है। इससे हम इसे Go कार्यान्वयन के साथ-साथ तैनात कर सके और धीरे-धीरे ट्रैफ़िक स्थानांतरित कर सके। दोबारा लिखने में तीन इंजीनियरों को चार सप्ताह लगे — कोर कार्यान्वयन के लिए दो सप्ताह और परीक्षण, बेंचमार्किंग, और एज केस हैंडलिंग के लिए दो सप्ताह।
सबसे चुनौतीपूर्ण पहलू भाषा स्वयं नहीं था बल्कि Go कार्यान्वयन के साथ व्यवहारगत समानता सुनिश्चित करना था। हमने एक तुलना हार्नेस बनाया जो दोनों कार्यान्वयनों को समान इनपुट पर चलाता था और सत्यापित करता था कि वे समान आउटपुट उत्पन्न करते हैं। इस प्रक्रिया के दौरान हमने 14 सूक्ष्म भिन्नताएँ खोजीं — ज़्यादातर floating-point हैंडलिंग, Unicode सामान्यीकरण, और JSON पार्सिंग एज केसों से संबंधित।
प्रदर्शन परिणाम
Rust कार्यान्वयन सिग्नलों को Go के 3.2ms के मुकाबले औसतन 0.8ms में प्रोसेस करता है — 4 गुना सुधार। समान वर्कलोड के लिए मेमोरी उपयोग 2.1GB से घटकर 340MB हो गया। कोई GC ठहराव नहीं है क्योंकि कोई garbage collector नहीं है। समान थ्रूपुट पर CPU उपयोग 60% घट गया, जिसका अर्थ है कि प्रत्येक सर्वर 4 गुना अधिक ट्रैफ़िक संभालता है।
80K इवेंट/सेकंड पर, Rust कार्यान्वयन शून्य ठहराव के साथ 1.4ms का p99 प्रोसेसिंग समय बनाए रखता है। यह गुंजाइश का अर्थ है कि हमें निकट भविष्य में सिग्नल प्रोसेसिंग प्रदर्शन पर दोबारा विचार नहीं करना पड़ेगा। घटा हुआ CPU और मेमोरी उपयोग भी सीधे कम इंफ़्रास्ट्रक्चर लागत में तब्दील होता है — हमने 12 में से 8 सिग्नल प्रोसेसिंग सर्वर हटा दिए।
सीखे गए सबक
हमारे विशिष्ट मामले के लिए Rust में दोबारा लिखना सार्थक था — एक CPU-बाउंड, आवंटन-भारी, विलंबता-संवेदनशील वर्कलोड। हम अपनी HTTP ingestion परत या अपनी ClickHouse क्वेरी सेवा को Rust में दोबारा नहीं लिखेंगे, क्योंकि वे घटक I/O-बाउंड हैं और Go उन्हें कुशलता से संभालता है। सबक यह नहीं है कि "हर चीज़ को Rust में दोबारा लिखो" बल्कि "Rust का उपयोग वहाँ करो जहाँ इसके zero-cost abstractions और नियतात्मक प्रदर्शन सबसे अधिक मायने रखते हैं।"
सबसे बड़ा आश्चर्य यह था कि Rust कंपाइलर ने दोबारा लिखने के दौरान कितना कुछ पकड़ा। हमारे Go कार्यान्वयन में कई छिपे हुए बग — साझा बफ़रों पर race conditions, हैश गणना में integer overflow, और खराब इनपुट पर out-of-bounds पहुँच — Rust में compile-time त्रुटियों के रूप में पकड़े गए। कंपाइलर माँग करने वाला है, लेकिन वह शुद्धता में अपना मूल्य चुका देता है।
ईमानदारी से कहें तो, पहला सप्ताह कष्टदायक था। Sarah ने व्हाइटबोर्ड पर "borrow checker के साथ झगड़े" की गिनती रखी — टीम के गिनना बंद करने से पहले हम 47 तक पहुँच गए। लेकिन तीसरे सप्ताह तक, जो कोड कंपाइल होता था वह बस काम करता था। कोई रहस्यमय प्रोडक्शन पैनिक नहीं, भार के तहत कोई data race नहीं। हॉट पाथ पर किसी भी चीज़ के लिए वह अदला-बदली सार्थक है।