Үндсэн агуулга руу алгасах
OpenAI

2026 оны зургаадугаар сарын 30

Инженерчлэл

Санах ойн хуулбарын дүн шинжилгээ: 18 жилийн турш байсан

Өгөгдлийн дэд бүтцэд үүссэн төвөгтэй доголдлыг популяцийн түвшний шинжилгээгээр дебаг хийх нь

Ачаалж байна…

OpenAI-ийн загвар болон агентууд нь асуултад хариулах үед холбогдох өгөгдлийг хайх зорилгоор өргөтгөх боломжтой өгөгдлийн дэд бүтцэд түлхүү тулгуурладаг. Эдгээр үйлчилгээний зарим нь C++ хэл дээр бичигдсэн бөгөөд системийн доод түвшний хяналт нь гүйцэтгэлийг дээд зэргээр нэмэгдүүлж, санах ойн хэрэглээг багасгах боломж олгодог. Энэ нь гүйцэтгэлийн хувьд давуу талтай ч C++ хэлний санах ойн хамгаалалт сул тул буруу эсвэл байхгүй санах ой руу бичих үед доголдол үүсгэж, систем унах шалтгаан болдог.

Хэдэн сарын өмнө бид ChatGPT‑ийн өгөгдлийн дэд бүтцийн чухал хэсэг болох олон өгөгдлийн залгаас болон ярианы түүхийн хайлтад ашиглагддаг Rockset үйлчилгээний доторх зарим доголдлыг ажигласан. Эдгээр доголдол бүрд энгийн C++ функц хэвийн дууссан мэт харагдаж, дараа нь буруу хаяг руу буцаж, зааварлагч код руу заахаа больсноор цөм программыг зогсооход хүргэсэн. Зарим тохиолдолд стек хүрээ дэх буцах хаягийн талбар NULL байсан. Зарим үед стек заагч CPU-ийн регистр 8 байтаар зөрж, %rsp нь хэвийн ажиллагааны дундуур ямар нэгэн байдлаар буурсан мэт байдал ажиглагдсан. Хоёр тохиолдолд хоёуланд нь доголдол буцах үед гарсан байна.

Эдгээр нь программын кодын хэвийн алдааны горим биш юм. Зөвхөн хадгалагдсан буцах хаяг дээр буусан санамсаргүй бичилт байх боломжтой ч магадлалын хувьд бага. %rsp-ийг бидний ашигладаггүй шугаман угсралт, setcontext, эсвэл longjmp оролцолгүйгээр 8 байтаар буруу байрлуулсан алдаа бүр ч хачирхалтай бөгөөд учир нь хөрвүүлсэн код нь функцийн эхлэл болон төгсгөл хэсэгт шууд регистрийг тохируулдаг. Бидний эсвэл ChatGPT‑ийн бодсон бүх таамаглал бүрийн эсрэг хүчтэй нотолгоо байсан тул алдаа гарах боломжгүй мэт санагдаж байлаа.

Бид нэг асуудал гэж таамаглаж байсан зүйл эцэстээ нэгэн зэрэг санамсаргүй байдлаар илэрсэн хоорондоо холбоогүй хоёр алдаа байсан. Нэгдүгээрт, Azure-ийн нэг хост дээрх чимээгүй техник хангамжийн эвдрэл бөгөөд CPU тооцоолол буруу хийж байсан. Хоёрдугаарт, өргөн хэрэглэгддэг нээлттэй эхийн сан дахь анзаарагдаагүй алдаа болох GNU libunwind-ийн 18 жилийн турш байсан зэрэгцээ гүйцэтгэлийн үед үүсэх алдаа байв.

Энэхүү нийтлэл нь бид тархвар судлаач шиг сэтгэж, бүх гацалтыг хамарсан өндөр чанартай өгөгдлийн сан бүрдүүлснээр тайлбарлахын аргагүй мэт санагдах доголдлыг хэрхэн илрүүлж, зассан тухай түүх юм.

Дебаг хийх анхны оролдлого: цөөн хэдэн санах ойн хуулбарыг нягт шалгах

