Por que o alinhamento de estrutura depende se um tipo de campo é primitivo ou definido pelo usuário?

121

No Noda Time v2, estamos mudando para a resolução em nanossegundos. Isso significa que não podemos mais usar um número inteiro de 8 bytes para representar todo o intervalo de tempo em que estamos interessados. Isso me levou a investigar o uso de memória das (muitas) estruturas do Noda Time, o que, por sua vez, me levou para descobrir uma pequena estranheza na decisão de alinhamento do CLR.

Em primeiro lugar, percebo que essa é uma decisão de implementação e que o comportamento padrão pode mudar a qualquer momento. Sei que posso modificá-lo usando [StructLayout]e [FieldOffset], mas prefiro criar uma solução que não exija isso, se possível.

Meu cenário principal é que eu tenho um structque contém um campo de tipo de referência e dois outros campos de tipo de valor, nos quais esses campos são invólucros simples int. Eu esperava que isso fosse representado como 16 bytes no CLR de 64 bits (8 para a referência e 4 para cada um dos outros), mas por algum motivo, ele está usando 24 bytes. A propósito, estou medindo o espaço usando matrizes - entendo que o layout pode ser diferente em situações diferentes, mas isso parecia um ponto de partida razoável.

Aqui está um exemplo de programa demonstrando o problema:

using System;
using System.Runtime.InteropServices;

#pragma warning disable 0169

struct Int32Wrapper
{
    int x;
}

struct TwoInt32s
{
    int x, y;
}

struct TwoInt32Wrappers
{
    Int32Wrapper x, y;
}

struct RefAndTwoInt32s
{
    string text;
    int x, y;
}

struct RefAndTwoInt32Wrappers
{
    string text;
    Int32Wrapper x, y;
}    

class Test
{
    static void Main()
    {
        Console.WriteLine("Environment: CLR {0} on {1} ({2})",
            Environment.Version,
            Environment.OSVersion,
            Environment.Is64BitProcess ? "64 bit" : "32 bit");
        ShowSize<Int32Wrapper>();
        ShowSize<TwoInt32s>();
        ShowSize<TwoInt32Wrappers>();
        ShowSize<RefAndTwoInt32s>();
        ShowSize<RefAndTwoInt32Wrappers>();
    }

    static void ShowSize<T>()
    {
        long before = GC.GetTotalMemory(true);
        T[] array = new T[100000];
        long after  = GC.GetTotalMemory(true);        
        Console.WriteLine("{0}: {1}", typeof(T),
                          (after - before) / array.Length);
    }
}

E a compilação e saída no meu laptop:

c:\Users\Jon\Test>csc /debug- /o+ ShowMemory.cs
Microsoft (R) Visual C# Compiler version 12.0.30501.0
for C# 5
Copyright (C) Microsoft Corporation. All rights reserved.


c:\Users\Jon\Test>ShowMemory.exe
Environment: CLR 4.0.30319.34014 on Microsoft Windows NT 6.2.9200.0 (64 bit)
Int32Wrapper: 4
TwoInt32s: 8
TwoInt32Wrappers: 8
RefAndTwoInt32s: 16
RefAndTwoInt32Wrappers: 24

Assim:

  • Se você não tiver um campo de tipo de referência, o CLR terá prazer em Int32Wrapperagrupar os campos ( TwoInt32Wrapperstem um tamanho de 8)
  • Mesmo com um campo de tipo de referência, o CLR ainda pode empacotar os intcampos ( RefAndTwoInt32stem um tamanho de 16)
  • Combinando os dois, cada Int32Wrappercampo parece estar preenchido / alinhado a 8 bytes. ( RefAndTwoInt32Wrapperstem um tamanho de 24.)
  • A execução do mesmo código no depurador (mas ainda uma versão compilada) mostra um tamanho de 12.

