Otimizando tamanho em memória de Structs em Golang
Vinicius Gabriel
Posted on May 22, 2023
Quando trabalhamos com Go, é muito comum utilizarmos a estrutura Struct para agrupar vários campos com diferentes tipos primitivos de dados, mas como será que essa estrutura é armazenada em memória e existe alguma maneira de otimizar esse espaço ocupado em memória?
Com esse questionamento acabei me deparando com dois conceitos que antes eu não conhecia, chamados de Data Alignment e Padding.
Para podermos entender os conceitos, é importante primeiro termos conhecimento de quanto cada tipo primitivo de dado ocupa em memória, não vou me extender muito nesse assunto em específico, mas segue um breve resumo dos tipos primitivos e seus respectivos tamanhos em bytes:
Tipos primitivos em Go e seus respectivos tamanhos em bytes:
* string - 16 bytes
* int32 - 4 bytes
* int64 - 8 bytes
* int - depende da arquitetura do Sistema Operacional(32 bits - 4 bytes ou 64 bits - 8 bytes)
* uint - mesmas regras aplicados ao tipo int e respectivamente int32 e int64
* float32 - 4 bytes
* float64 - 8 bytes
* bool - 1 byte
Caso queira avaliar mais profundamente os tipos primitivos e seus tamanhos em Go você pode consultar aqui.
Em primeiro caso, para entendermos a ideia do Padding, vamos trabalhar com essa struct:
type StructA struct {
i32 int32
s string
b bool
}
Nós sabemos que um int32 ocupa 4 bytes, uma string ocupa 16 bytes e um bool ocupa 1 byte certo? Então em teoria o tamanho em memória da struct deveria ser exatamente a soma em bytes de todos os campos que ela contém, no caso da nossa StructA deveria ser 21 bytes certo?
Bom, podemos validar esse tamanho com a função Sizeof() do pacote unsafe que retorna o tamanho em bytes alocado para a estrutura passada na função dessa maneira:
package main
import (
"fmt"
"unsafe"
)
type StructA struct {
i32 int32 // 4 bytes
s string // 16 bytes
b bool // 1 bytes
}
func main() {
a := StructA{}
fmt.Println("Tamanho da StructA: ", unsafe.Sizeof(a))
}
O retorno que imaginamos seria 21 bytes certo? Mas o retorno desse script é:
Tamanho da StructA: 32
E agora vem o ponto de dúvida, por que exatamente essa struct ocupa 32 bytes se a soma de todos os campos é 21 bytes? É aí que entra o Data Alignment.
Para entermos melhor o conceito, é importante que saibamos de maneira abstrata como a memória é organizada em nossa máquina. A estrutura de memória é armazenada em células, seguindo uma estrutura parecida com essa:
0x00 | dados |
0x01 | ... |
0x02 | ... |
Cada célula de memória, armazena um tamanho específico de bits, que depende da arquitetura do nosso sistema operacional (x32 ou x64), ou seja, 4 bytes para sistemas x32 e 8 bytes para sistemas x64, podemos imaginar cada celula dessa maneira, tomando como premissa um SO x64:
0x00 | byte | byte | byte | byte | byte | byte | byte | byte |
0x01 | ... |
0x02 | ... |
O Data Alignment estrutura cada variável que criamos em nosso programa, para sempre estar alinhado em somente uma célula de memória. Por exemplo, quando temos uma variável do tipo int32 (4 bytes), ele trabalhar para que todos os 4 bytes estejam na mesma célula de memória, para evitar que o processador tenha que acessar mais de uma célular de memória para ler ou escrever o conteúdo da nossa variável. Portanto nossa StructA, está organizada dessa forma em memória:
0x00 | i32Byte | i32Byte | i32Byte | i32Byte | - | - | - | - |
0x01 | sByte | sByte| sByte| sByte| sByte | sByte | sByte | sByte |
0x02 | sByte | sByte| sByte| sByte| sByte | sByte | sByte | sByte |
0x03 | boolByte| - | - | - | - | - | - | - |
Se contarmos cada byte alinhado na estrutura de memória acima representado pelo “| |”, podemos contar no total 32 casas, ou seja, nossos 32 bytes retornados pela função unsafe.SizeOf().
A estrutura vazia na célula de memória representada pelo “-”, é o que chamamos de Padding. Sua função é basicamente completar o espaço não usado naquela célula, para que a próxima estrutura possa começar em uma nova célula de memória. Já que caso a alocação dos bytes da nossa variável string inciasse no final da célula 0x00, o processador acabaria tendo que acessar 3 células de memória invés de 2 para trabalhar no conteúdo dessa variável, já que a organização ficaria dessa forma:
0x00 |i32Byte|i32Byte|i32Byte|i32Byte|sByte|sByte|sByte|sByte|
0x01 | sByte | sByte| sByte| sByte| sByte | sByte | sByte | sByte |
0x02 | sByte | sByte| sByte | sByte | - | - | - | - |
0x03 | boolByte| - | - | - | - | - | - | - |
Bom, agora sabemos que o Data Alignment é responsável por trabalhar o alinhamento dos bytes das nossas variáveis nas células de memória, para que o processador execute o mínimo de instruções possíveis para manipular um dado em memória e o Padding é o responsável por “preencher” os espaços não usados em cada endereço, fornecendo o apoio para o alinhamento mais eficiente em memória.
Sabendo disso, como então podemos utilizar esse conhecimento para otimizar nossas structs? É extremamente simples, basta reordenar os campos para que sempre que possível dois tipos que somado seus tamanhos em bytes seja no máximo 8, ou seja, podem ocupar interiamente o mesmo alocamento de 8 bytes na memória.
Para exemplicar, vamos criar uma nova struct chamada StructAOptimized, onde reordenaremos os campos de maneira mais eficiente:
package main
import (
"fmt"
"unsafe"
)
type StructA struct {
i32 int32 // 4 bytes
s string // 16 bytes
b bool // 1 bytes
}
type StructAOptimized* struct {
i32 int32 // 4 bytes
b bool // 1 bytes
s string // 16 bytes
}
func main() {
a := StructA{}
aOptimized := StructAOptimized{}
fmt.Println("Tamanho da StructA: ", unsafe.Sizeof(a))
fmt.Println("Tamanho da StructA Otimizada: ", unsafe.Sizeof(aOptimized))
}
O retorno desse script será:
Tamanho da StructA: 32
Tamanho da StructA Otimizada: 24
Percebam que a única mudança da StructA para a StructAOptimized foi que alteremos a ordem entre o campo booleano e o campo string, apenas com esse pequeno ajuste reduzimos o tamanho alocado da struct em memória em 8 bytes. Mas por que isso ocorre?
O Data Alignment realiza o alinhamento dos dados em memória de maneira sequencial, portanto quando temos a estrutura da StructAOptimized, nossas células de memória estarão alinhadas dessa forma:
0x00 | i32Byte | i32Byte | i32Byte | i32Byte | boolByte |-|-|-|
0x01 | sByte | sByte| sByte| sByte| sByte | sByte | sByte | sByte |
0x02 | sByte | sByte| sByte| sByte| sByte | sByte | sByte | sByte |
Percebam que como nossa variável do tipo int32 ocupa somente 4 bytes, há ainda 4 bytes livres no mesmo espaço de alocamento. Quando a segunda variável a ser alocado é uma string, o Data Alignment começa a alocar os bytes da nossa string em uma nova estrutura, afim de otimizar o trabalho do processador, já que nossa variável string não acabe inteiramente nos 4 bytes que sobraram.
Agora, no caso da nossa StructAOptimized o segundo campo a ser alocado é um boolean, que necessita de 1 byte, como é possível alocar todos os seus bytes no espaço restante do endereço 0x00, ele não precisa criar uma nova estrutura de 8 bytes.
Espero que esse conteúdo tenha sido útil pra você que leu até aqui. Lembrando que toda a exemplificação foi utilizando Go, mas o mesmo comportamento pode ser reproduzido em outras linguagens.
Deixei um repositório no Github com os testes realizados na explicação e mais uma demonstração com outras duas structs, você pode acessa-lo aqui: https://github.com/viniciusgabrielfo/go-struct-optimization-test
Referências:
Posted on May 22, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.