Эхлээд Rockset-ийн талаар илүү дэлгэрэнгүй авч үзье. Энэ нь хайлт болон бодит цагийн аналитикт зориулсан cloud-д суурилсан өгөгдлийн систем бөгөөд OpenAI-д синк холбогч зэрэг олон дотоод хэрэглээнд ашиглагддаг (Rockset-ийг OpenAI 2024 онд худалдан авсан). ChatGPT нь асуултад хариулах эсвэл үйлдэл гүйцэтгэх үед холбогдох мэдээллийг хайж олохын тулд ажлын талбарын мэдлэгийн сангийн хамгийн сүүлийн үеийн индексийг хадгалахын тулд урсгал шинэчлэлтийг ашигладаг.

Rockset-ийн гүйцэтгэлийн давхарга нь C++ хэл дээр бичигдсэн. C++ хэл нь CPU-д доод түвшний хандалт өгдөг тул гүйцэтгэл болон бүтээмжийн хувьд давуу талтай ч программын алдаа нь буруу санах ойн хандалт болон санах ойн сегментчлэлийн алдаа үүсгэх эрсдэлтэй байдаг. Эдгээрийг илрүүлэхийн тулд бид доголдол гарсан үед стекийн мөрийг бүртгэх зорилгоор folly-ийн ноцтой сигналын зохицуулагчийг ашиглаж, харгалзах санах ойн хуулбарыг (доголдол гарсан үеийн програмын төлөвийн агшин зураг) дараа шинжлэх зорилгоор Azure blob storage-д байршуулдаг. Rockset-ийн бүх асуулга боловсруулах навчнууд хуулбарлагдсан байдаг тул доголдлын хэрэглэгчид үзүүлэх нөлөөг багасдаг. Гэсэн хэдий ч санах ойн сегментчлэлийн алдаа бүр манай найдвартай байдал болон чанарын зорилтуудыг хангахын тулд заавал засах шаардлагатай алдааг илэрхийлдэг.

Бид анхны арга барилаараа эдгээр санах ойн хуулбарыг ердийн дебаг хийх асуудал мэтээр авч үзэж, цөөн хэдэн санах ойн хуулбарыг сайтар нягтлан шалгаж, таамаг дэвшүүлээд нэг нэгээр нь үгүйсгэх байдлаар ажиллаж байсан.

Ихэнх доголдол DocumentTree::updateDocument нэртэй функцэд гарсан. Эдгээр доголдлын үед updateDocument үл мэдэгдэх X функцийг дуудсан мэт харагдаж байсан бөгөөд X ажиллаж байх үед стек гэмтэж, дараа нь X нь гүйцэтгэх боломжгүй хаяг руу буцаж байсан. Зарим тохиолдолд X-ийн дөнгөж гарсан хүрээ хэвийн мэт харагдаж байсан ч хадгалсан буцах хаяг нь NULL байсан. Харин бусад тохиолдолд стек заагч буруу мэт байсан боловч дараагийн хүчинтэй хүрээ updateDocument хэвээр байсан.

Стек хэзээ эвдэрч эхэлснийг мэдээгүй байсан тул энэ нь асар том хайлтын орон зайг үүсгэсэн. updateDocument нь олон удаа доторлогоо хийгддэг том функц тул X байж болох нэр дэвшигчдийн тоо хэт их байсан.

Энэ нь манай C++ кодын алдаа байсан уу? Хөрвүүлэгч эсвэл холболтын асуудал байсан уу? Манай ажиллах үеийн сангийн аль нэгэнд нь асуудал байсан уу? Сигнал дамжуулах эсвэл контекст шилжүүлэхтэй холбоотой Linux цөмийн алдаа байсан уу? Бүр ч ховор зүйл үү? Хэрэв энэ санамсаргүй бичилт байсан бол яагаад манай ASAN үе шатны орчин үүнийг илрүүлээгүй вэ?

Бид асуудал гарсан бүх тохиолдлыг тодорхойлохын тулд программын түвшний бүртгэлүүдийг ашиглахыг оролдсон боловч бүртгэгдсэн стекийн мөрүүд эвдэрсэн эсвэл огт байхгүй байсан тул стстекийн гэмтлийн алдаануудыг зөвхөн бүртгэлд үндэслэн ангилахад хүндрэлтэй байсан. Бид хуурамч эерэг, хуурамч сөрөг бүртгэлийн хайлтын асуулга боловсруулж чадсангүй. Бид илүү олон санах ойн хуулбарыг гар аргаар шалгаж, нэмэлт жишээ олсон ч энэ үйл явц нь найдвартай өгөгдлийн багц бүрдүүлэхэд хэт их хөдөлмөр шаарддаг байсан.