Alguns outros experimentos produziram resultados semelhantes:

  • Colocar o campo do tipo de referência após os campos do tipo de valor não ajuda
  • Usar em objectvez de stringnão ajuda (espero que seja "qualquer tipo de referência")
  • Usar outra estrutura como um "wrapper" em torno da referência não ajuda
  • Usar uma estrutura genérica como invólucro em torno da referência não ajuda
  • Se eu continuar adicionando campos (em pares por simplicidade), os intcampos ainda contam com 4 bytes e os Int32Wrappercampos contam com 8 bytes
  • Adicionar [StructLayout(LayoutKind.Sequential, Pack = 4)]a todas as estruturas à vista não altera os resultados

Alguém tem alguma explicação para isso (idealmente com documentação de referência) ou uma sugestão de como eu posso sugerir ao CLR que gostaria que os campos fossem compactados sem especificar um deslocamento de campo constante?

Jon Skeet
fonte
1
Você realmente não parece estar usando, Ref<T>mas está usando string, não que isso faça diferença.
tvanfosson
2
O que acontece se você colocar dois e criar uma estrutura com dois TwoInt32Wrappers, ou um Int64e um TwoInt32Wrappers? Que tal se você criar um genérico Pair<T1,T2> {public T1 f1; public T2 f2;}e depois criar Pair<string,Pair<int,int>>e Pair<string,Pair<Int32Wrapper,Int32Wrapper>>? Quais combinações forçam o JITter a preencher as coisas?
Supercat
7
@supercat: É provavelmente o melhor para você copiar o código e experimentar por si mesmo - mas Pair<string, TwoInt32Wrappers> não dar apenas 16 bytes, de modo que iria resolver o problema. Fascinante.
21814 Jon Skeet
9
@ Slaks: Às vezes, quando uma estrutura é passada para o código nativo, o Runtime copia todos os dados para uma estrutura com um layout diferente. Marshal.SizeOfretornará o tamanho da estrutura que seria passada ao código nativo, que não precisa ter nenhuma relação com o tamanho da estrutura no código .NET.
Supercat
5
A observação interessante: Mono dá resultados corretos. Ambiente: CLR 4.0.30319.17020 em Unix 3.13.0.24 (64 bits) Int32Wrapper: 4 TwoInt32s: 8: 8 TwoInt32Wrappers RefAndTwoInt32s: 16 RefAndTwoInt32Wrappers: 16
AndreyAkinshin

Respostas:

85

Eu penso que isto é um erro. Você está vendo o efeito colateral do layout automático, ele gosta de alinhar campos não triviais a um endereço com múltiplos de 8 bytes no modo de 64 bits. Isso ocorre mesmo quando você aplica explicitamente o [StructLayout(LayoutKind.Sequential)]atributo. Isso não deveria acontecer.

Você pode vê-lo tornando os membros struct públicos e anexando o código de teste como este:

    var test = new RefAndTwoInt32Wrappers();
    test.text = "adsf";
    test.x.x = 0x11111111;
    test.y.x = 0x22222222;
    Console.ReadLine();      // <=== Breakpoint here

Quando o ponto de interrupção atingir, use Debug + Windows + Memory + Memory 1. Alterne para números inteiros de 4 bytes e coloque &testno campo Endereço:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0é o ponteiro de string na minha máquina (não na sua). Você pode ver facilmente Int32Wrappers, com os 4 bytes extras de preenchimento que transformaram o tamanho em 24 bytes. Volte para a estrutura e coloque a string por último. Repita e você verá que o ponteiro da string ainda é o primeiro. Violando LayoutKind.Sequential, você entendeu LayoutKind.Auto.

Vai ser difícil convencer a Microsoft a consertar isso, pois funcionou dessa maneira por muito tempo, portanto qualquer alteração quebrará alguma coisa . O CLR apenas tenta honrar [StructLayout]a versão gerenciada de uma estrutura e torná-la blittable, geralmente desiste rapidamente. Notoriamente para qualquer estrutura que contenha um DateTime. Você obtém a verdadeira garantia LayoutKind ao organizar uma estrutura. A versão empacotada certamente tem 16 bytes, como Marshal.SizeOf()será o caso.

O uso LayoutKind.Explicitcorrige, não o que você queria ouvir.

