Saltar para o conteúdo principal
OpenAI

30 de junho de 2026

Engenharia

Epidemiologia de core dumps: corrigir um bug de 18 anos

Usar análise populacional para depurar falhas difíceis na nossa infraestrutura de dados.

A carregar…

Os modelos e agentes da OpenAI dependem cada vez mais de infraestruturas de dados escaláveis para procurar dados relevantes no momento da inferência: quando os modelos estão a pensar na sua pergunta. Alguns destes serviços são escritos em C++, cujo controlo de baixo nível do sistema nos permite maximizar o desempenho e minimizar o uso de memória. Esses ganhos de eficiência são importantes à medida que escalamos, mas a falta de segurança de memória em C++ significa que bugs podem causar falhas ao escrever em endereços de memória incorretos ou inexistentes.

Há alguns meses observámos algumas falhas dentro do serviço Rockset, uma parte específica da nossa infraestrutura de dados do ChatGPT, essencial para muitos plugins de dados e para a pesquisa em conversas. Em cada uma dessas falhas, uma função C++ normal parecia terminar e regressar depois a um endereço falso, levando o kernel a parar o programa porque o ponteiro de instrução já não apontava para código. Por vezes, o espaço do endereço de retorno no stack frame era NULL. Por vezes, o próprio registo de CPU do ponteiro da stack parecia estar deslocado 8 bytes, como se %rsp tivesse sido de algum modo decrementado a meio da execução normal. Em ambos os casos, a falha ocorria no retorno.

Estes não são modos de falha normais para código de aplicação. Uma escrita perdida que acerte apenas num endereço de retorno guardado é possível, mas extremamente improvável. Um bug que desalinha %rsp em 8 sem envolver assembly inline, setcontext ou longjmp (nenhum dos quais usamos) é ainda mais estranho, porque o código compilado só ajusta esse registo diretamente no prólogo e no epílogo da função. Todas as hipóteses em que nós (ou o ChatGPT) pensámos tinham fortes provas contra si, por isso o bug parecia impossível.

Aquilo que assumimos ser um único problema acabou por revelar-se dois bugs sem relação, descobertos por coincidência ao mesmo tempo. Primeiro, corrupção silenciosa de hardware num host Azure, onde o CPU simplesmente não fazia cálculos corretamente. Segundo, uma race condition com 18 anos na GNU libunwind, um bug despercebido numa biblioteca open source muito usada.

Este artigo conta como identificámos e corrigimos falhas aparentemente inexplicáveis pensando como epidemiologistas e criando um conjunto de dados de alta qualidade sobre toda a população de falhas.

Primeira tentativa de depuração: examinar cuidadosamente alguns core dumps

Primeiro, aprofundemos o Rockset. É um sistema de dados cloud-native para pesquisa e análise em tempo real que usamos em muitos casos internos na OpenAI, como conectores de sincronização (a Rockset foi adquirida pela OpenAI em 2024). As atualizações em streaming mantêm um índice atualizado da base de conhecimento de um espaço de trabalho, para que o ChatGPT possa procurar informação relevante ao responder a perguntas ou executar ações.

A camada de execução da Rockset é escrita em C++. A linguagem C++ dá acesso de baixo nível ao CPU, o que é bom para desempenho e eficiência, mas significa que bugs da aplicação podem levar a acessos inválidos à memória e a segfaults. Para os localizar, usamos o fatal signal handler da folly para registar uma stack trace quando ocorre uma falha, e carregamos os core dumps correspondentes (um instantâneo do estado do programa no momento da falha) para Azure Blob Storage para análise posterior. Todas as folhas de processamento de consultas da Rockset são replicadas, o que minimiza o impacto de uma falha no cliente. No entanto, cada segfault corresponde a um bug que tem de ser corrigido para cumprir os nossos objetivos de fiabilidade e qualidade.

A nossa abordagem inicial foi tratar estes cores como um problema de depuração convencional: inspecionar alguns core dumps muito de perto, formular hipóteses e excluí-las uma a uma.

