Desempenho - Economizando memória em Structs com StructLayout

William Santos - Apr 18 '21 - - Dev Community

Olá, este é um artigo da seção Desempenho e, desta vez, vamos falar sobre StructLayout, uma forma de organização dos campos de uma struct que permite economizar memória.

Antes de começarmos, vale lembrar a razão pela qual utilizamos structs. Structs, de forma resumida, são value types, que são alocados em um espaço de memória não monitorado pelo Garbage Collector chamado stack, e tem um ciclo de vida restrito ao método onde foram declarados ou recebidos como argumentos.

Portanto, utilizar structs representa uma economia de memória do ponto de vista da aplicação, e de processamento do ponto de vista do runtime, já que o Garbage Collector não sofrerá pressão por elas e, consequentemente, será menos acionado.

Isto dito, vamos entender como structs são alinhadas na memória.

Alinhamento em Memória

O que é alinhamento em memória? É a forma como o compilador distribui os bytes que serão alocados para a sua struct na memória. Por padrão, strcuts são alinhadas em pacotes cujo tamaho é dado de acordo com o tamanho do maior campo presente. Ou seja, se o maior campo de uma struct for um inteiro de 64bit, que tem 8 bytes, os campos de sua struct serão alinhados em blocos de 8 bytes.

Imagine a seguinte struct:

namespace Lab.Desempenho.StructLayout
{
    public struct Struct1
    {
        public byte Byte;
        public int Int;
        public long Long;
    }
}
Enter fullscreen mode Exit fullscreen mode

Seu alinhamento em memória seria o seguinte:

Size: 16 bytes. Paddings: 3 bytes (%18 of empty space)
|===============================|
|     0: Byte Byte (1 byte)   |
|-------------------------------|
|   1-3: padding (3 bytes)      |
|-------------------------------|
|   4-7: Int32 Int (4 bytes)  |
|-------------------------------|
|  8-15: Int64 Long (8 bytes) |
|===============================|
Enter fullscreen mode Exit fullscreen mode

Repare no seguinte: lendo do final do para o começo da tabela acima, temos nesta struct um campo long (inteiro de 64 bits, com 8 bytes), e ele determina o tamanho dos blocos de alinhamento em memória da struct por padrão por ser o maior campo. Acima dele, temos 4 bytes de nosso campo int (4 bytes), um padding (3 bytes) e nosso campo byte (1 byte).

Agora, você pode estar se perguntando, o que é esse padding? Ele é o responsável por garantir que nossa struct será alinhada em blocos de 8 bytes. Ou seja, caso em vez de um int e um byte tivéssemos dois byte, esse padding seria de 6 bytes em vez de 3, pois 6 bytes seria a diferença do tamanho do bloco, 8 bytes, para os 2 bytes dos campos byte declarados.

Um detalhe muito interessante aqui é que a ordem dos campos importa, já que o alinhamento também é dado por ele. Então, veja este outro exemplo com a mesma struct, mas trocando a ordem de nosso campo long com nosso campo int:

namespace Lab.Desempenho.StructLayout
{
    public struct Struct1
    {
        public byte Byte;
        public long Long;
        public int Int;
    }
}
Enter fullscreen mode Exit fullscreen mode

Seu alinhamento passaria a ser o seguinte:

Size: 24 bytes. Paddings: 11 bytes (%45 of empty space)
|=============================|
|     0: Byte Byte (1 byte)   |
|-----------------------------|
|   1-7: padding (7 bytes)    |
|-----------------------------|
|  8-15: Int64 Long (8 bytes) |
|-----------------------------|
| 16-19: Int32 Int (4 bytes)  |
|-----------------------------|
| 20-23: padding (4 bytes)    |
|=============================|
Enter fullscreen mode Exit fullscreen mode

Repare em um detalhe fundamental: não apenas a organização dos blocos mudou, como o tamanho da struct aumentou em 8 bytes!

Isso aconteceu porque, como o alinhamento é dado pelo campo de maior tamanho e pela ordem de declaração, foi necessário aumentar o primeiro padding de 3 para 7 bytes, para acompanhar o tamanho do campo long e, para que o campo int também o acompanhasse, foi necessário criar um novo padding com 4 bytes.

Ou seja, apenas por força da ordem de declaração de seus campos, sua struct ficou 11 bytes maior!

StructLayout: lidando com o alinhamento

Agora que entendemos como nossas structs são alinhadas na memória, a pergunta que fica é: como lidamos com isso e evitamos os paddings?

Existem duas formas de resolver este problema. Um deles, que você já deve ter imaginado, é organizar os campos em uma sequência que resulte na menor quantidade e tamanho possível de paddings. Mas há uma outra forma, fornecida pelo próprio .Net que nos ajuda com essa tarefa ingrata, o atributo StructLayout.

Com este atributo, podemos não apenas definir a ordem pela qual nossos campos serão distribuídos pelo compilador como, também, informar o tamanho do bloco de alinhamento, de modo a controlar a ocorrência de paddings.

Vejamos um exemplo com uma nova versão da mesma struct:

using System.Runtime.InteropServices;

