iddqd, या unsafe Rust का सबसे कठिन प्रकार
(oxide.computer)- iddqd एक Rust map लाइब्रेरी है जो value से key उधार लेती है, और Oxide के Omicron control plane में disk और sled inventory जैसे बड़े records के in-memory index बनाए रखने के लिए इस्तेमाल होती है, जहाँ correctness बेहद महत्वपूर्ण है
- मानक
BTreeMapkey और value को अलग-अलग संग्रहीत करता है, इसलिए उन्हें साथ ले जाना असुविधाजनक हो सकता है या duplicate keys के mismatch होने का जोखिम रहता है, लेकिन IdOrdMap record के अंदर के field से key निकालकर lookup करता है - unsafe Rust एक escape hatch है जो ऐसे सुरक्षित programs को व्यक्त करने देता है जिन्हें compiler साबित नहीं कर सकता, और जब generic code user-provided trait implementations को call करता है तो उसे pathological safe Rust तक झेलना पड़ता है
iddqdमें mutable iteration इस invariant पर निर्भर करती है कि सारे index अलग हैं, इसलिए यह lifetime extension करती है; लेकिन pathological Ord implementation B-tree और item set को असंगत बना सकती थी, जिससे एक ही item के duplicate index बन जाते थे- fix में key और index दोनों की तुलना की जाती है, और failure पर user code को call किए बिना linear scan पर fallback किया जाता है; पर्याप्त भरोसा पाने के लिए Miri, model-based testing, panic fault injection, और LLM adversarial review को साथ में इस्तेमाल करना पड़ता है
वह समस्या जिसे iddqd हल करता है
iddqdएक Rust लाइब्रेरी है जो ऐसे map उपलब्ध कराती है जिनमें key value से उधार ली जाती है, और Oxide इसे Omicron control plane में व्यापक रूप से इस्तेमाल करता है- Omicron वह software है जो Oxide rack के केंद्र में compute और storage जैसे resources को provision करता है और rack को लगातार चालू रखता है; यदि
iddqdगलत व्यवहार करे, तो control plane ऐसे तरीकों से गलत काम कर सकता है जिनकी भविष्यवाणी करना और diagnosis करना कठिन हो - Rust standard library में
BTreeMap<Email, User>जैसी संरचना key यानी email को value से अलग रखती है - key और value को साथ पास करने के लिए
get_key_valueसे(email, user)लाना पड़ता है, और यदि fetch के समयUserRecordजैसी अलग struct बनाई जाए, तो कई record types होने पर यह संभालना कठिन हो जाता है - यदि email को
Userके अंदर भी duplicate किया जाए, तो map की key और value के भीतर का email अलग हो जाने का जोखिम बनता है IdOrdMapIdOrdItemtrait के जरिए record से key निकालता है, और key type कोtype Key<'a> = &'a Emailकी तरह value से उधार लिया जा सकता है- Oxide में यह pattern database query results जैसे बड़े records पर अच्छी तरह फिट हुआ, और पूरे control plane में बड़े records की indexing के लिए उपयोगी साबित हुआ
अतिरिक्त सुविधाएँ
iddqdकई fields से उधार ली गई complex keys को first-class रूप में support करता है, इसलिए dynamic dispatch जैसे workaround की जरूरत नहीं पड़तीBiHashMapऔरTriHashMapएक item को क्रमशः दो या तीन keys से index करते हैं, जिससे कई maps को हाथ से बनाए रखने वाला आम pattern टल जाता है- Serde implementation map की बजाय sequence के रूप में serialization करती है, ताकि JSON में non-string keys भी serialize हो सकें, और duplicate keys को reject किया जा सके
- backward compatibility के लिए map-shaped serialization भी support की जाती है
unsafe Rust कहाँ कठिन हो जाता है
- Rust में मूल जोखिम undefined behavior (UB) है; यदि safe code किसी भी तरह UB पैदा नहीं कर सकता तो वह sound है, और नहीं तो unsound
- safe Rust में compiler UB वाले programs को reject कर देता है, लेकिन static analysis की सीमाओं के कारण कुछ UB-रहित programs भी reject हो जाते हैं
unsafekeyword ऐसे programs को व्यक्त करने का escape hatch है, जहाँ लेखक soundness की जिम्मेदारी लेता है और compiler से trust माँगता है- unsafe code को जिन Rust नियमों का पालन करना होता है, उनमें data race निषिद्ध होना, uninitialized या freed memory को न पढ़ना, एक ही memory region पर कई
&mutalias न बनाना, और immutable data को modify न करना शामिल है - इन नियमों पर तर्क करना खासकर mutable aliasing की वजह से कठिन होता है, इसलिए आम तौर पर safe abstraction के पीछे unsafe को encapsulate करना पड़ता है
safe abstraction को सत्यापित करने के चरण
split_at_mutmutable slice को दो हिस्सों में बाँटने वाली एक safe method है, लेकिन safe Rust मात्र से इस तरह का split व्यक्त नहीं किया जा सकता, इसलिए unsafe चाहिएsplit_at_mutकी soundness इस तरह के आसपास के safe-code invariants पर निर्भर करती है: क्या उसे&mut [T]मिला, क्याmid <= slice.len()जाँचा गया, और क्या बाकी length सही तरह गिनी गईMyVec<T>काgetइस शर्त पर निर्भर करता है किlenसही है और index range के भीतर है; इस शर्त को उसी module की बाकी सारी methods को बनाए रखना होता है- जब generic unsafe code user-provided code को call करता है, तब कठिनाई सबसे अधिक होती है
- safe Rust code कितना भी pathological या adversarial लिखा गया हो, उसे unsafe code से UB नहीं करवाना चाहिए
ExactSizeIteratorकेlen()पर भरोसा करके capacity भर लिखने वालाcollect_exactजैसा code सामान्यतः unsound होता है, क्योंकि यदि iterator झूठी length लौटाए तो code unallocated memory में लिख देगाstd::io::Readभी ऐसा trait है जिसकी buggy implementation पढ़े गए bytes की संख्या गलत लौटा सकती है, और Rust RFC 2930 इसी समस्या पर चर्चा करता हैiddqdएक generic data structure है जो user-provided trait implementations को call करता है, इसलिए यह इसी सबसे कठिन श्रेणी में आता है
iddqd की आंतरिक संरचना
iddqditem listItemSetको slot index रखने वाली tables के साथ जोड़ता हैIdHashMap<T>ItemSet<T>औरItemIndexरखने वालीhashbrown::HashTableका उपयोग करता हैItemSet<T>मेंVec<ItemSlot<T>>होता है, औरItemSlot<T>या तोOccupied(T)होता है याVacant { next: ItemIndex }free_headहाल में मुक्त किए गएVacantslot की ओर इशारा करता है, या उस sentinel की ओर जो बताता है कि कोई free slot नहीं है;free_headऔरVacantslots मिलकर free chain बनाते हैं- नया item insert करते समय
free_headसे reusable slot देखा जाता है; यदि हो, तोVacantslot कोOccupiedसे overwrite किया जाता है औरfree_headको अगले मान पर बढ़ा दिया जाता है - यदि free slot न हो, तो अंत में नया
Occupiedslot push किया जाता है, फिर key ली जाती है, hash निकाला जाता है, और नया index hash table में दर्ज किया जाता है - removal उल्टी दिशा में होती है: पहले hash table में key से index खोजकर हटाया जाता है, फिर संबंधित
ItemSlotकोVacantबनाया जाता है और पुरानेfree_headकोnextसे जोड़ दिया जाता है IdOrdMaphash table की जगह B-tree index का उपयोग करता है, औरBiHashMapतथाTriHashMapक्रमशः दो और तीन hash tables रखते हैं
mutable iteration और lifetime extension
IdOrdMap::iter_mutkey order में items पर mutable iteration करता है- Rust iterator में लौटाए गए values iterator खुद को borrow नहीं करने चाहिए;
collectजैसे combinators iterator खत्म होने के बाद भीVec<&mut T>जैसे values छोड़ सकते हैं - इस व्यवहार के सुरक्षित होने के लिए iterator को वही value दो बार नहीं लौटानी चाहिए
- borrow checker सूची पर क्रमिक access तो देख सकता है, लेकिन यह नहीं जान सकता कि सारे index अलग हैं
iddqdstd::mem::transmute::<&mut T, &'a mut T>के जरिए lifetime extension का उपयोग करता है, और यह इस invariant पर निर्भर करता है किself.iterद्वारा लौटाए गए सारे index अलग हों
pathological Ord implementation से बना bug
- पाँच items वाले
IdOrdMapमें, जहाँ keys 0 से 4 तक क्रम में हैं, यदिEntry APIसे key 0 lookup की जाए, तोOccupiedEntryभीतर index 0 सहेज लेता है - इसके बाद यदि
KeyकीOrdimplementation बदलकर ऐसी कर दी जाए जो value की परवाह किए बिना हमेशाEqualलौटाए, तोOccupiedEntry::removeजब B-tree में फिर नीचे जाता है, तब वह केवल key comparison के आधार पर गलत item हटा सकता है - उदाहरण के लिए, यदि B-tree में पहले
(key = 2, index = 2)से तुलना हो, तोEqualकी वजह से वही entry हट जाएगी, औरOccupiedEntryके पास मौजूद index 0 के कारण item set में item 0 हट जाएगा - इस स्थिति में B-tree में index 0 बना रहता है, लेकिन item set का slot 0 vacant हो जाता है, और item 2 item set में रहते हुए भी B-tree pointer खो देता है
- बाद में
Ordफिर सामान्य व्यवहार करने लगे और key 1000 वाला item insert किया जाए, तोfree_headslot 0 को फिर से reuse करेगा - नतीजतन B-tree में एक ही slot की ओर इशारा करने वाले duplicate index बन जाते हैं, और
IterMutएक ही memory के लिए कई&mutreferences बना सकता है, जिससे unsoundness पैदा होती है
fix का तरीका और performance trade-off
- B-tree में नीचे जाते समय अब सिर्फ key नहीं, बल्कि index equality भी जाँची जाती है
- जब known index वाली key खोजी जाती है, तब
(key, index)दोनों की तुलना होती है; इसलिए pathologicalOrdअगरEqualलौटाए भी, तो(key = 2, index = 2)और target index 0 की तुलना index tie-breaker के कारणLessबनेगी - इस search के सफल होने के लिए stored index का खोजे जा रहे index से वास्तव में मेल खाना जरूरी है
- tie-breaker गलत item matching को रोकता है, लेकिन यह हमेशा सही item मिल ही जाएगा, इसकी गारंटी नहीं देता
- B-tree key के आधार पर sorted है, और tie-breaker index की तुलना करता है, इसलिए दोनों order आम तौर पर स्वतंत्र होते हैं
- यदि tree search विफल हो जाए, तो user code को call किए बिना B-tree में उस index को हटाने के लिए linear scan किया जाता है
- यह fallback removal operation को logarithmic time की जगह linear time बना देता है, लेकिन यह केवल buggy user code होने पर ही पहुँचा जा सकता है, इसलिए इसे स्वीकार्य trade-off माना गया
validation की परतें
- क्योंकि
iddqdको foundational data structure की तरह इस्तेमाल किया जाता है, इसलिए इसमें analytical review और कई प्रकार की empirical validation साथ में की जाती है - हर unsafe block और unsafe pattern का एक से तीन Rust लेखक और reviewer विश्लेषण करते हैं
- हर unsafe block के ऊपर
// SAFETY:comment में reasoning लिखी जाती है, और Clippy काundocumented_unsafe_blockslint comment की मौजूदगी जाँचता है - example-based tests map बनाकर operations चलाते हैं और फिर results जाँचते हैं;
iddqdमें internal unit tests और public API integration tests दोनों हैं - यही tests doctest के रूप में भी मौजूद हैं, इसलिए वे documentation का काम भी करते हैं
- pathological tests buggy
Ordऔर अन्य trait implementations प्रदान करते हैं - CI में regular tests और pathological tests दोनों को Miri के तहत चलाया जाता है ताकि कई प्रकार के UB का पता लगाया जा सके
- duplicate index नहीं होने चाहिए जैसे invariants को Miri के बाहर सामान्य test environment में भी जाँचा जा सकता है
model-based testing और fault injection
iddqdrandomized testing की दो परतें इस्तेमाल करता है: सही oracle से तुलना करने वाली model-based testing, और उसके ऊपर fault injection- model-based testing, या stateful property-based testing, किसी type instance पर random operation sequences चलाती है और result की तुलना ज्ञात रूप से सही oracle से करती है
iddqdएक अक्षम लेकिन साफ-साफ सहीNaiveMaporacle के आधार पर व्यापक model-based tests चलाता है- fault injection user code में bugs को random तरीके से डालने की तकनीक है;
iddqdमें panic safety खास तौर पर एक प्रभावी axis साबित हुई - यदि operation के बीच user code panic भी कर दे, तब भी map के invariants नहीं टूटने चाहिए
- हर map operation में random panic countdown जोड़ा जाता है, और हर user-code call पर countdown को 1 घटाया जाता है; 0 होने पर panic कराया जाता है, जिससे random panic-safety testing की जाती है
- इस तरीके ने
iddqdमें कई subtle bugs खोजे, जिनमें से कुछ unsoundness तक ले जाते थे - model-based tests हर operation के बाद no-duplicate-index जैसे internal invariants भी verify करते हैं
- model-based tests Miri के तहत चलाने के लिए बहुत धीमे हैं, इसलिए soundness और correctness जिन invariants पर निर्भर हैं, उन्हें अलग से जाँचा जाता है
LLM adversarial review और formal techniques
- current-generation frontier models ने map को corrupt करने वाली कई pathological user-code implementations खोज निकालीं
- एक notable case में LLM ने ऐसा path तैयार किया जिसमें custom allocator panic करता है और unwind के दौरान map invariants टूट जाते हैं
- यह मौजूदा panic-safety tests द्वारा कवर किए गए
Ordimplementation जैसे सामान्य user-code panics से अलग तरह की panic-safety समस्या थी - LLM plausible लेकिन गलत soundness claims भी बना सकता है, इसलिए Miri को oracle बनाकर red-green TDD एक रक्षा तंत्र बनता है
- soundness bug के मामले में पहले Miri के तहत UB दिखाने वाला test बनाया जाता है, फिर fix के बाद यही test पास होता है या नहीं, यह दोबारा जाँचा जाता है
Kaniजैसे model checker से map invariants साबित करने का तरीकाiddqdके लिए बहुत जटिल हो जाता है, क्योंकि आवश्यक proofs इतने बड़े हो जाते हैं कि tool उन्हें संभाल नहीं पाताCreusotयह साबित करने में मदद कर सकता है कि Rust code panic और अन्य errors से मुक्त है, लेकिन user code के panic करने या गलत व्यवहार करने पर भी जो invariants बने रहने चाहिए, उन्हें यह अभी साबित नहीं कर सकताNaiveMapiddqdके सबसे निकट specification की भूमिका निभाता है, और CI में model-based tests को हजारों बार चलाकर implementation के specification से मेल खाने पर उच्च भरोसा पाया जाता हैcargo-annealएक विकसित हो रहा tool है जो unsafe Rust के बगल में doc comments में Lean soundness proof रखने की सुविधा दे सकता है, इसलिए यह रुचि का विषय हैiddqdके पास स्पष्ट invariants और सीमित लेकिन तुच्छ न होने वाला scope है, इसलिए यह formal verification tools के benchmark candidate के रूप में उपयुक्त है
निष्कर्ष
- unsafe generic Rust अत्यंत कठिन है, क्योंकि हर invariant को बनाए रखने के लिए arbitrary trait implementations और adversarial implementations तक को ध्यान में रखना पड़ता है
iddqdका bug दिखाता है कि pathologicalOrdimplementation map को धोखा देकर एक ही memory पर mutable alias बना सकती है- कोई एक तकनीक सभी bugs नहीं पकड़ सकती, इसलिए इंसानी line-by-line unsafe reasoning, example-based tests, pathological tests, randomized tests, और frontier-model adversarial review को साथ में उपयोग किया जाता है
- Oxide का मानना है कि foundational infrastructure में इस स्तर की सख्ती उचित है
1 टिप्पणियां
Lobste.rs की राय
अगर मैं सही समझ रहा हूँ (मैं सफर में था इसलिए फोन पर देखा), तो यह wrapper type और
HashMap/BTreeMapकी जगहHashSet/BTreeSetइस्तेमाल करके हल होता हुआ लगता हैwrapper type बिल्कुल ज़रूरी नहीं है, लेकिन safety और आगे के maintenance को देखते हुए यह अच्छा विकल्प है
ज़रूरत सिर्फ ऑब्जेक्ट को लपेटने वाले 0-size wrapper की है, और उसमें ऐसा custom
PartialEq/Hashimplementation डालना है जो सिर्फ उन fields को देखे जिनमें दिलचस्पी है। पूरे value को बनाए बिना search करने के लिए value type के लिएAsRefimplement करने वाला दूसरा type बनाया जा सकता हैयह
unsafeके बिना मौजूदाHashSet/BTreeSetinterface को ज्यों का त्यों reuse करने का तरीका है। value/key type को wrap करने के बजाय, ऐसा व्यवहार उपलब्ध कराने वाला नयाHashSet/BTreeSetwrapper भी बनाया जा सकता हैइसमें
EntryAPI, mutable access/iteration जैसी चीज़ें भी हैं।iddqdके भीतर mutability को काफ़ी सावधानी से संभाला जाता है, और कुछ जगहों पर interior mutability की अनुमति देने के लिए atomic values का उपयोग किया जाता है। index indirection के बिना इसे संभालना काफ़ी कठिन होताअगर
iddqdका formal verification करना हो, तो शुरुआत में मैं शायद Kani जैसे model checker से यह साबित करने की कोशिश करता कि map अपने internal invariants को नहीं तोड़ता। लेकिन यह बात दिलचस्प लगी किiddqd, Kani के संभालने के लिए थोड़ा जटिल है, और ज़रूरी proofs इतने बड़े हो जाते हैं कि tool उन्हें व्यावहारिक रूप से संभाल नहीं पाताक्या आप इस हिस्से के बारे में थोड़ा और साझा कर सकते हैं? क्या आपका मतलब है कि algorithmic complexity worst case में degrade हो जाती है?
कुल मिलाकर यह formal methods का एक दिलचस्प case study है, और अच्छा लगा कि आपने इस हिस्से को छुआ। मौजूदा formal verification tools को देखकर, खासकर बड़े प्रोजेक्ट्स में उनकी success stories के आधार पर, कोई भोलेपन में सोच सकता है कि कम-से-कम data structure correctness जैसी चीज़ें तो साबित की जा सकती होंगी; लेकिन वास्तव में data structure के हिसाब से कठिनाई बहुत अलग-अलग होती है, और यह काम उन भाषाओं में भी व्यावहारिक रूप से आसान नहीं है जिन्हें Rust जैसी unrestricted aliasing वाली भाषाओं की तुलना में proof-friendly माना जाता है
थोड़ा off-topic है, लेकिन यह भी जानना चाहूँगा कि diagrams कैसे बनाए गए। क्या आपने कोई one-off script लिखी थी, या वे Oxide ब्लॉग/वेबसाइट design के हिसाब से specially crafted चीज़ें हैं? वे किसी general-purpose tool की output जैसी नहीं लगतीं
मैंने scope कम करने की भी कोशिश की। उदाहरण के लिए
hashbrownको सही मान लिया, लेकिन उससे भी बहुत फ़र्क नहीं पड़ा। उस बिंदु पर मैं लगभग हार मान चुका था। ठीक से काम करने वाले trait implementations के लिए मुझे काफ़ी भरोसा है किiddqdसही है, और formal methods में मेरी असली दिलचस्पी तो उस proof में थी जो मनमाने, गलत तरीके से काम करने वाले implementations के लिए भी लागू होहालाँकि मैं Kani का सबसे अच्छा उपयोगकर्ता नहीं हूँ। अगर आप या कोई और इसे आज़माए, तो मुझे सच में अच्छा लगेगा
diagrams के लिए पहले Mermaid में sketch बनाया गया, फिर शानदार designer Ben Leonard ने उसे हमारे color palette और theme के अनुसार हाथ से polish किया
किसी object को उसके अपने ही किसी field को key बनाकर index करने वाला field-based map pattern, C# में भी ऐसा है जिसे मैं हमेशा उपलब्ध देखना चाहूँगा
मैंने पहले खुद इसका एक simple version बनाने की कोशिश की थी, लेकिन interface बहुत साफ़ नहीं लग रहा था, इसलिए छोड़ दिया। यह लेख देखकर फिर से कोशिश करने का मन हो रहा है
cargo-nextestजैसे production codebase से लेकर personal projects तक हर तरह की जगहों पर इसका उपयोग कियासिर्फ एक field को key की तरह इस्तेमाल करना सबसे आम use case है, लेकिन
iddqdइतना flexible है कि field, subfield, या field से pure और सस्ते तरीके से compute होने वाले function के किसी भी combination को इस्तेमाल किया जा सकता है। उदाहरण के लिए https://docs.rs/iddqd/latest/iddqd/ मेंArtifactKeyखोज सकते हैं (अगर आप Rust syntax से परिचित नहीं हैं तो क्षमा करें)iddqddesign करते समय मेरे मन में यह बहुत मज़बूत विचार था कि users को Rust type system की पूरी ताकत इस्तेमाल करने में सक्षम होना चाहिए, चाहे library author के रूप में मुझे कितने भी workarounds क्यों न करने पड़ें। मैं सच में चाहता था किiddqd, users के लिए, खासकर मेरे सहकर्मियों के लिए, एक आनंददायक library बने