Hans Passant
fonte
7
"Vai ser difícil convencer a Microsoft a consertar isso, funcionou dessa maneira por muito tempo, para que qualquer mudança possa quebrar alguma coisa". O fato de que isso aparentemente não se manifeste em 32 bits ou mono pode ajudar (conforme outros comentários).
NPSF3000
A documentação do StructLayoutAttribute é bastante interessante. Basicamente, apenas tipos blittable são controlados através do StructLayout na memória gerenciada. Interessante, nunca soube disso.
Michael Stum
@ Sonon não, ele não corrige. Você colocou o Layout nos dois campos para compensar 8? Nesse caso, x e y são iguais e a alteração de uma muda a outra. Claramente não é o que Jon procura.
BartoszAdamczewski
Substituir stringpor outro novo tipo de referência ( class) ao qual se aplica [StructLayout(LayoutKind.Sequential)]não parece mudar nada. Na direção oposta, aplicar [StructLayout(LayoutKind.Auto)]às struct Int32Wrapperalterações no uso de memória TwoInt32Wrappers.
Jeppe Stig Nielsen
1
"Vai ser difícil convencer a Microsoft a consertar isso, funcionou dessa maneira por muito tempo, para que qualquer alteração esteja quebrando algo". xkcd.com/1172
iCodeSometime
19

EDIT2

struct RefAndTwoInt32Wrappers
{
    public int x;
    public string s;
}

Esse código será alinhado em 8 bytes, portanto a estrutura terá 16 bytes. Em comparação, isso:

struct RefAndTwoInt32Wrappers
{
    public int x,y;
    public string s;
}

Será alinhado em 4 bytes, portanto, essa estrutura também terá 16 bytes. Portanto, a lógica aqui é que o alinhamento de estrutura no CLR é determinado pelo número de campos mais alinhados, as classes obviamente não podem fazer isso, para que permaneçam alinhados por 8 bytes.

Agora, se combinarmos tudo isso e criar struct:

struct RefAndTwoInt32Wrappers
{
    public int x,y;
    public Int32Wrapper z;
    public string s;
}

Ele terá 24 bytes {x, y} terá 4 bytes cada e {z, s} terá 8 bytes. Depois que introduzirmos um tipo ref na estrutura, o CLR sempre alinhará nossa estrutura personalizada para corresponder ao alinhamento da classe.

struct RefAndTwoInt32Wrappers
{
    public Int32Wrapper z;
    public long l;
    public int x,y;  
}

Esse código terá 24 bytes, pois o Int32Wrapper será alinhado da mesma forma que o comprimento. Portanto, o wrapper de estrutura personalizado sempre se alinhará com o campo mais alto / melhor alinhado na estrutura ou com seus próprios campos internos mais significativos. Portanto, no caso de uma string de referência com 8 bytes alinhados, o wrapper struct será alinhado a isso.

O campo struct personalizado final dentro de struct sempre será alinhado ao campo de instância alinhado mais alto da estrutura. Agora, se eu não tenho certeza se isso é um bug, mas sem algumas evidências, vou manter minha opinião de que isso pode ser uma decisão consciente.


EDITAR

Na verdade, os tamanhos são precisos apenas quando alocados em uma pilha, mas as estruturas em si têm tamanhos menores (os tamanhos exatos dos seus campos). Uma análise mais aprofundada sugere que isso pode ser um bug no código CLR, mas precisa ser apoiado por evidências.

Vou inspecionar o código cli e postar mais atualizações se algo útil for encontrado.


Essa é uma estratégia de alinhamento usada pelo alocador de memórias .NET.

public static RefAndTwoInt32s[] test = new RefAndTwoInt32s[1];

static void Main()
{
    test[0].text = "a";
    test[0].x = 1;
    test[0].x = 1;

    Console.ReadKey();
}

Esse código compilado com .net40 em x64, no WinDbg, faça o seguinte:

Vamos encontrar o tipo no Heap primeiro:

    0:004> !dumpheap -type Ref
       Address               MT     Size
0000000003e72c78 000007fe61e8fb58       56    
0000000003e72d08 000007fe039d3b78       40    

Statistics:
              MT    Count    TotalSize Class Name
000007fe039d3b78        1           40 RefAndTwoInt32s[]
000007fe61e8fb58        1           56 System.Reflection.RuntimeAssembly
Total 2 objects

Assim que tivermos, vamos ver o que está sob esse endereço:

    0:004> !do 0000000003e72d08
Name:        RefAndTwoInt32s[]
MethodTable: 000007fe039d3b78
EEClass:     000007fe039d3ad0
Size:        40(0x28) bytes
Array:       Rank 1, Number of elements 1, Type VALUETYPE
Fields:
None

Vemos que esse é um ValueType e foi o que criamos. Como esta é uma matriz, precisamos obter a definição ValueType de um único elemento na matriz:

    0:004> !dumparray -details 0000000003e72d08
Name:        RefAndTwoInt32s[]
MethodTable: 000007fe039d3b78
EEClass:     000007fe039d3ad0
Size:        40(0x28) bytes
Array:       Rank 1, Number of elements 1, Type VALUETYPE
Element Methodtable: 000007fe039d3a58
[0] 0000000003e72d18
    Name:        RefAndTwoInt32s
    MethodTable: 000007fe039d3a58
    EEClass:     000007fe03ae2338
    Size:        32(0x20) bytes
    File:        C:\ConsoleApplication8\bin\Release\ConsoleApplication8.exe
    Fields:
                      MT    Field   Offset                 Type VT     Attr            Value Name
        000007fe61e8c358  4000006        0            System.String      0     instance     0000000003e72d30     text
        000007fe61e8f108  4000007        8             System.Int32      1     instance                    1     x
        000007fe61e8f108  4000008        c             System.Int32      1     instance                    0     y

A estrutura é na verdade de 32 bytes, já que seus 16 bytes são reservados para preenchimento; portanto, na realidade, toda estrutura possui pelo menos 16 bytes de tamanho desde o início.

se você adicionar 16 bytes de ints e uma string ref a: 0000000003e72d18 + 8 bytes EE / padding, você terminará em 0000000003e72d30 e este é o ponto de partida para a referência de string, e como todas as referências são preenchidas em 8 bytes no primeiro campo de dados real isso compensa nossos 32 bytes para essa estrutura.

Vamos ver se a string é realmente preenchida dessa maneira:

0:004> !do 0000000003e72d30    
Name:        System.String
MethodTable: 000007fe61e8c358
EEClass:     000007fe617f3720
Size:        28(0x1c) bytes
File:        C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
String:      a
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
000007fe61e8f108  40000aa        8         System.Int32  1 instance                1 m_stringLength
000007fe61e8d640  40000ab        c          System.Char  1 instance               61 m_firstChar
000007fe61e8c358  40000ac       18        System.String  0   shared           static Empty
                                 >> Domain:Value  0000000001577e90:NotInit  <<

Agora vamos analisar o programa acima da mesma maneira:

public static RefAndTwoInt32Wrappers[] test = new RefAndTwoInt32Wrappers[1];

static void Main()
{
    test[0].text = "a";
    test[0].x.x = 1;
    test[0].y.x = 1;

    Console.ReadKey();
}

0:004> !dumpheap -type Ref
     Address               MT     Size
0000000003c22c78 000007fe61e8fb58       56    
0000000003c22d08 000007fe039d3c00       48    

Statistics:
              MT    Count    TotalSize Class Name
000007fe039d3c00        1           48 RefAndTwoInt32Wrappers[]
000007fe61e8fb58        1           56 System.Reflection.RuntimeAssembly
Total 2 objects

Nossa estrutura é de 48 bytes agora.

