Na postagem anterior, abordamos alguns conceitos relacionados à estrutura de uma aplicação Spark Nesta publicação, entrarei em detalhes a respeito da memória do executor partindo ideia de tarefa, comum ao texto anterior.

Na primeira seção, “Tarefas e partições”, veremos a relação entre tarefas, partições e o hardware para introduzir o tema. Em seguida, veremos em “*Memória On-Heap e Off-Heap”, onde discutimos sobre as diferentes regiões de memória de um executor, com maior foco na memória On-Heap. Nesta seção, teremos tópicos como “Memória reservada, memória unificada e memória de usuário” para entrar em maiores detalhes de como a memória On-Heap é dividida e utilizada, e em “Memória unificada: armazenamento e execução” trataremos sobre a região de memória mais importante para a pessoa desenvolvedora.


Tarefas e partições

Na publicação anterior, vimos que o Spark faz uso de tarefas (task) para maximizar o processamento em paralelo. Fisicamente, uma task é um pedaço de memória e uma thread ocupados pelo Spark para processar um dado que chamamos de partição. Para simplificar, vamos tratar aqui que cada núcleo de processador usa apenas uma thread.

Tarefa em termos de hardware

O tamanho da partição pode variar bastante. Às vezes, partições são grandes demais, outras vezes elas são pequenas demais, e cada um destes cenários nos trazem diferentes tipos de problemas. Vamos tratar deles em uma postagem futura.

O que gostaria de enfatizar em relação às partições agora é a questão da sua volatilidade. Em geral, os dados não são persistidos e só existem durante a execução da aplicação. Caso haja algum problema não relacionado ao código da aplicação em si, como problemas de rede, todo o processamento será perdido. Devido a essa falta de armazenamento de estado de execução de uma aplicação e à avaliação preguiçosa é que geralmente não se recomenda o Spark como ferramenta de ingestão.


Memória On-Heap e Off-Heap

A memória de um executor no Spark pode ser dividida em duas grandes áreas nomeadas on-heap e a off-heap:

Memória on-heap e mem[oria off-heap no executor do Spark]

A memória “on-heap” é uma área gerenciada pela JVM e está sujeita a um processo de limpeza automático chamado garbage collection . Este recurso, presente em linguagens que usam a JVM, tira a responsabilidade da pessoa desenvolvedora de gerenciar a memória do dispositivo. Quando há a execução de garbage collection, todos os processos são parados até que todos os objetos desnecessários armazenados em memória sejam apagados, e só depois que a aplicação Spark volta a processar os dados. A memória on-heap é definida pelo parâmetro spark.executor.memory, e a maioria das aplicações só faz uso deste tipo de memória. Devido a isso, vamos dar atenção especial a ela, mas sem deixar de pelo menos mencionar o que é a memória off-heap.

A memória “off-heap” é um espaço acessível pela aplicação executada dentro da JVM, mas não é sujeita aos ciclos de garbage collection. Este tipo de memória é usada quando o ciclo de garbage collection compromete a aplicação e a pessoa desenvolvedora decide assumir o controle da memória. Mas, como dito antes, a maioria das aplicações não requer o uso deste tipo de memória.

Memória reservada, memória unificada e memória de usuário

A memória reservada é um espaço de 300MB utilizado pelo Spark para alocar seus objetos internos e garantir o seu funcionamento. Este valor pode ser mudado alterando a variável RESERVED_SYSTEM_MEMORY_BYTES no código do Spark e recompilando o framework, ou alterando o parâmetro spark.testing.reservedMemory. Como é altamente não recomendado alterar a memória reservada, vamos tratá-la aqui como imutável.

A memória unificada é a região mais importante para quem desenvolve a aplicação por ser nesta região onde as partições das tarefas são alocadas . A região de memória unificada recebe este nome por ser dividida em duas partes, a memória de execução e a de armazenamento, que estão sob a gestão de um mecanismo de alocação dinâmica entre ambas conforme a necessidade da aplicação.

Por último, temos a memória de usuário, utilizada para armazenar alguns metadados do Spark, estruturas definidas pelo usuário e dados sobre dependência entre RDDs. Um exemplo de estrutura que faz uso desta memória são as UDFs e, em algumas situações, o Spark fará uso desta memória para criar uma tabela temporária apenas para mapear valores em um RDD para outro RDD em algumas operações de junção (joins).