A maioria das falhas ocorria num método chamado DocumentTree::updateDocument. Nessas falhas, parecia que updateDocument tinha chamado uma função desconhecida X, a stack tinha sido corrompida enquanto X estava ativa, e depois X tinha regressado a um endereço que não era código executável. Em alguns casos, o frame acabado de remover de X parecia válido, exceto pelo endereço de retorno guardado, que era NULL. Noutros casos, o próprio ponteiro da stack parecia errado, mas o frame válido seguinte continuava a parecer ser updateDocument.

Não sabíamos quando a stack estava a ser corrompida, o que deixava um enorme espaço de procura. updateDocument é um método grande, com muito inlining, pelo que o número de candidatos a X era avassalador.

Seria um bug no nosso código C++? Um problema de compilador ou de ligação? Um problema numa das nossas bibliotecas de runtime? Um bug do kernel Linux na entrega de sinais ou na troca de contexto? Algo ainda mais raro? Se isto era uma escrita perdida, porque não foi detetada pelo nosso ambiente de staging com ASAN?

Tentámos usar os logs ao nível da aplicação para identificar todas as ocorrências do problema, mas bugs de corrupção da stack são difíceis de classificar só a partir de logs, porque as stack traces registadas estão elas próprias corrompidas ou em falta. Não conseguimos construir uma consulta aos logs que não tivesse falsos positivos e falsos negativos. Inspecionámos manualmente mais cores e encontrámos exemplos adicionais, mas esse processo era demasiado trabalhoso para nos dar um conjunto de dados fiável.

Nesta fase da investigação, excluímos (erradamente) um bug de hardware, porque víamos falhas em várias regiões e vários tipos de hardware, pelo que continuávamos a procurar causas apenas de software. Durante alguns dias, mergulhámos a fundo numa única falha com %rsp desalinhado, reconstruindo o histórico antes da falha a partir do conteúdo da stack e dos registos. Isto produziu algumas pistas possíveis, mas, por não abandonarmos a conclusão inicial de que todos os bugs tinham a mesma causa, não nos desbloqueou.

Pistas vindas da stack

Antes de chegar ao ponto de viragem da investigação, é importante explicar que tipo de informação estávamos a extrair dos ficheiros core.

A Rockset é compilada com -fno-omit-frame-pointer, por isso o stack frame ativo é sempre acessível através de %rbp, e os chamadores formam uma lista ligada de frame pointers.

Em Linux x86_64, a ABI AMD64 System V também reserva 128 bytes abaixo de %rsp como red zone. Essa região está disponível para código em userspace e, crucialmente, o kernel promete não a sobrescrever ao entregar um sinal, como parte do contrato da ABI.

A red zone foi central na depuração de uma falha após o retorno, porque preserva alguma informação anterior ao retorno. Quando um SIGSEGV é acionado, o fatal signal handler da folly corre na stack da thread que falhou. Stack frames que já não estão ativos (porque a sua função retornou) serão sobrescritos pelo signal handler, exceto os últimos 128 bytes. É por isso que podemos dizer coisas como «o stack frame acabado de remover de X parecia válido, exceto por um endereço de retorno NULL». A red zone preserva parte dos frames inativos, ou por vezes apenas o fim de um frame inativo.

Diagrama de stack que mostra stack frames corrompidos capazes de sobrescrever endereços de retorno e causar falhas.

Encontrámos uma falha de stack desalinhada em que todas as funções envolvidas eram muito pequenas. Isso permitiu-nos ver que %rsp se tinha desalinhado durante a execução de uma função relativamente simples, e que outras chamadas tinham tido sucesso depois disso. O programa só falhou quando a função ativa tentou finalmente retornar. Nenhum desses caminhos de código usava exceções, assembly inline, setcontext ou longjmp; portanto, se o ponteiro da stack mudou mesmo como o core sugeria, nenhum bug plausível em código de userspace explicava o problema.

Isso empurrou-nos para o kernel.