0:004> !dumparray -details 0000000003c22d08
Name:        RefAndTwoInt32Wrappers[]
MethodTable: 000007fe039d3c00
EEClass:     000007fe039d3b58
Size:        48(0x30) bytes
Array:       Rank 1, Number of elements 1, Type VALUETYPE
Element Methodtable: 000007fe039d3ae0
[0] 0000000003c22d18
    Name:        RefAndTwoInt32Wrappers
    MethodTable: 000007fe039d3ae0
    EEClass:     000007fe03ae2338
    Size:        40(0x28) bytes
    File:        C:\ConsoleApplication8\bin\Release\ConsoleApplication8.exe
    Fields:
                      MT    Field   Offset                 Type VT     Attr            Value Name
        000007fe61e8c358  4000009        0            System.String      0     instance     0000000003c22d38     text
        000007fe039d3a20  400000a        8             Int32Wrapper      1     instance     0000000003c22d20     x
        000007fe039d3a20  400000b       10             Int32Wrapper      1     instance     0000000003c22d28     y

Aqui a situação é a mesma: se adicionarmos a 0000000003c22d18 + 8 bytes da string ref, terminaremos no início do primeiro wrapper Int, onde o valor realmente aponta para o endereço em que estamos.

Agora podemos ver que cada valor é uma referência a objeto novamente e vamos confirmar que, espreitando 0000000003c22d20.

0:004> !do 0000000003c22d20
<Note: this object has an invalid CLASS field>
Invalid object

Na verdade, isso está correto, já que é uma estrutura, o endereço não diz nada se for um obj ou vt.

0:004> !dumpvc 000007fe039d3a20   0000000003c22d20    
Name:        Int32Wrapper
MethodTable: 000007fe039d3a20
EEClass:     000007fe03ae23c8
Size:        24(0x18) bytes
File:        C:\ConsoleApplication8\bin\Release\ConsoleApplication8.exe
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
000007fe61e8f108  4000001        0         System.Int32  1 instance                1 x

Portanto, na realidade, isso é mais parecido com um tipo Union que terá 8 bytes alinhados desta vez (todos os preenchimentos serão alinhados com a estrutura pai). Se não fosse, teríamos 20 bytes e isso não é o ideal, para que o alocador de memórias nunca permita que isso aconteça. Se você fizer as contas novamente, a estrutura terá realmente 40 bytes de tamanho.

Portanto, se você deseja ser mais conservador com a memória, nunca deve empacotá-la em um tipo de estrutura personalizada struct, mas usar matrizes simples. Outra maneira é alocar memória fora do heap (VirtualAllocEx, por exemplo), dessa forma, você recebe seu próprio bloco de memória e o gerencia da maneira que desejar.

A questão final aqui é por que, de repente, podemos ter um layout assim. Bem, se você comparar o código jited e o desempenho de uma incremento int [] com struct [] com um incremento de campo de contador, o segundo gerará um endereço alinhado de 8 bytes sendo uma união, mas quando jited isso se traduzirá em um código de montagem mais otimizado (singe LEA vs MOV múltiplo). No entanto, no caso descrito aqui, o desempenho será realmente pior, portanto, acredito que isso seja consistente com a implementação subjacente do CLR, já que é um tipo personalizado que pode ter vários campos; portanto, é mais fácil / melhor colocar o endereço inicial em vez de um value (já que seria impossível) e faça o preenchimento de estruturas, resultando em um tamanho maior de bytes.