Шинжилгээний энэ үе шатанд бид бид олон бүс нутаг болон олон төрлийн техник хангамжийн доголдол гарч байгааг харсан тул бид зөвхөн программ хангамжтай холбоотой шалтгааныг хайж, техник хангамжийн алдааг буруугаар үгүйсгэсэн. Хэдэн өдрийн турш бид нэг буруу байрлалтай-%rsp доголдлыг маш нарийвчлан шинжилж, стек болон регистрийн агуулгыг ашиглан доголдлоос өмнөх үйл явдлын дарааллыг сэргээн тогтоосон. Ингэснээр зарим боломжит сэжүүрийг олсон боловч бүх алдаа нэг ижил шалтгаантай гэсэн анхны дүгнэлтээ салаагүй тул энэ нь биднийг мухардлаас гаргаж чадсангүй.

Стекийн сэжүүрүүд

Шинжилгээний эргэлтийн цэгт хүрэхээс өмнө санах ойн файлуудаас ямар төрлийн мэдээлэл гаргаж байснаа тайлбарлах нь зүйтэй.

Rockset нь -fno-omit-frame-pointer ашиглан хөрвүүлэгддэг тул идэвхтэй стек хүрээнд %rbp-ээр дамжин байнга хандах боломжтой байдаг бөгөөд дуудагч нь хүрээний заагчдын холбоост жагсаалт үүсгэдэг.

Linux x86_64 дээр AMD64 System V ABI нь %rsp-оос доош 128 байтыг улаан бүс болгон нөөцөлдөг. Энэ бүс нь хэрэглэгчийн орон зайн кодод боломжтой бөгөөд хамгийн чухал нь ABI-ийн гэрээний дагуу сигнал дамжуулах үед Linux цөм энэ бүсийн агуулгыг өөрчлөхгүй байхыг баталгаажуулдаг.

Улаан бүс нь буцсаны дараах доголдлыг дебаг хийхэд гол үүрэг гүйцэтгэсэн бөгөөд энэ нь буцахаас өмнөх зарим мэдээллийг хадгалж үлддэг. SIGSEGV үүсэхэд folly-ийн ноцтой сигналын зохицуулагч доголдож буй урсгалын стек дээр ажилладаг. Функц нь буцаж идэвхгүй болсон стекийн хүрээнүүдийг сигналын зохицуулагч ажиллах явцдаа дарж бичдэг бөгөөд зөвхөн сүүлийн 128 байт нь хадгалагдан үлддэг. Тиймээс бид “X-ийн стекээс дөнгөж гарсан хүрээ нь NULL буцах хаягтай байснаас бусдаар хүчинтэй харагдаж байсан” зэргээр хэлэх боломжтой болдог. Улаан бүс нь идэвхгүй хүрээнүүдийн зарим хэсэг эсвэл заримдаа нэг идэвхгүй хүрээний зөвхөн сүүлийн хэсгийг хадгалдаг.

Эвдэрсэн stack frame-үүд return address-уудыг overwrite хийж, crash үүсгэж болохыг харуулсан stack diagram.

Оролцсон бүх функц нь маш жижиг байсан нэг буруу байрлалтай стекийн доголдлыг олсон. Энэ нь харьцангуй энгийн функцийг гүйцэтгэх явцад %rsp буруу байрлалд орсон бөгөөд үүний дараа илүү олон дуудлага амжилттай болсон болохыг харуулж байна. Идэвхтэй функц эцэст нь буцахыг оролдох үед л программд доголдол гарсан. Эдгээр кодын замуудын аль нь ч онцгой тохиолдол, шугаман угсралт, setcontext, эсвэл longjmp ашиглаагүй тул хэрэв стек заагч санах ойн харуулж байгаагаар үнэхээр өөрчлөгдсөн бол хэрэглэгчийн орон зайн код дахь ямар ч боломжит алдаа энэ асуудлыг тайлбарлаж чадахгүй байв.

Энэ нь биднийг цөм рүү чиглүүлсэн.

Rockset нь ихэнх программаас илүү идэвхтэйгээр сингал ашигладаг. Асуулгын гүйцэтгэлийг өгөгдөл солилцдог олон хөнгөн даалгаварт хуваадаг. Энэ нь өндөр QPS-тэй ачааллыг үр дүнтэй боловсруулахад чухал ч олон асуулгын ажил нэг урсгалын санд нэгтгэдэг тул асуулга бүрийн CPU-ийн бүртгэлийг төвөгтэй болгодог.

Бидний шийдэл бол coarse_thread_cputime_clock гэж нэрлэдэг зүйл бөгөөд clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)-ийг маш хямд өртгөөр ойролцоолон тооцоолж, даалгавар бүрийн зааг дээр дээж авах боломж олгодог. timer_create API-г CPU-ийн цагийн хуримтлал зэрэг цаг хугацааны хэд хэдэн ойлголт дээр үндэслэн тогтмол дохионы дамжуулалтыг төлөвлөх боломжтой. Бид CPU цагийн хэдэн миллисекунд тутамд SIGUSR2 сигнал дамжуулахаар төлөвлөж, тухайн үед сигналын зохицуулагч нь урсгалын орон нутгийн утгыг шинэчилдэг. Олон даалгавар гүйцэтгэж байх үед бүдүүн цагийн өсөлтийг шууд харахгүй ч бүх дельтануудыг нийлүүлснээр нэг асуулгад ногдох бодит CPU цагийн хазайлтаас ангид үнэлгээг гаргадаг.

Бид сигналыг ойр ойрхон дамжуулдаг тул контекст шилжүүлэх эсвэл сигнал дамжуулахтай холбоотой цөмийн ховор цөмийн алдаа байж болох юм гэж үзсэн. Бид алдааны тайлан, цөмийн эх код, Azure-д зориулсан цөмийн нөхөөснүүдийг уншиж судалж, стресс тестүүдийг туршиж үзсэн. Гэвч эдгээртэй холбоотой мэт харагдах ямар ч зүйл олж чадсангүй.

Тэр үед бид нэг алхам ухарч, өөр арга барил туршиж үзэхээр шийдсэн.

Эмч үү эсвэл тархвар судлаач уу?

Иймэрхүү асуудлыг дебаг хийх хоёр үндсэн арга байдаг.

Нэг нь эмчтэй төстэй байдлаар ажиллах: нэг өвчтөн дээр төвлөрч, олон шинжилгээ хийж, нарийвчилсан баримт нотолгоонд үндэслэн ганц тохиолдлыг оношлохыг оролдох.

Нөгөөх нь тархвар судлаач шиг ажиллах: нийт популяцийг бүхэлд нь харж, ганц тохиолдлоор илрэх боломжгүй хэв шинж байгаа эсэхийг шалгах. Алдаа тодорхой нэг гаргалтаас эхэлсэн үү? Нэг техник хангамжийн SKU (тухайн CPU ба серверийн загвар), нэг бүс нутаг, эсвэл нэг цөмийн хувилбартай уялдаж байна уу? Нэг хам шинж шиг харагдаж буй зүйлийн дотор олон өөр хэсэг нуугдаж байна уу?

Бид ихэвчлэн эмчийн горимд байсан. Гол өөрчлөлт нь өндөр чанартай популяцийн өгөгдөл цуглуулах шаардлагатай гэсэн шийдвэр байсан.

Өгөгдлийг цэвэрлэх нь

Бид бүртгэл дээр текст хайлт ашиглаж байсан тул асуудлын бүх тохиолдлыг автоматаар олох өмнөх оролдлогууд бүтэлгүйтсэн. Санах ойн хуулбар илүү их мэдээлэл агуулдаг боловч тэдгээрийг гараар шалгах нь даацгүй байсан. Тиймээс бид санах ойн хуулбарыг автоматаар шинжлэх боломжтой дамжуулах хоолой бүтээхэд хүчин чармайлт гаргахаар шийдсэн.

Бид ChatGPT‑ийг ашиглан нэг бичвэр бичүүлсэн бөгөөд тэр нь санах ойн файл бүрийн эхлэл хэсгийг татаж, регистрүүдийг гаргаж, бүртгэл ашиглан мэдэгдэж буй хуурамч эерэг үр дүнг шүүж, доголдлыг null руу буцах, буруу байрласан-стек, эсвэл бусад гэж автоматаар ангилдаг байсан. Дараа нь тэр бичвэрийг өмнөх жилийн Rockset санах ойн хуулбарын бүх үйлдвэрлэл дээр зэрэгцүүлэн ажиллуулсан.

Энэ нь бидний хувьд эргэлтийн цэг болсон.