Agora, podemos acrescentar alguns detalhes à imagem anterior:

Memória on-heap detalhada

E, a partir disto, podemos considerar o seguinte:

  • spark.executor.memory: define o valor total da memória on-heap do executor. Por padrão, este valor é de 1GB.

  • spark.memory.fraction: define a fração da memória on-heap que será reservada para a memória unificada. O valor padrão é 0.6

    • Memória Unificada = spark.executor.memory * spark.memory.fraction
  • O restante é a memória de usuário:

    • Memória de Usuário = (1 - spark.memory.fraction) * spark.executor.memory - 300MB

Agora, vamos entrar em um pouco mais de detalhes a respeito da memória unificada.

Memória unificada: armazenamento e execução

Esta região unifica as memórias de armazenamento e de execução, e é a região mais importante para a pessoa desenvolvedora. O modivo desta afirmação se dá por que é nesta região em que os objetos particulares da aplicação são armazenados. Estruturas como DataFrames em cache e variáveis usam o espaço da memória de armazenamento, já dados de partições criados por operações de shuffle e transformações amplas em geral são armazenadas na memória de execução. Apesar destes respectivos espaços terem uso bem definidos, é possível que uma região pegue espaço da outra emprestada se for necessário, isso graças ao mecanismo de alocação dinâmica presente desde o Spark 1.6.

Por padrão, a memória de armazenamento e a de execução ocupam cada uma a metade da memória unificada, sendo que a metade correspondente à memória de armazenamento não pode ser ocupada pela memória de execução. Contudo, é possível ajustar essa configuração alterando o valor do parâmetro spark.memory.storageFraction para um valor diferente de 0.5. Tenha em mente que este parâmetro controla a fração da memória unificada que não será ocupada pela memória de execução.

Para ilustrar o mecanismo em questão, vamos supor que temos um DataFrame que é usado muitas vezes por diferentes Jobs. Para evitar todo o reprocessamento que gera este DataFrame, a pessoa desenvolvedora decide armazená-lo em cache. Com isso, o Spark armazena os dados na memória de armazenamento, e também pega uma parte da memória de execução emprestada.

Memória unificada

Em algum ponto, a pessoa desenvolvedora decide que é hora de remover o DataFrame do cache, liberando a memória de armazenamento. Em seguida, a aplicação executa uma agregação pesada que requer bastante memória. Com isso, o Spark decide usar a memória de execução e tomar parte do que antes era a memória de armazenamento para realizar esta operação. Neste exemplo, o parâmetro spark.memory.storageFraction é menor que 0.5

Memória unificada

Agora que temos uma melhor ideia de como é composição da memória de um executor, podemos voltar com a imagem da publicação anterior, omitindo apenas parte da memória “off-heap”, que não foi abordada nesta publicação.

Memória on-heap de um executor Spark


Conclusão

Ter uma maior clareza sobre o que é uma tarefa e como a memória de um executor é composta ajuda a pessoa desenvolvedora tirar melhor proveito do hardware disponível. Tarefas, em sua essência, são partições de dados sendo processados por uma thread, e estas partições ocupam diferentes áreas da memória do executor.

Em uma visão ampla, a memória do executor é dividida em on-heap e off-heap, onde a primeira os dados são gerenciados de forma automática e, na segunda, a responsabilidade cai sobre a pessoa desenvolvedora. Felizmente, a maioria das aplicações tem critérios que usar a memória on-heap é o suficiente.

A região on-heap é ainda dividida em termos de outras regiões, sendo elas a memória unificada, a memória do usuário e a memória de reserva. A memória de usuário armazena estruturas geradas pelo usuário, como funções, e a memória reservada é um espaço de 300MBque o Spark usa para executar a si mesmo. A memória unificada é composta pela memória de execução, que é usada para armazenar objetos relacionados a transformações amplas em geral, e a memória de armazenamento que geralmente é usada para cache de DataFrames. A memória de armazenamento ainda pode se expandir sobre a memória de execução caso seja necessário. Algo similar também acontece em respeito à memória de execução se expandir sobre a memória de armazenamento, contudo, há um limite definido pelo parâmetro spark.memory.storageFraction.


Referências