namespace Lab.Desempenho.StructLayout
{
    [StructLayout(LayoutKind.Auto)]
    public struct Struct2
    {
        public byte Byte;
        public long Long;
        public int Int;
    }
}
Enter fullscreen mode Exit fullscreen mode

O resultado será o seguinte:

Size: 16 bytes. Paddings: 3 bytes (%18 of empty space)
|=============================|
|   0-7: Int64 Long (8 bytes) |
|-----------------------------|
|  8-11: Int32 Int (4 bytes)  |
|-----------------------------|
|    12: Byte Byte (1 byte)   |
|-----------------------------|
| 13-15: padding (3 bytes)    |
|=============================|
Enter fullscreen mode Exit fullscreen mode

Repare que, neste caso, usando o atributo StructLayout e informando o LayoutKind como Auto, o compilador se encarregou de organizar nossos campos do maior para o menor, utilizando o padding de 3 bytes apenas para compensar o restante para completar os 8 bytes de nosso long.

Ou seja, utilizando o atributo com esta configuração, nos livramos do trabalho para organizar nossos campos.

Legal. Né? Mas tem mais!

É possível não apenas determinar a ordem de alinhamento dos campos como, também, definir o tamanho do pacote de alinhamento.

Vejamos um novo exemplo:

using System.Runtime.InteropServices;

namespace Lab.Desempenho.StructLayout
{
    [StructLayout(LayoutKind.Sequential, Pack = 4)]
    public struct Struct3
    {
        public int Int;
        public long Long;
        public int Int2;
    }
}
Enter fullscreen mode Exit fullscreen mode

Repare que, aqui, temos o LayoutKind.Sequential, que significa que o alinhamento vai seguir a mesma ordem definida no código (que é a opção padrão). Além disso, introduzimos uma propriedade chamada Pack, que é o tamanho do pacote de alinhamento dos bytes de nossos campos, com o valor 4. Isso significa que serão criados blocos com 4 bytes para alinhar nossos campos, e não mais 8 como seria o padrão dado pelo campo de maior tamanho (nosso, long).

Portanto, o resultado será o seguinte:

Size: 16 bytes. Paddings: 0 bytes (%0 of empty space)
|=============================|
|   0-3: Int32 Int (4 bytes)  |
|-----------------------------|
|  4-11: Int64 Long (8 bytes) |
|-----------------------------|
| 12-15: Int32 Int2 (4 bytes) |
|=============================|
Enter fullscreen mode Exit fullscreen mode

E voi lá! Temos apenas os 16 bytes de nossos campos sendo alinhados. Nosso long é entendido como uma sequência com dois blocos com 4 bytes cada, e isso permite que nossa struct evite dos dois paddings com 4 bytes que seriam criados caso a propriedade Pack não tivesse sido definida.

Vamos colocar a afirmação acima a prova?

A struct abaixo tem a mesma distribuição de campos do exemplo acima, mas dispensa o atributo StructLayout e, por consequência, a propriedade Pack (lembrando que a ausência do atributo tem o mesmo efeito de declarar seu LayoutKind como Sequential):

namespace Lab.Desempenho.StructLayout
{
    public struct Struct4
    {
        public int Int;
        public long Long;
        public int Int2;
    }
}
Enter fullscreen mode Exit fullscreen mode

Veja o resultado abaixo: temos agora não mais 16 bytes, mas 24. Isso porque a ausência da propriedade Pack de StructLayout fez retornar o comportamento padrão, que é definir o tamanho do pacote a partir do campo de maior tamanho.

Size: 24 bytes. Paddings: 8 bytes (%33 of empty space)
|=============================|
|   0-3: Int32 Int (4 bytes)  |
|-----------------------------|
|   4-7: padding (4 bytes)    |
|-----------------------------|
|  8-15: Int64 Long (8 bytes) |
|-----------------------------|
| 16-19: Int32 Int2 (4 bytes) |
|-----------------------------|
| 20-23: padding (4 bytes)    |
|=============================|
Enter fullscreen mode Exit fullscreen mode

Conclusão:

Pudemos observar como um detalhe simples, como a forma como distribuímos os campos em nossas structs pode afetar seu tamanho final e, por consequência, o desempenho de nossa aplicação. Sem dúvida uma questão tão interessante quanto pouco conhecida.

Mas tão interessante quando conhecer este comportamento do alinhamento das structs, é compreender os comportamentos do compilador do C#. Conhecer a maneira como ele traduz nosso código nos dá uma visão de como promover otimizações em nosso código com um mínimo de esforço.

Em fututos posts da seção Desempenho traremos mais detalhes sobre como podemos extrair maior velocidade de execução e maior economia de recursos utilizando pequenos truques, sejam eles a partir de ferramentas do próprio .Net como neste caso, ou através de simples mudanças na escrita de nosso código.

Como de costume, segue um projeto de exemplo no Github para verificar os resultados obtidos no post. Para a geração destes relatórios é utilizado um componente muito interessante, o ObjectLayoutInspector.

Gostou? Me deixe saber pelos indicadores. Tem dúvidas ou sugestões? Deixe um comentário ou me procure pelas redes sociais.

Até a próxima!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .