Tiago Temporin

Kubernetes & NUMA: Como rodar 2 pods com TFSS no mesmo Node

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.

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?


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.

* indicates required
Sair da versão mobile