A Rockset usa sinais de forma mais agressiva do que a maioria dos programas. A execução de consultas é dividida em muitas tarefas leves que trocam dados. Isto é importante para lidar eficientemente com cargas de alto QPS, mas complica a contabilização de CPU por consulta, pois o trabalho de muitas consultas é multiplexado no mesmo pool de threads.

A nossa solução é algo a que chamamos coarse_thread_cputime_clock, que aproxima clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) de forma suficientemente barata para amostrar em cada fronteira de tarefa. A API timer_create pode ser usada para agendar a entrega periódica de sinais com base em várias noções de passagem do tempo, incluindo a acumulação de tempo de CPU. Agendamos a entrega de um sinal (SIGUSR2) a cada poucos milissegundos de tempo de CPU; nesse momento, o signal handler atualiza um valor local à thread. Embora muitas tarefas não vejam o relógio aproximado avançar durante a execução, somar todos os deltas produz uma estimativa não enviesada do tempo de CPU real de uma consulta.

Como entregamos sinais com tanta frequência, um bug raro do kernel na troca de contexto ou entrega de sinais parecia plausível. Passámos tempo a ler relatórios de bugs, código-fonte do kernel e patches de kernel específicos do Azure. Tentámos testes de stress. Não conseguimos encontrar nada que parecesse relacionado.

Nessa altura decidimos recuar e tentar outra abordagem.

Médico ou epidemiologista?

Há duas formas gerais de depurar um problema como este.

Uma é agir como uma espécie de médico: focar-se num paciente, fazer muitos testes e tentar diagnosticar um caso único a partir de provas detalhadas.

A outra é agir mais como um epidemiologista: olhar para toda a população e perguntar se existem padrões que um caso isolado não consegue revelar. O bug começou numa versão específica? Correlaciona-se com um SKU de hardware (o CPU e modelo de servidor específicos), uma região ou uma versão do kernel? Haverá vários clusters distintos escondidos dentro do que parece ser uma única síndrome?

Tínhamos estado sobretudo em modo médico. A mudança decisiva foi perceber que precisávamos de reunir dados populacionais de alta qualidade.

Limpar os dados

As nossas tentativas anteriores de encontrar automaticamente todas as instâncias do problema falharam porque tentávamos fazer pesquisas de texto nos logs. Os próprios core dumps têm muito mais informação, mas analisá-los manualmente não escalava. Decidimos investir o esforço necessário para criar um pipeline capaz de analisar automaticamente os core dumps.

Pedimos ao ChatGPT que escrevesse um script que descarregasse um prefixo de cada ficheiro core, extraísse os registos, filtrasse falsos positivos conhecidos usando os logs e classificasse automaticamente a falha como retorno para null, stack desalinhada ou outra. Depois executámos esse script em paralelo sobre todos os core dumps de produção da Rockset do ano anterior.

Este foi o ponto de viragem.

Assim que tivemos um conjunto de dados limpo, as correlações apareceram de imediato. Aquilo que vínhamos a tratar como um bug estranho era, na verdade, duas populações de falhas separadas.

Os cores de retorno para null estavam distribuídos por muitos clusters e regiões geográficas. A sua frequência tinha aumentado recentemente, mas não havia uma data de início clara nem uma fronteira de infraestrutura nítida.

As falhas de stack desalinhada pareciam completamente diferentes. Vinham todas de uma região, tinham uma data de início clara e nunca aconteciam em nodes que estavam em execução há muito tempo. Embora envolvessem várias VMs Azure (máquinas virtuais alojadas na cloud), o padrão parecia ser o de uma máquina física com hardware defeituoso a causar problemas a qualquer VM que nela calhasse.

Gráfico de pontos das taxas de falhas por cluster ao longo do tempo, mostrando a maioria das falhas concentrada nos clusters 2, 3 e 6, com um pico no cluster 1 perto do fim do período.

Foi nesse momento que percebemos que estávamos a confundir mentalmente dois bugs. Como misturávamos contraexemplos dos dois bugs, não conseguíamos encontrar uma explicação única e coerente.

