Desde que eu trabalhava como SRE, havia um workload na empresa o qual era praticamente impossível de colocar 2 pods em um mesmo node sem uma degradação muito grande em sua performance.
No início desse ano, fiz uma mudança interna de carreira, indo trabalhar exatamente no time que cuida desse workload. Isso me possibilitou estudá-lo intensivamente durante alguns meses, até que entendi o por que dele perder performance.
Nesse post, vou compartilhar algumas das minhas descobertas, e como fiz para resolver o problema de ter 2 pods com TFSS rodando no mesmo node.
O problema
Além da questão mencionada acima, ter somente 1 pod por node trás outros problema, como por exemplo, quando precisamos escalar o ambiente, além do tempo que o workload leva para ficar pronto, ainda temos o tempo que o provider leva para provisionar um novo node.
Outra problema associado a escalabilidade é que, se alguma parte do seu cluster for formado por nodes spot, ainda há o risco de throttling, caso seja solicitado um grande número de nodes de uma só vez.
A solução
Como eu disse inicialmente, foram meses estudando e entendendo profundamente desde como o workload funcionava, até como é a arquitetura de hardware do GCP nas máquinas que usamos.
E nesse ponto você pode estar imaginando que a solução deve ser extremamente complexa. Pois acredite se quiser, ela não é!
A experiência que tive na resolução desse problema comprova por A + B que, quando mais tempo estudando o problema e entendendo tudo o que o compõe, mais simples será a solução.
Entretanto, é de extra importância deixar claro que, compreender todas as partes que compunham o problema não foi nada fácil. Então, assim como o Jack, vamos por partes.
O hardware da C4
Por ser um workload que usa TFSS para inferência, usamos as máquinas da família C4, pois elas apresentam uma otimização a nível de hardware para esse tipo de workload.
Acontece que, mesmo quando usávamos máquina maiores, ao colocar 2 pods no mesmo node, a performance piorava muito.
Depois de várias semanas estudando e entendendo a arquitetura do processador, ficou claro onde estava o problema.
Arquitetura de uma C4
As máquinas C4 são disponibilizadas com os processadores Intel Emerald Rapids (5ª geração) e Intel Granite Rapids (6ª geração). Para cada core físico (diferente de vCPU), existe uma memória cache L2 privada e dedicada. Enquanto a L3 é compartilhada por todos os cores.

Quando entendi a arquitetura do processador, ficou muito claro o motivo da degradação do workload quando mais de um pod estava no mesmo node.
Motivo da degradação
Para que uma inferência ocorra, são necessários vários ciclos de processamento, onde cada ciclo pode ter seu resultado matemático salvo no cache L2.
Como uma inferência não ocorre do início ao fim no mesmo core, além do fato do workload não lidar somente com uma request por vez, quando o cache L2 “enche”, parte deles é empurrada para o L3.
Como na C4 ele é compartilhado entre todos os cores, embora haja uma “punição” de latência ela não é tão alta quanto como o cache da L3 é empurrado para memória RAM. E é aqui que mora nosso problema.
Veja, ao ter somente 1 pod dedicado no node, a chance do cache de uma inferência chegar até a memória RAM é extremamente baixo. No entanto, quando você adiciona o segundo pod, a quantidade de cache necessário para lidar com o dobro de inferência também dobra, aumentando a chance do cache de uma inferência para memória RAM, o que por sua vez irá aumentar a latência de processamento.
NUMA ao resgate
NUMA significa Non-Uniform Memory Access, e como o próprio tópico diz, ela será a responsável por salvar o dia.
Para simplificar a explicação, imagine que cada NUMA é o agrupamento físico de CPU e memória da máquina, porém com um controle lógico de acesso. Tal controle lógico de acesso garante a utilização de CPU e memória mais próximo (NUMA 0, por exemplo). Porém em uma máquina com, digamos, 2 NUMA, durante um processamento com vários ciclos de CPU, pode ocorrer um cruzamento de NUMA, o que, além de ter uma penalização simplesmente pelo fato do cruzamento ocorrer, todo o cache L2 e L3 é perdido.

Aqui vale dizer que a quantidade de NUMA depende do tamanho da máquina.
- C4 até 48 cores: 1 NUMA
- C4 de 96 até 192 cores: 2 NUMA
- C4 de 288 cores: 4 NUMA
Calma ai Tiago, mas o que você acabou de me dizer parece piorar ainda mais a situação! Como que você pode dizer que ela será responsável por salvar o dia?
Simples! O problema fundamental da perda de performance está diretamente ligado a tentar executar 2 pods na mesma NUMA. Em outras palavras, se isolarmos cada pod em uma NUMA, a concorrência por cache deixará de existir.
E para atingir isso, precisamos fazer duas pequenas mudanças no Kubernetes.
O Kubernetes
Como nosso workload é executado no GKE, vou separar as mudanças de configurações a nível de node e workload.
Embora o exemplo seja com Compute Class, o mesmo resultado pode ser alcançado em configurações de node que foram criados manualmente ou via Terraform.
Kubelet
A primeira parte da solução passa por configurar o cpuManagerPolicy do kubelet como static.
Essa mudança garante que, pods que utilizem Quality-of-Service (QoS) como Guaranteed e tenham suas configurações de request.cpu como inteiro, tenham acesso exclusivo a certos cores da máquina.
Em outras palavras, essa mudança é a responsável por não deixar que um pod utilize o mesmo core que o outro.
Tal configuração está disponível através dos atributos de customização do node. Abaixo um exemplo de como fazer a mudança em um compute class.
apiVersion: cloud.google.com/v1
kinds: ComputeClass
metadata:
name: my-custom-cc
spec:
priorityDefaults:
nodeSystemConfig:
kubeletConfig:
cpuManagerPolicy: static
Workload
A segunda parte é garantir que seu workload esteja com QoS Guaranteed ao invés de Burstable.
Isso é necessário pois, se ele estiver como Burstable, ou seja, se a quantidade de CPU máxima do pod é diferente da minima, o kubelet não consegue deixar cores alocados de forma exclusiva para o pod, já que pode haver uma variação.
Logo, para garantir que o QoS do seu workload esteja como Guaranteed, basta configurar request e limit com o mesmo valor.
apiVersion: v1
kind: Pod
metadata:
name: backend-service
spec:
containers:
- name: app-container
image: nginx:latest
resources:
requests:
memory: "512Mi"
cpu: "1"
limits:
memory: "512Mi"
cpu: "1"
Conclusão
Com essas simples mudanças, nossos pods começaram a ser alocados em NUMAs diferentes, nos possibilitando executar mais de 1 pod por node sem nenhuma degradação em latência.
Em um próximo post – ainda estou na faze de pesquisa e teste -, compartilharei sobre minha experiência tentando manter a mesma latência em C4 ou C4D (processadores AMD tendem a perder performance).
Gostou do conteúdo?
- ✅ Inscreva-se na newsletter para receber mais dicas práticas sobre Go diretamente no seu e-mail!
- 🚀 Conheça a Imersão Golang e leve seus conhecimentos em Go para o próximo nível!
Faça parte da comunidade!
Receba os melhores conteúdos sobre Go, Kubernetes, arquitetura de software, Cloud e esteja sempre atualizado com as tendências e práticas do mercado.