BartoszAdamczewski
fonte
1
Olhando para mim, o tamanho de RefAndTwoInt32Wrappers não é 32 bytes - é 24, o mesmo que o relatado no meu código. Se você olhar na exibição de memória em vez de usar dumparraye procurar na memória uma matriz com (digamos) 3 elementos com valores distinguíveis, poderá ver claramente que cada elemento consiste em uma referência de cadeia de 8 bytes e dois números inteiros de 8 bytes . Eu suspeito que dumparrayestá mostrando os valores como referências simplesmente porque não sabe como exibirInt32Wrapper valores. Essas "referências" apontam para si mesmas; eles não são valores separados.
Jon Skeet
1
Não sei ao certo de onde você obtém o "preenchimento de 16 bytes", mas suspeito que seja porque você está observando o tamanho do objeto da matriz, que será "16 bytes + contar * tamanho do elemento". Portanto, uma matriz com a contagem 2 tem um tamanho de 72 (16 + 2 * 24), que é o que dumparraymostra.
Jon Skeet
@ jon, você despejou sua estrutura e verificou quanto espaço ela ocupa na pilha? Normalmente, o tamanho da matriz é mantido no início da matriz, isso também pode ser verificado.
BartoszAdamczewski
@jon o tamanho relatado também contém o deslocamento da string que começa em 8. Eu não acho que esses 8 bytes extras mencionados venham da matriz, já que a maioria das coisas da matriz reside antes do endereço do primeiro elemento, mas vou checar e comentar sobre isso.
BartoszAdamczewski
1
Não, o ThreeInt32Wrappers acaba em 12 bytes, o FourInt32Wrappers como 16, o FiveInt32Wrappers como 20. Não vejo nada lógico sobre a adição de um campo de tipo de referência que altere o layout de maneira tão drástica. E observe que é um prazer ignorar o alinhamento de 8 bytes quando os campos são do tipo Int32. Não estou muito preocupado com o que faz na pilha, para ser sincero - mas ainda não o verifiquei.
22814 Jon Skeet
9

Resumo, veja a resposta de @Hans Passant provavelmente acima. Sequencial de layout não funciona


Alguns testes:

Definitivamente, é apenas em 64 bits e o objeto de referência "envenena" a estrutura. 32 bits faz o que você está esperando:

Environment: CLR 4.0.30319.34209 on Microsoft Windows NT 6.2.9200.0 (32 bit)
ConsoleApplication1.Int32Wrapper: 4
ConsoleApplication1.TwoInt32s: 8
ConsoleApplication1.TwoInt32Wrappers: 8
ConsoleApplication1.ThreeInt32Wrappers: 12
ConsoleApplication1.Ref: 4
ConsoleApplication1.RefAndTwoInt32s: 12
ConsoleApplication1.RefAndTwoInt32Wrappers: 12
ConsoleApplication1.RefAndThreeInt32s: 16
ConsoleApplication1.RefAndThreeInt32Wrappers: 16

Assim que a referência do objeto é adicionada, todas as estruturas se expandem para 8 bytes, em vez do tamanho de 4 bytes. Expandindo os testes:

Environment: CLR 4.0.30319.34209 on Microsoft Windows NT 6.2.9200.0 (64 bit)
ConsoleApplication1.Int32Wrapper: 4
ConsoleApplication1.TwoInt32s: 8
ConsoleApplication1.TwoInt32Wrappers: 8
ConsoleApplication1.ThreeInt32Wrappers: 12
ConsoleApplication1.Ref: 8
ConsoleApplication1.RefAndTwoInt32s: 16
ConsoleApplication1.RefAndTwoInt32sSequential: 16
ConsoleApplication1.RefAndTwoInt32Wrappers: 24
ConsoleApplication1.RefAndThreeInt32s: 24
ConsoleApplication1.RefAndThreeInt32Wrappers: 32
ConsoleApplication1.RefAndFourInt32s: 24
ConsoleApplication1.RefAndFourInt32Wrappers: 40

Como você pode ver, assim que a referência é adicionada, todo Int32Wrapper se torna 8 bytes, portanto não é um alinhamento simples. Eu reduzi a alocação de matriz, pois era a alocação de LoH que está alinhada de maneira diferente.

Ben Adams
fonte
4

Apenas para adicionar alguns dados à mistura - criei mais um tipo dos que você tinha:

struct RefAndTwoInt32Wrappers2
{
    string text;
    TwoInt32Wrappers z;
}

O programa escreve:

RefAndTwoInt32Wrappers2: 16

Portanto, parece que a TwoInt32Wrappersestrutura está alinhada corretamente na nova RefAndTwoInt32Wrappers2estrutura.

Jesse C. Slicer
fonte
Você está executando 64 bits? O alinhamento é bem em 32 bits
Ben Adams
Minhas descobertas são as mesmas de todos os outros para os vários ambientes.
Jesse C. Slicer