Bug n.º 1: o host defeituoso

Com uma lista limpa de nodes Kubernetes e carimbos de data/hora, conseguimos rastrear as falhas de stack desalinhada até um único host físico, que foi fácil de colocar numa denylist.

Não conseguimos reproduzir a corrupção de registos nesse host num ambiente controlado, mesmo após várias semanas de testes de stress. No entanto, assim que o host problemático foi retirado de serviço, as falhas de stack desalinhada desapareceram.

Remover o host defeituoso não é uma solução permanente, no sentido em que não impede uma nova ocorrência do mesmo problema. Podemos, porém, alterar o software para que, se um problema semelhante voltar a ocorrer, seja facilmente detetado e tratado. Melhorámos o nosso fatal signal handler para incluir o estado dos registos, para podermos detetar recorrências apenas a partir dos logs (sem precisar de core dump). Alterámos o control plane para que as VMs sejam normalmente reutilizadas em vez de recicladas, o que torna muito mais fácil detetar nodes defeituosos ao nosso nível da stack de infraestrutura. Também atualizámos os nossos runbooks (e os modelos mentais da equipa) para incluir esta possibilidade.

Com as falhas do host defeituoso separadas, os cores restantes de retorno para null tornaram-se muito mais fáceis de analisar. Antes tínhamos excluído o unwinding de exceções porque pensávamos ter contraexemplos: falhas em caminhos de código onde as exceções não eram de certeza usadas. Mas esses contraexemplos vinham todos do cluster de corrupção de hardware.

Quando revisitámos os cores restantes com isso em mente, descobrimos que a conclusão estava exatamente ao contrário: as falhas aconteciam todas durante o unwinding de exceções.

O tratamento de exceções é uma transferência dinâmica de controlo

Quando C++ lança uma exceção, o runtime tem de descobrir que bloco catch a deve receber e que destrutores ou handlers de limpeza devem correr pelo caminho. O compilador emite estes metadados, mas a correspondência real acontece dinamicamente em runtime.

O unwinding de exceções não é, na verdade, executado pela função que invoca throw, mas por funções auxiliares chamadas pelo código compilado resultante. Essas rotinas de runtime examinam a stack, obtêm metadados sobre as funções encontradas nela, procuram dinamicamente handlers de limpeza e blocos catch, e depois transferem o controlo para uma dessas localizações. Transferir o controlo inclui desenrolar todos os stack frames intermédios (incluindo os das funções auxiliares).

Operacionalmente, isto está muito mais próximo de um longjmp ou de uma troca de fiber do que de uma chamada e retorno normais. Os registos preservados pelo chamado têm de ser restaurados, tal como os registos do stack frame %rbp e %rsp.

O nosso binário liga contra duas bibliotecas que contêm implementações das funções que executam unwinding de exceções C++: libgcc e GNU libunwind. As definições da GNU libunwind foram as escolhidas pelo linker dinâmico. Isso surpreendeu-nos; esperávamos que a implementação da libgcc prevalecesse por causa das regras de versionamento de símbolos; porém, ao inspecionar binários em execução vimos que não era assim.

Desfazer uma última suposição

Neste ponto, a nossa hipótese de trabalho mudou, ao relaxarmos outra suposição feita quando pensávamos que havia apenas um bug.

Talvez não estivéssemos a ver uma função comum retornar para NULL. Talvez estivéssemos a ver uma transferência de unwinding — na prática, uma restauração de registos ao estilo de setcontext — em que o ponteiro de instrução de destino se tinha tornado NULL antes da transferência de controlo. Por outras palavras, dados incorretos vindos da biblioteca de unwinding, e não um espaço de endereço de retorno incorreto na stack.

Isso reduziu drasticamente o problema. Ou a GNU libunwind estava a calcular o estado de destino errado, ou calculava o estado certo e algo o corrompia antes de poder ser aplicado.

Lemos o código-fonte da GNU libunwind e vimos que sintetiza um ucontext_t na stack, preenche o estado de registos desejado para o frame do handler de limpeza e depois entrega um ponteiro para essa struct a uma rotina interna em assembly: _Ux86_64_setcontext.