Бид цэвэр өгөгдлийн багцтай болмогц хамааралууд шууд гарч ирэв. Бид нэг хачирхалтай алдаа гэж үзэж байсан зүйл үнэндээ хоёр өөр доголдлын популяци байсан.

Null рүү буцах цөмүүд олон хэсэг болон газарзүйн бүс нутгуудад тархсан байв. Тэдгээрийн давтамж сүүлийн үед нэмэгдсэн боловч тодорхой эхлэх огноо, цэвэр дэд бүтцийн хил хязгаар байгаагүй.

Misaligned-stack crash-үүд огт өөр харагдсан. Тэд бүгд нэг region-оос ирсэн, тод эхлэх огноотой, удаан ажиллаж байсан нод дээр хэзээ ч гараагүй. Хэд хэдэн Azure VM (cloud-д host хийсэн virtual machine) оролцсон ч pattern нь муу hardware-тэй нэг physical machine дээр буусан ямар ч VM-д асуудал үүсгэж буй мэт харагдсан.

Цаг хугацааны явц дахь cluster тус бүрийн crash rate-ийн dot plot; ихэнх crash cluster 2, 3, 6-д төвлөрч, хугацааны төгсгөл орчим cluster 1-д огцом өсөлт байгааг харуулна.

Тэр мөчид бид хоёр алдааг сэтгэлдээ нэгтгэн хутгаж байснаа ойлгосон. Хоёр алдааны counterexample-уудыг хольж байсан тул бид нэг coherent тайлбар олж чадахгүй байжээ.

Алдаа #1: муу host

Kubernetes нод болон timestamp-ийн цэвэр жагсаалттай болсноор бид misaligned-stack crash-үүдийг нэг physical host хүртэл мөрдөж, түүнийг denylist хийхэд амархан байв.

Хэдэн долоо хоног stress testing хийсэн ч бид тэр host дээр register corruption-ийг controlled environment-д давтаж чадсангүй. Гэхдээ асуудалтай host-ийг service-ээс гаргамагц misaligned-stack crash-үүд алга болсон.

Муу host-ийг авах нь ижил асуудал шинээр давтагдахаас сэргийлэхгүй тул байнгын шийдэл биш. Гэхдээ төстэй асуудал давтагдвал амархан илрүүлж, зохицуулдаг байхаар software-ийг өөрчилж болно. Core dump хэрэггүй, зөвхөн логоос давтагдлыг илрүүлэхийн тулд register state оруулдаг болгож fatal signal handler-аа сайжруулсан. VM-үүдийг ихэвчлэн дахин үүсгэхийн оронд дахин ашигладаг болгож control plane-ийг өөрчилсөн нь infrastructure stack-ийн манай түвшинд bad-node detection-ийг хавьгүй хялбар болгодог. Мөн энэ боломжийг runbook-ууддаа (мөн багийнхаа mental model-д) нэмсэн.

Bad-host crash-үүдийг тусгаарласны дараа үлдсэн return-to-null core-уудыг ойлгох хавьгүй амар болсон. Өмнө нь exception unwinding-ийг бид үгүйсгэсэн, учир нь exception огт ашиглаагүй code path дахь crash гэх counterexample байна гэж бодсон. Гэхдээ тэр counterexample-ууд бүгд hardware-corruption cluster-ээс байв.

Үүнийг санаж үлдсэн core-уудыг дахин үзэхэд бидний дүгнэлт яг эсрэгээрээ байсныг олсон: crash-үүд бүгд exception unwinding үед гарч байв.

Exception handling бол dynamic control transfer юм

C++ exception throw хийхэд runtime аль catch block түүнийг авах, замдаа ямар destructor эсвэл cleanup handler ажиллах ёстойг олох хэрэгтэй болдог. Compiler энэ metadata-г emit хийдэг ч бодит matching runtime үед dynamic байдлаар явагдана.

Exception unwinding нь throw дуудсан функцээр биш, харин үүссэн compiled code-ийн дууддаг helper function-уудаар хийгддэг. Тэдгээр runtime routine stack-ийг шалгаж, stack дээрх функцуудын metadata-г авч, cleanup handler болон catch block-уудыг dynamic-аар хайгаад, тэдгээр location-ийн нэг рүү control шилжүүлдэг. Control шилжүүлэхэд завсрын бүх stack frame-ийг unwind хийх (helper function-уудынхыг оруулаад) багтана.

