Os mutexes mais rápidos
(justine.lol)- Em situações de alta contenção, as diferenças entre implementações de mutex ficam bem evidentes, e o
pthread_mutex_tda Cosmopolitan Libc apresenta tempo de execução menor e uso de CPU mais baixo do que as principais implementações do Windows e do Linux - Em um teste no Windows com um Threadripper 29070WX de 24 núcleos, a Cosmopolitan foi 2,75 vezes mais rápida que o Microsoft SRWLOCK e usou 18 vezes menos recursos de CPU
- No Linux, em um Threadripper Pro 7995WX de 96 núcleos, ela foi 3 vezes mais rápida que a glibc e 11 vezes mais rápida que a musl libc, com uma diferença ainda maior em tempo de CPU
- No MacOS M2 Ultra, a Apple Libc fica ligeiramente à frente, e a Cosmopolitan usa no ambiente ARM um algoritmo simples que depende da chamada de sistema ulock do XNU
- A base do desempenho é a integração do nsync do Google; os pontos centrais são o caminho rápido com CAS, a fila de espera, futex/ulock/
WaitOnAddress(), prevenção de starvation e o design com designated waker
Método do benchmark de mutex sob contenção
- O teste cria 30 threads, e cada thread incrementa o mesmo inteiro global
g_chores100.000 vezes - Cada incremento é executado dentro de uma seção crítica muito pequena entre
pthread_mutex_lock()epthread_mutex_unlock() - As medições são em microssegundos e distinguem três tipos de tempo
- wall time: o tempo real gasto na execução do programa, incluindo o overhead de criação das threads e de join
- user time: tempo de CPU consumido no espaço de usuário
- system time: tempo de CPU consumido no kernel
- Como várias threads são executadas em paralelo, a soma de user time e system time pode ser maior que o wall time
- Em situações sem contenção, a diferença de desempenho entre implementações costuma ser pequena, mas em situações de contenção as diferenças de design do mutex ficam bem evidentes
Windows: Cosmopolitan mais rápida que SRWLOCK
- O teste no Windows foi realizado em um Threadripper 29070WX de 24 núcleos
- O MutexShootout, de Mark Waterman, avaliou o SRWLOCK do Windows como a implementação mais forte em cenários de alta contenção
- Nas mesmas condições, o
pthread_mutex_tda Cosmopolitan registrou wall time menor e uso de CPU mais baixo que o SRWLOCK
| Implementação | wall time | user time | system time |
|---|---|---|---|
Cosmopolitan pthread_mutex_t |
148.940µs | 328.125µs | 62.500µs |
| Microsoft SRWLOCK | 410.416µs | 5.515.625µs | 1.640.625µs |
Microsoft CRITICAL_SECTION |
949.187µs | 7.937.500µs | 5.078.125µs |
MSVC 2022 std::mutex |
991.750µs | 12.156.250µs | 4.031.250µs |
| spin lock | 1.165.435µs | 24.515.000µs | 15.000µs |
Cygwin pthread_mutex_t |
9.780.803µs | 1.937.000µs | 6.156.000µs |
- O mutex da Cosmopolitan é 2,75 vezes mais rápido que o Microsoft SRWLOCK e usa 18 vezes menos recursos de CPU
- Em comparação com o mutex da Cygwin, que oferece uma implementação POSIX no Windows, ele é 65 vezes mais rápido
- Nesse caso de uso, o mutex da Cygwin apresentou resultado mais lento até que um spin lock
Linux: diferença em tempo de CPU ainda maior que em wall time
- O teste no Linux foi realizado em um Threadripper Pro 7995WX de 96 núcleos
| Implementação | wall time | user time | system time |
|---|---|---|---|
Cosmopolitan pthread_mutex_t |
36.905µs | 44.511µs | 23.492µs |
glibc pthread_mutex_t |
101.353µs | 150.706µs | 2.724.851µs |
| spin lock | 202.423µs | 4.694.749µs | 2.000µs |
Musl libc pthread_mutex_t |
411.013µs | 2.167.898µs | 9.926.850µs |
- O mutex da Cosmopolitan é 3 vezes mais rápido que a glibc e 11 vezes mais rápido que a musl libc
- Em termos de tempo de CPU, usa 42 vezes menos que a glibc e 178 vezes menos que a musl libc
- Em workloads nos quais todas as threads precisam realizar trabalho serializado, a Cosmopolitan pode parecer, no
htop, como se apenas um núcleo estivesse ativo - Na mesma situação, glibc e musl libc podem ocupar muito a CPU, aumentando a carga ao executar várias tarefas no mesmo servidor
MacOS: Apple Libc fica ligeiramente à frente
- O teste no MacOS foi realizado em um M2 Ultra
| Implementação | wall time | user time | system time |
|---|---|---|---|
| Apple Libc | 52.263µs | 43.202µs | 911.009µs |
Cosmopolitan pthread_mutex_t |
54.700µs | 63.055µs | 1.003.674µs |
- No MacOS M2 ARM64, a Apple Libc é um pouco mais rápida que o mutex da Cosmopolitan
- A implementação geral de mutex da Cosmopolitan não funciona bem nessa plataforma
- No MacOS ARM, a Cosmopolitan usa um algoritmo mais simples baseado em Futexes Are Tricky, de Ulrich Drepper
- Essa abordagem deixa a maior parte do trabalho pesado para a chamada de sistema ulock do XNU e, como resultado, entrega desempenho quase igual ao da implementação da Apple
Base do desempenho: integração do nsync
- O ponto central do desempenho do mutex da Cosmopolitan é a integração da biblioteca nsync, do Google
- nsync é uma biblioteca com 371 estrelas no GitHub, escrita por Mike Burrows, do Google
- No processo de integração à Cosmopolitan, foram feitos os seguintes trabalhos
- Encontrar e corrigir um bug antigo, até então não detectado, na função de unlock do mutex do nsync
- Fazer o port para AARCH64 usando operações atômicas C11, tornando o mutex nsync sob contenção 30% mais rápido que o nsync upstream
- Reescrever integrações de sistema como futex para viabilizar portabilidade em runtime
- Fazer com que funcionasse de forma fluida com cancelamento de threads POSIX
Como o nsync funciona
- Para adquirir o lock rapidamente, o nsync primeiro tenta imediatamente um CAS otimista (compare and swap)
- Se não conseguir adquirir o lock, ele adiciona a thread chamadora a uma lista duplamente ligada de waiters
- Cada waiter tem seu próprio semáforo em uma cacheline separada e independente
- Uma thread que entrou em estado de espera não mexe mais no lock principal
- Isso é importante para reduzir o overhead de comunicação que surge quando vários núcleos acessam a mesma cacheline
- Como contexto relacionado, há o link para What Every Programmer Should Know About Memory, de Ulrich Drepper
- O nsync usa o futex do sistema operacional para colocar threads para dormir
- No MacOS, futex é chamado de ulock
- No Windows,
WaitOnAddress()faz o papel de futex - Entre os sistemas operacionais compatíveis com a Cosmo, apenas o NetBSD não tem futex; os semáforos POSIX são implementados no espaço do kernel e exigem um novo descritor de arquivo para cada semáforo
- O nsync evita starvation com o conceito de “long wait”
- Se um waiter for acordado 30 vezes, mas sempre falhar internamente ao tentar adquirir o lock, um bit é adicionado ao lock para impedir que threads que ainda não esperaram consigam adquiri-lo
- Quando esse bit está presente, o CAS inicial de threads recém-chegadas falha até que a fila de espera seja esvaziada até certo ponto
- Casos de uso com contenção em seções críticas pequenas ficam mais rápidos com o conceito de designated waker
- Quando uma thread acorda e tenta adquirir o lock, um bit é definido no lock principal
- No nsync, a função de unlock tem a responsabilidade de acordar a próxima thread em espera
- Graças a esse bit, a thread que está fazendo unlock não precisa acordar um segundo waiter quando já há uma thread acordada
- O código-fonte relacionado está em
cosmopolitan/third_party/nsync/mu.cecosmopolitan/libc/intrin/pthread_mutex_lock.c
Serviço real e código de validação
- Como demo ao vivo usando o Cosmo Mutex, é possível ver o servidor http://ipv4.games/
- Esse serviço roda em uma VM GCE de 2 núcleos e, até agora, já suportou um DDoS de botnet com até 49.131.669 IPs
- Graças ao nsync, foi possível mover consultas SQL para threads em segundo plano e usar uma arquitetura em que as threads enviam mensagens umas às outras
- Os indicadores de status podem ser verificados em /statusz
- O código do benchmark mede wall time com
gettimeofday()e mede user time e system time comgetrusage() - Ao final, ele verifica
g_chores == THREADS * ITERATIONSpara validar que todos os incrementos foram executados
Cuidados ao analisar spin locks
- Em situações sem contenção, as diferenças entre implementações de mutex são pequenas, e um spin lock de poucas linhas pode até ser melhor
- Mas spin locks só devem ser usados quando realmente não houver outra opção
- Eles são úteis em lugares onde restrições de nível extremamente baixo, como no kernel, dificultam o uso de abordagens mais complexas
- Spin locks também podem ser usados como detalhe interno de implementação de um lock do nsync
- Se o desempenho do lock for analisado apenas pelo wall time, um spin lock pode parecer bom; portanto, é preciso verificar também o tempo de CPU com
getrusage()
1 comentários
Opiniões do Hacker News
Novas implementações de mutex e comparações são sempre interessantes, mas não gosto desta metodologia de benchmark. Parece quase um microbenchmark
Quem realmente coloca locks rápidos em produção normalmente usa programas multithread muito grandes como principal meio de teste de desempenho. Em cargas de trabalho complexas, nas quais o tamanho da seção crítica, o número de threads concorrentes e o grau de contenção variam, os fatores que tornam um mutex rápido ou lento parecem mudar
Para referência, escrevi o lock rápido do WebKit, inventei a abstração ParkingLot para implementação de locks (também usada no Rust e na Unreal Engine) e, no passado, fiz pesquisa e publiquei um artigo sobre locks rápidos para Java
Como programador de áudio em tempo real, o custo de adquirir um mutex que ainda não está bloqueado é mais importante. No nosso app, essa situação é de longe a mais comum. Da mesma forma, eu também gostaria de saber o custo de uma operação
try-lockque vai falhar, não apenas quando N threads competemComo o Cosmopolitan é open source, eu poderia medir isso por conta própria, mas ainda assim sinto falta
Assim como com hash maps, é raro um único hash map ser melhor para todas as cargas de trabalho possíveis
Um mutex que, ao falhar em adquirir o lock, dorme por um tempo fixo (por exemplo, 100 µs) quase sempre vai agrupar o trabalho, aproximando-se desse comportamento e podendo “vencer” no benchmark. Mas, em aplicações reais, se houver qualquer contenção, um mutex assim é horrível
Não estou dizendo que este mutex seja ruim ou que o mutex pthread seja bom, e sim que esse microbenchmark não mede algo que sirva para prever o desempenho de aplicações reais
Na parte que diz que “o Cosmopolitan Mutex é bom porque usou uma biblioteca chamada nsync”, eu nunca tinha ouvido falar de nsync, mas Mike Burrows também escreveu a implementação de mutex de produção do Google: https://github.com/abseil/abseil-cpp/blob/master/absl/synchr...
Por isso fiquei curioso sobre por que essa implementação de mutex ficou fora do benchmark. E, se no macOS ela delega para
__ulock, parece que daria para conseguir isso de forma mais simples usando apenas as funções-membrowait()enotify_one()da biblioteca atomic do libc++Também houve uma thread grande, algum tempo atrás, relacionada à melhoria da implementação de mutex do Rust: https://github.com/rust-lang/rust/issues/93740#issuecomment-... O interessante é que o funcionamento interno de quase todas as implementações populares de mutex é discutido em detalhes
Pode ser verdade, mas não tenho como confirmar diretamente. Era um engenheiro extremamente inteligente, focado em eficiência. Só que nós não mantínhamos um servidor rodando por muito tempo
A implementação atual de mutex do Rust entrou no início deste ano e, no Linux, talvez não seja muito diferente, mas entendo que no Windows e no Mac é um trabalho novo
Ainda assim, a explicação da Mara sobre o interior de outras implementações continua interessante, mas é melhor verificar se a informação não está desatualizada para o seu caso
https://awards.acm.org/award-recipients/burrows_9434147
A frase “Ainda é uma nova biblioteca C, então tem arestas, mas está melhorando tão rápido que não usá-la em produção começou a parecer uma negligência profissional” é bem estranha. Eu valorizo muito o projeto Cosmopolitan, mas esse tipo de afirmação exagerada de superioridade costuma ser um sinal de alerta bem ruim
Entendo que isso possa parecer áspero para algumas pessoas. Já houve drama desse tipo antes no llamacpp
Em produção, a prioridade principal não é “melhorar incrivelmente rápido”, e sim estabilidade, previsibilidade e confiabilidade. Claro que desempenho também é importante. Código mais rápido pode reduzir infraestrutura, o que é bom em termos de custo e meio ambiente. Mas velocidade vem por último
Por exemplo, APE me parece um hack muito impressionante, mas também dá para criticar dizendo: “então agora, em vez de ser inseguro em uma única plataforma, pode ser inseguro em várias plataformas ao mesmo tempo?”
Quanto mais tempo passo na área de tecnologia, mais percebo que benefícios totalmente mútuos são extremamente raros; na maioria dos casos há ganhos e perdas ao mesmo tempo, ou seja, trade-offs
Fugindo completamente do assunto, como desenvolvedor de jogos passei a gostar de mutexes lentos que fazem bastante trabalho de debug em todas as builds de desenvolvimento. Coisas como ter nome/ID de debug, rastrear o proprietário, reportar ao profiler o tempo gasto em contenção e também reportar ao profiler as mudanças de propriedade
Jogos tendem a estruturar a concorrência de outra forma, e também evoluíram padrões para evitar locks. Mas esses padrões são difíceis de usar e exigem que o programador mude a estrutura. A maior parte do código começa com “vamos colocar um lock aqui por enquanto e passar pelo milestone”
Locks rápidos também podem ficar imprevisivelmente lentos e quebram qualquer garantia de tempo real que existisse. Em média podem ser rápidos, mas a latência de cauda não desaparece. Eu não quero ser a pessoa que volta para investigar “nosso jogo está engasgando”, mas normalmente acabo sendo essa pessoa
Então prefiro usar locks lentos. Aqueles que aparecem bem grandes e em vermelho no profiler. Se der para ver que algo está apanhando, é só refatorar e eliminar
Sei que é uma exigência difícil. Em uma produção AAA, dá para contar nos dedos as pessoas que sabem usar um profiler. Em várias produções que vi, sempre foi assim
Desculpem o desabafo, mas espero que a pesquisa em primitivas e algoritmos de concorrência rápidos continue
Em jogos, você nunca quer contenção de locks se puder evitar, e em muitos casos dá para provar que pegar um lock é desnecessário. Por exemplo, cada frame é dividido em etapas, e o acesso mutável a algum recurso compartilhado só é necessário em uma etapa específica, como
update()antes derender()ou hot reload de assetsCom threads com escopo e as regras de empréstimo do Rust, dá para estruturar o código de modo que mutexes nem sejam necessários, e ter confiança de que, se uma mudança futura no código os tornar necessários, o compilador vai apontar um erro de forma rigorosa
Sempre que possível, prefiro receber um erro de compilação a um pico no profiler
Por um lado, a linhagem Cosmo/APE/redbean parece realmente incrível, e os comentários nos textos relacionados também são em geral positivos, com pouca coisa contestando o conceito em si. Mas, por outro lado, quase nunca ouço falar de outras pessoas usando isso
Nem todo mundo compartilha amplamente o próprio trabalho, mas, depois de alguns anos, eu esperaria ter visto pelo menos alguns posts de retrospectiva de projetos. Todas as menções a Cosmo/APE/redbean que vi vieram do site da Justine
Então fico curioso. Há alguma armadilha escondida? São ferramentas que fazem alguma coisa ruim para obter resultados? É algum tipo de piada ou trollagem à la tom7 que eu não entendo por não conhecer profundamente compiladores ou runtimes? Ou são, de fato, ferramentas engenhosas que ainda não se disseminaram amplamente?
A maioria das pessoas que cria software multiplataforma não quer um único executável que rode em todas as plataformas, mas sim uma única base de código que funcione corretamente em cada plataforma suportada
Desse ponto de vista, linguagens como Go, nas quais é possível compilar de forma cruzada para todos os alvos evitando CGO, são agradáveis. Mas a mágica do APE de ser executável de três maneiras, por mais inteligente que seja, não inspira confiança de que vá funcionar para sempre, e para a maioria das pessoas também não traz muito ganho prático
Cada plataforma tem seus próprios requisitos de empacotamento e assinatura, então é melhor compilar separadamente para os alvos específicos de cada plataforma
Por exemplo, se eu já consigo fazer cross-compile de um projeto para outros sistemas operacionais e plataformas, ou se já tenho essa infraestrutura de build, não há motivo para procurar uma solução que gere um único binário que funcione em qualquer lugar
Além disso, o APE usa hacks engenhosos para rodar em vários sistemas operacionais. E se esses hacks quebrarem um dia, à medida que os formatos de executáveis evoluírem? E se ninguém tiver tempo para corrigir o APE para acompanhar essa mudança?
Por outro lado, as ferramentas entediantes como gcc, clang, go e rust continuarão sendo atualizadas e funcionando em sistemas operacionais que evoluem. Por isso acabo ficando simplesmente com o lado entediante. O motivo de eu não me preocupar com o que é engenhoso é que o que é entediante simplesmente funciona bem para mim
Também é possível executá-lo sem os pesos embutidos e fazer com que leia os pesos do sistema de arquivos. Pode ser a forma mais fácil de “baixar e executar imediatamente” um LLM local
Mas, para ser usado como tecnologia de base, como
libc, parece útil principalmente como um brinquedo divertido ou para pequenos projetos pessoaisNesse contexto, soa um pouco estranho quando é apresentado como uma alternativa séria a coisas como
glibc,muslemsvcrt. É um hack muito simpático, mas eu ficaria bastante desconcertado se o encontrasse em algo de que dependo seriamenteNo Hugging Face, eles também publicam regularmente modelos populares reempacotados nesse formato: https://huggingface.co/models?search=llamafile
Mas se isso tem utilidade prática além de testar rapidamente modelos pequenos é outra questão
Se é tão bom assim, fico me perguntando por que todas as bibliotecas C não adotaram o mesmo truque
Meu palpite é que esses truques provavelmente só são sempre rápidos em uma arquitetura específica, em um modelo específico de CPU, com uma carga de trabalho ou padrão de acesso específico. Se fizerem benchmarks adequados com cargas de trabalho variadas em todo o hardware suportado, talvez o mesmo benefício não apareça
Ou talvez a semântica da API pthread que a Cosmopolitan tenta implementar seja sutilmente diferente, e esta implementação talvez não cumpra rigorosamente a especificação
É difícil imaginar que os vários autores de libc não estejam acompanhando as pesquisas mais recentes sobre primitivas de sistema operacional
O malloc da glibc é razoavelmente utilizável, mas perde fácil para alternativas mais modernas em velocidade geral e escalabilidade. Ele fragmenta bastante e piora com o tempo, e há muitos ajustes como
MALLOC_ARENA_MAXque têm grande impacto em cargas de trabalho reais. O malloc da musl é terrível em todos os níveis em termos de desempenho. Usar o alocador da musl em um programa multithread prejudicava tanto o desempenho que quase dava para chamar de negligênciaA musl também não tem coisas como rotinas de comparação de strings otimizadas com SIMD. Você ficaria surpreso com quantos ciclos de CPU são gastos nesse tipo de operação em programas não triviais; isso aparece claramente nos perfis reais, e melhorar isso beneficia quase todos os programas de forma geral. As rotinas otimizadas da glibc são boas, mas ainda parecem poder ser mais rápidas
Essas coisas não são “otimizações especializadas para uma arquitetura só e que não generalizam”. Em especial, essas duas áreas são bem exploradas e compreendidas, reduzem o tempo de relógio em 2 a 5 vezes em quase todas as cargas de trabalho e também melhoram bastante o uso do conjunto de trabalho em execuções longas. Então por que não foram adotadas? Como sempre, provavelmente porque havia outras coisas a fazer ou porque, como na musl, existiam prioridades conflitantes que favoreciam simplicidade em vez de desempenho máximo
Não estou culpando esses projetos. Ninguém diz “meu programa é horrivelmente lento, foi projetado para não fazer nada direito, e tenho orgulho disso”. Mas a ideia de que as pessoas que trabalham nesses projetos escolheram apenas projetos perfeitamente na fronteira de Pareto não é nada realista e não captura como a maioria dos projetos realmente funciona
Mudar qualquer coisa na glibc, ou no equivalente do lado do C++, leva uma eternidade
Existem vários tipos de primitivas de sincronização, e pthreads só dá suporte a algumas delas. Ao se limitar a isso, em geral você abre mão de desempenho em troca de portabilidade
Não sei quanto aos mantenedores de libc, mas, como alguém que mantém algumas coisas, eu não tento implementar as pesquisas mais recentes. Tento manter a estabilidade e garantir que o desempenho seja aceitável. Implementações de pesquisa ficam fora do meu orçamento de “manutenção”
Um homem e um estatístico estão andando pela rua quando veem uma nota de 50 euros. O estatístico continua andando, e o homem para e diz: “Olhe, tem dinheiro no chão”. Então o estatístico diz: “Deve ser falsa. Se fosse verdadeira, alguém já teria pegado”, e continua andando. O outro homem pega o dinheiro
Threads e mutexes estão entre os elementos que mais introduzem complexidade na ciência da computação. Sempre olho uma nova implementação com ceticismo até que ela tenha sido usada em grande escala por anos
Bugs nesse tipo de mecanismo de threading muitas vezes escapam até das revisões mais intensas. Quando o Java apareceu em meados dos anos 90, expôs todo tipo de bug de threads e mutexes no Solaris
Não precisamos da implementação de mutex mais rápida, precisamos de uma implementação confiável
Este código não faz benchmark do desempenho de lock de mutex, mas sim da contenção de mutex. Se você está usando locks desse jeito, deveria reavaliar seu código
Cada thread bloqueia e desbloqueia o mutex toda vez que incrementa
g_chores. Isso gera overhead de aquisição e liberação frequentes do mutex, repetido 100.000 vezes por threadEsse overhead mascara as diferenças reais de desempenho entre os mecanismos de lock. O benchmark é dominado pela contenção de lock, não por trabalho real. Um benchmark assim é inútil
Sou fã da Justine e do trabalho dela, mas este provavelmente é o caso de teste menos interessante para um benchmark de mutex. Várias threads martelando continuamente o mesmo mutex é algo que deveria ser evitado desde o início
Por isso, não acho muito interessante saber qual implementação de mutex lida melhor com esse caso
Uma coisa importante que deixei de mencionar é que, sob contenção, um lock com desempenho ruim pode causar efeitos sistêmicos muito negativos, como criar hotspots na rede de memória, e isso também apareceria aqui
Consigo pensar em alguns casos em que várias threads convergem para o mesmo mutex. Um exemplo simples é preencher simultaneamente uma estrutura de dados como uma lista ou um dicionário
Também dá para fazer isso com passagem de mensagens, mas pode usar mais memória e ser mais lento do que esperar para escrever em um local compartilhado
Produção não tem a ver com velocidade, eficiência ou hacks obviamente “engenhosos”
Se eu tiver que sacrificar 50% de eficiência para ter a garantia de que não serei chamado para consertar um sistema quebrado às 3 da manhã de domingo, faço essa escolha toda vez
Produção tem a ver com confiabilidade, e escrever código confiável é 10 vezes mais difícil do que escrever código “rápido”