Neste ponto tínhamos todas as peças.

O ucontext_t sintetizado vive num dos stack frames que é desenrolado por _Ux86_64_setcontext durante a execução dessa função. Estaria _Ux86_64_setcontext a ler da struct depois de alterar %rsp, quando a struct já não fazia parte da stack ativa? Isso torná-la-ia vulnerável a ser sobrescrita pela entrega de um sinal, como o nosso frequente SIGUSR2.

Bug n.º 2: o bug da libunwind

A resposta foi sim.

Estas são as últimas seis instruções de _Ux86_64_setcontext na versão da GNU libunwind que usávamos, compostas sobretudo por instruções mov que carregam de memória para um registo de destino:

Texto simples

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 aponta para o ucontext_t alocado na stack, e as macros UC_MCONTEXT_* expandem-se simplesmente para o offset fixo onde um determinado registo é armazenado.)

A primeira instrução é o início da janela de corrida. Atualiza %rsp para apontar para a nova base da stack ativa. Assim que isso acontece, a struct apontada por %rdi deixa de fazer parte da stack ativa (ou da red zone) e já não está fora do alcance do kernel.

Normalmente isto não causa problemas, mas, se um sinal chegar exatamente no momento certo (errado?), o kernel criará o signal frame em %rsp-128. Isso pode sobrescrever a memória apontada por %rdi.

Se isso acontecer antes de a instrução seguinte ler UC_MCONTEXT_GREGS_RIP(%rdi), o ponteiro de instrução restaurado pode ficar corrompido. Nas nossas falhas, tornou-se NULL.

É esse o bug.

Porque os cores pareciam retornos incorretos comuns

Este assembly também explica uma das observações que nos confundiu: porque é que a função X tinha NULL no espaço do endereço de retorno do stack frame anterior.

setcontext foi escrita para restaurar todos os registos, incluindo %rdi, por isso não pode usar esse registo para ler UC_MCONTEXT_GREGS_RIP(%rdi) no momento final da transferência de controlo. Em vez disso, lê o valor antes, guarda-o na stack, restaura mais alguns registos e depois usa retq para ler o valor guardado e transferir o controlo.

O que nos cores parecia «uma função retornou para NULL» era na verdade «o unwinder sintetizou na stack um endereço de retorno de destino, mas esse destino foi corrompido antes de a transferência terminar». Tínhamos assumido que a corrupção do espaço do endereço de retorno teria de acontecer no próprio local, porque não conhecíamos sítios onde dados (corruptíveis) fossem escritos de propósito nesse espaço.

Uma janela de corrida de uma só instrução

O que torna este bug absurdo é a estreiteza da janela de corrida. Neste tipo de race condition, o evento externo (o sinal) tem de acontecer entre dois passos executados por outra thread. Quanto mais próximos estiverem esses passos, menos provável é que a race condition aconteça.

Neste caso, a janela vulnerável tem literalmente a largura de uma instrução! Um sinal tem de ser entregue depois de %rsp ter sido alterado, mas antes de a instrução seguinte carregar %rip. Várias instruções simples como esta podem correr por ciclo num CPU superescalar moderno com execução fora de ordem, pelo que a janela de corrida é de cerca de cem picossegundos.

Quando encontrámos esta corrida, a nossa primeira reação foi pensar que tinha de ser demasiado rara para explicar a taxa de falhas observada. Víamos mais de uma dúzia de falhas de retorno para null por dia em toda a frota. Poderia uma corrida de uma só instrução durante a limpeza de exceções explicar isso?

Recorremos à estimativa de Fermat. Se a janela vulnerável é da ordem de 101010^{-10} segundos e SIGUSR2 chega a cada 10210^{-2} segundos de tempo de CPU, então cada handler de limpeza de exceções ou bloco catch tem uma probabilidade de cerca de 10810^{-8} de perder a corrida.