Ажиллагааны хувьд энэ нь ердийн call ба return-ээс илүү longjmp эсвэл fiber switch-тэй төстэй. Callee save register-үүд, мөн stack frame register болох %rbp ба %rsp-г restore хийх ёстой.

Манай binary C++ exception unwinding хийдэг функцуудын implementation-ийг агуулсан хоёр library-тэй link хийгддэг: libgcc ба GNU libunwind. Dynamic linker GNU libunwind-ийн definition-уудыг сонгосон байсан. Энэ биднийг гайхшруулсан; symbol versioning rule-уудаас болж libgcc implementation ялах ёстой гэж бид бодсон ч ажиллаж буй binary-уудыг шалгахад тийм биш байв.

Сүүлийн нэг таамгийг буцаах нь

Энэ үед нэг л алдаа байна гэж бодож байхдаа хийсэн өөр нэг таамгаа сулруулснаар working hypothesis маань өөрчлөгдсөн.

Магадгүй бид ердийн function NULL руу return хийхийг хараагүй байж болох юм. Магадгүй бид unwind transfer буюу үндсэндээ setcontext маягийн register restore-ийг харсан; control шилжихээс өмнө destination instruction pointer NULL болсон байж. Өөрөөр хэлбэл stack дээрх буруу return address slot биш, unwind library-ээс ирсэн буруу өгөгдөл.

Ингэснээр асуудал эрс хумигдсан. GNU libunwind буруу destination state тооцоолж байсан, эсвэл зөв state тооцоолсон ч хэрэгжихээс өмнө ямар нэг зүйл түүнийг эвдэж байсан.

Бид GNU libunwind-ийн source-ийг уншаад stack дээр ucontext_t синтезлэж, cleanup handler-ийн frame-д хэрэгтэй register state-ийг бөглөж, дараа нь тэр struct-ийн pointer-ийг internal assembly routine болох _Ux86_64_setcontext-д өгдгийг олсон.

Энэ үед бүх хэсэг бидэнд байсан.

Синтезлэсэн ucontext_t нь _Ux86_64_setcontext тухайн function-ийг гүйцэтгэх явцдаа unwind хийдэг stack frame-үүдийн нэг дотор байрладаг. _Ux86_64_setcontext %rsp-г өөрчилсний дараа, struct active stack-ийн хэсэг байхаа больсон үед тэр struct-ээс уншиж байсан уу? Тийм бол манай ойр ойрхон SIGUSR2 зэрэг signal delivery-д clobber хийгдэх эмзэг байдал үүснэ.

Алдаа #2: libunwind алдаа

Хариулт нь тийм байв.

Бидний ашиглаж байсан GNU libunwind version дахь _Ux86_64_setcontext-ийн сүүлийн зургаан instruction энд байна. Эдгээр нь ихэвчлэн memory-оос destination register рүү load хийдэг mov instruction-ууд юм:

Энгийн текст

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 нь stack дээр allocation хийсэн ucontext_t рүү заадаг бөгөөд UC_MCONTEXT_* macro-ууд тухайн register хадгалагдсан fixed offset руу л expand хийгддэг.)

Эхний instruction нь race window-ийн эхлэл юм. Энэ нь %rsp-г active stack-ийн шинэ bottom руу заахаар update хийнэ. Ингэмэгц %rdi-ийн зааж буй struct active stack-ийн (эсвэл red zone-ийн) хэсэг байхаа больж, kernel-д хориотой байхаа болино.

Ихэвчлэн энэ асуудал үүсгэдэггүй ч signal яг зөв (эсвэл буруу?) мөчид ирвэл kernel signal frame-ийг %rsp-128 дээр байгуулна. Тэр нь %rdi-ийн зааж буй memory-г overwrite хийж болно.

Хэрэв дараагийн instruction UC_MCONTEXT_GREGS_RIP(%rdi)-г уншихаас өмнө тийм зүйл болбол restore хийгдэх instruction pointer эвдэрч болно. Манай crash-үүдэд тэр NULL болсон.

Алдаа нь энэ.

Why the cores masked as ordinary bad returns

This assembly also explains one of the observations that confused us: why function X had a NULL in the return address slot of the preceding stack frame.

setcontext was written to restore all registers, including %rdi, so it can’t use that register to read UC_MCONTEXT_GREGS_RIP(%rdi) at the final moment of the control transfer. Instead, it reads the value earlier, saves it to the stack, restores a few more registers, then uses retq to read the saved value and transfer control.

What looked in the cores like “a function returned to NULL” was actually “the unwinder synthesized a target return address on the stack, but that target had been corrupted before the transfer completed.” We had assumed that corruption of the return address slot must happen in-place, because we didn’t know of any places where (corruptible) data was written to the return address slot on purpose.

A single-instruction race window

What makes this bug seem absurd is how narrow this race window is. In this kind of race condition, the external event (the signal) needs to happen in between two steps taken by another thread. The closer those steps are to each other, the less likely the race condition is to happen.

In this case the vulnerable window is literally one instruction wide! A signal must be delivered after %rsp has been changed, but before the next instruction loads %rip. Several simple instructions like this can be run per cycle on a modern super-scalar out-of-order CPU, so the race window is roughly a hundred picoseconds.

When we found this race, our first reaction was that it must be too rare to explain the observed crash rate. We were seeing more than a dozen return-to-null crashes per day across the fleet. Could a one-instruction race during exception cleanup really account for that?

We turned to Fermi estimation. If the vulnerable window is on the order of 101010^{-10} seconds and SIGUSR2 arrives every 10210^{-2} seconds of CPU time, then each exception cleanup handler or catch block has a roughly 10810^{-8} probability of losing the race.

Rockset uses exceptions as part of its internal ingest backpressure mechanism. A single overloaded host can throw on the order of 10410^{4} exceptions per second. That implies the mean time between failures of a host using backpressure is 10410^{4} seconds, or one crash every few hours. At fleet scale, that is more than enough to explain the observed crash frequency.

Why did the libunwind bug appear now?

The GNU libunwind bug is old—more than 18 years old, present in the first x86_64 version that supported C++ exception unwinding.

So why did it show up now?

The crash rate is roughly proportional to how many exceptions are thrown and how many signals are delivered. It’s also dependent on how much stack the signal handler consumes.

Rockset is unusual on all three axes. We throw exceptions at high rates as part of normal overload control; we deliver SIGUSR2 unusually often because of coarse_thread_cputime_clock; and earlier this year we made the SIGUSR2 handler use more stack by adding a call to timer_getoverrun, so we could account for merged signals.

That last change seems to have been important. If the handler uses little enough stack, it may not reach and overwrite the stale ucontext_t memory. Before that change, we do not observe these crashes at all. After the change the rate remained low until we ramped up load for some use cases that stressed the backpressure mechanism.

In other words, the libunwind bug has always been there, but the product of our exception rate, signal rate, and handler stack usage had only recently crossed the threshold where it became operationally visible.

This mechanism also explains the coincidence that both the hardware bug and the libunwind bug crashed mostly inside DocumentTree::updateDocument. Crashes from libunwind were heavily biased toward this method, because it’s always active at the point we throw an exception to apply ingest backpressure. It was also heavily selected for the %rsp-misalignment crashes because the bad hardware node was of a SKU that we use for bulk ingest, which spends the majority of its CPU time in that method.

Our immediate mitigation was to switch from GNU libunwind to libgcc’s unwinder. That was a good trade on its own: libgcc’s implementation has benefited from a lot of work to reduce lock contention, which matters when scaling to large VMs.

We also upstreamed a self-contained reproducer and a fix(шинэ цонхонд нээгдэнэ) to GNU libunwind, and verified that the other unwinders don’t have a similar issue.

The power of a population-level diagnosis

This debugging journey taught us a lot about the specific details of dynamic linking, DWARF unwind metadata, Linux signal delivery, the System V ABI, and C++ exception machinery. But the main lesson was simpler than any of that.

The most important step was not the clever assembly reading or deep knowledge of the details. It was building a high-quality data set. In the absence of this data set, we were mixing two distinct phenomena into one story and trying to reason our way out of the confusion. Once we had accurate and complete population data, the structure of the problem became obvious: one crash population belonged to a bad host, and the other belonged to a race in libunwind. Once the data got better, the debugging got easier.

For infrastructure systems like Rockset, that matters a lot. This investigation reinforced our commitment to deep instrumentation, automated investigations, and continual improvements in our operational tooling. Reliability is not just about fixing bugs after they happen—it’s about building the data, workflows, and skills that turn impossible problems into diagnosable and solvable ones.

Зохиогчид

By Nathan Bronson, Member of Technical Staff