A Rockset usa exceções como parte do seu mecanismo interno de backpressure na ingestão. Um único host sobrecarregado pode lançar da ordem de 10410^{4} exceções por segundo. Isso implica um tempo médio entre falhas, para um host que usa backpressure, de 10410^{4} segundos, ou uma falha a cada poucas horas. À escala da frota, isso é mais do que suficiente para explicar a frequência de falhas observada.

Porque surgiu agora o bug da libunwind?

O bug da GNU libunwind é antigo — tem mais de 18 anos e já estava presente na primeira versão x86_64 que suportava unwinding de exceções C++.

Então porque apareceu agora?

A taxa de falhas é aproximadamente proporcional ao número de exceções lançadas e de sinais entregues. Também depende da quantidade de stack consumida pelo signal handler.

A Rockset é invulgar nos três eixos. Lançamos exceções a taxas elevadas como parte do controlo normal de sobrecarga; entregamos SIGUSR2 com frequência invulgar por causa de coarse_thread_cputime_clock; e, no início deste ano, fizemos o handler de SIGUSR2 usar mais stack ao acrescentar uma chamada a timer_getoverrun, para contabilizar sinais fundidos.

Essa última alteração parece ter sido importante. Se o handler usar pouca stack, pode não chegar à memória obsoleta de ucontext_t nem sobrescrevê-la. Antes dessa alteração, não observávamos estas falhas de todo. Depois da alteração, a taxa manteve-se baixa até aumentarmos a carga em alguns casos de uso que pressionavam o mecanismo de backpressure.

Por outras palavras, o bug da libunwind sempre existiu, mas o produto da nossa taxa de exceções, taxa de sinais e uso de stack pelo handler só recentemente cruzou o limiar em que se tornou visível operacionalmente.

Este mecanismo também explica a coincidência de tanto o bug de hardware como o bug da libunwind falharem sobretudo dentro de DocumentTree::updateDocument. As falhas da libunwind estavam fortemente enviesadas para este método, porque ele está sempre ativo no ponto em que lançamos uma exceção para aplicar backpressure à ingestão. Também foi muito selecionado para as falhas de desalinhamento de %rsp, porque o node de hardware defeituoso era de um SKU que usamos para ingestão em massa, que passa a maior parte do tempo de CPU nesse método.

A nossa mitigação imediata foi trocar GNU libunwind pelo unwinder da libgcc. Foi uma boa troca por si só: a implementação da libgcc beneficiou de muito trabalho para reduzir contenção de locks, o que importa ao escalar para VMs grandes.

Também contribuímos upstream com um reprodutor autónomo e uma correção(abre numa nova janela) para a GNU libunwind, e verificámos que os outros unwinders não têm um problema semelhante.

O poder de um diagnóstico populacional

Esta jornada de depuração ensinou-nos muito sobre detalhes específicos de ligação dinâmica, metadados de unwinding DWARF, entrega de sinais em Linux, ABI System V e maquinaria de exceções C++. Mas a principal lição foi mais simples do que tudo isso.

O passo mais importante não foi a leitura engenhosa de assembly nem o conhecimento profundo dos detalhes. Foi criar um conjunto de dados de alta qualidade. Sem esse conjunto de dados, estávamos a misturar dois fenómenos distintos numa só história e a tentar raciocinar para sair da confusão. Quando tivemos dados populacionais exatos e completos, a estrutura do problema tornou-se óbvia: uma população de falhas pertencia a um host defeituoso, a outra a uma corrida na libunwind. Assim que os dados melhoraram, a depuração ficou mais fácil.

Para sistemas de infraestrutura como a Rockset, isso é muito importante. Esta investigação reforçou o nosso compromisso com instrumentação profunda, investigações automatizadas e melhorias contínuas nas nossas ferramentas operacionais. Fiabilidade não é apenas corrigir bugs depois de acontecerem — é criar os dados, fluxos de trabalho e competências que transformam problemas impossíveis em problemas diagnosticáveis e solucionáveis.

Autores

By Nathan Bronson, Member of Technical Staff