¿Por qué la alineación de estructura depende de si un tipo de campo es primitivo o está definido por el usuario?

121

En Noda Time v2, nos estamos moviendo a una resolución de nanosegundos. Eso significa que ya no podemos usar un número entero de 8 bytes para representar todo el rango de tiempo que nos interesa. Eso me ha llevado a investigar el uso de memoria de las (muchas) estructuras de Noda Time, lo que a su vez me ha llevado para descubrir una ligera rareza en la decisión de alineación del CLR.

En primer lugar, me doy cuenta de que esta es una decisión de implementación y que el comportamiento predeterminado podría cambiar en cualquier momento. Me doy cuenta de que puedo modificarlo usando [StructLayout]y [FieldOffset], pero prefiero encontrar una solución que no requiera eso si es posible.

Mi escenario principal es que tengo un campo structque contiene un campo de tipo de referencia y otros dos campos de tipo de valor, donde esos campos son envoltorios simples int. Había esperado que eso puede representar como 16 bytes en el CLR de 64 bits (8 de la referencia y 4 para cada uno de los otros), pero por alguna razón se trata de utilizar 24 bytes. Por cierto, estoy midiendo el espacio usando matrices: entiendo que el diseño puede ser diferente en diferentes situaciones, pero esto se sintió como un punto de partida razonable.

Aquí hay un programa de muestra que demuestra el 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);
    }
}

Y la compilación y salida en mi computadora portátil:

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

Entonces:

  • Si no tiene un campo de tipo de referencia, el CLR se complace en Int32Wrapperagrupar los campos ( TwoInt32Wrapperstiene un tamaño de 8)
  • Incluso con un campo de tipo de referencia, el CLR sigue contento de intagrupar campos ( RefAndTwoInt32stiene un tamaño de 16)
  • Combinando los dos, cada Int32Wrappercampo parece estar relleno / alineado a 8 bytes. ( RefAndTwoInt32Wrapperstiene un tamaño de 24)
  • Ejecutar el mismo código en el depurador (pero aún una versión de lanzamiento) muestra un tamaño de 12.

Algunos otros experimentos han arrojado resultados similares:

  • Poner el campo de tipo de referencia después de los campos de tipo de valor no ayuda
  • Usar en objectlugar de stringno ayuda (espero que sea "cualquier tipo de referencia")
  • Usar otra estructura como "envoltorio" alrededor de la referencia no ayuda
  • Usar una estructura genérica como envoltorio alrededor de la referencia no ayuda
  • Si sigo agregando campos (en pares para simplificar), los intcampos aún cuentan para 4 bytes y los Int32Wrappercampos cuentan para 8 bytes
  • Agregar [StructLayout(LayoutKind.Sequential, Pack = 4)]a cada estructura a la vista no cambia los resultados

¿Alguien tiene alguna explicación para esto (idealmente con documentación de referencia) o una sugerencia de cómo puedo dar una pista al CLR de que me gustaría que los campos se empaqueten sin especificar un desplazamiento de campo constante?

Jon Skeet
fuente
1
En realidad, no parece estar utilizando, Ref<T>sino que lo está utilizando string, no es que deba marcar la diferencia.
tvanfosson
2
¿Qué sucede si pones dos y creas una estructura con dos TwoInt32Wrappers, o an Int64y a TwoInt32Wrappers? ¿Qué tal si creas un genérico Pair<T1,T2> {public T1 f1; public T2 f2;}y luego creas Pair<string,Pair<int,int>>y Pair<string,Pair<Int32Wrapper,Int32Wrapper>>? ¿Qué combinaciones obligan al JITter a rellenar las cosas?
supercat
77
@supercat: Probablemente sea mejor que copie el código y experimente por sí mismo, pero Pair<string, TwoInt32Wrappers> le da solo 16 bytes, por lo que eso resolvería el problema. Fascinante.
Jon Skeet
9
@SLaks: a veces, cuando una estructura se pasa al código nativo, Runtime copiará todos los datos a una estructura con un diseño diferente. Marshal.SizeOfdevolverá el tamaño de la estructura que se pasaría al código nativo, que no necesita tener ninguna relación con el tamaño de la estructura en el código .NET.
supercat
55
La observación interesante: Mono da resultados correctos. Entorno: CLR 4.0.30319.17020 en Unix 3.13.0.24 (64 bit) Int32Wrapper: 4 TwoInt32s: 8 TwoInt32Wrappers: 8 RefAndTwoInt32s: 16 RefAndTwoInt32Wrappers: 16
AndreyAkinshin

Respuestas:

85

Creo que esto es un error. Está viendo el efecto secundario del diseño automático, le gusta alinear campos no triviales a una dirección que es un múltiplo de 8 bytes en modo de 64 bits. Ocurre incluso cuando aplica explícitamente el [StructLayout(LayoutKind.Sequential)]atributo. Eso no se supone que suceda.

Puede verlo haciendo públicos los miembros de la estructura y agregando un código de prueba como este:

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

Cuando llegue el punto de interrupción, use Debug + Windows + Memory + Memory 1. Cambie a enteros de 4 bytes y coloque &testen el campo Dirección:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0es el puntero de cadena en mi máquina (no la tuya). Puede ver fácilmente el Int32Wrappers, con los 4 bytes adicionales de relleno que convirtió el tamaño en 24 bytes. Regrese a la estructura y coloque la cadena al final. Repita y verá que el puntero de cadena sigue siendo el primero. Violando LayoutKind.Sequential, tienes LayoutKind.Auto.

Será difícil convencer a Microsoft para que solucione esto, ha funcionado de esta manera durante demasiado tiempo, por lo que cualquier cambio va a romper algo . El CLR solo intenta honrar [StructLayout]la versión administrada de una estructura y hacer que se pueda borrar, en general se rinde rápidamente. Notoriamente para cualquier estructura que contenga un DateTime. Solo obtienes la verdadera garantía LayoutKind al organizar una estructura. La versión ordenada ciertamente es de 16 bytes, como Marshal.SizeOf()te diré.

El uso lo LayoutKind.Explicitarregla, no lo que querías escuchar.

Hans Passant
fuente
77
"Va a ser difícil convencer a Microsoft para que arregle esto, ha funcionado de esta manera durante demasiado tiempo, por lo que cualquier cambio va a romper algo". El hecho de que esto aparentemente no se manifieste en 32 bits o mono puede ayudar (según otros comentarios).
NPSF3000
La documentación de StructLayoutAttribute es bastante interesante. Básicamente, solo los tipos blittable se controlan a través de StructLayout en la memoria administrada. Interesante, nunca lo supe.
Michael Stum
@Soner no, no lo arregla. ¿Puso el diseño en ambos campos para compensar 8? Si es así, x e y son lo mismo y cambiar uno cambia el otro. Claramente no es lo que persigue Jon.
BartoszAdamczewski
La sustitución stringpor otro nuevo tipo de referencia ( class) al que se ha aplicado [StructLayout(LayoutKind.Sequential)]no parece cambiar nada. En la dirección opuesta, aplicando [StructLayout(LayoutKind.Auto)]a los struct Int32Wrappercambios el uso de memoria en TwoInt32Wrappers.
Jeppe Stig Nielsen
1
"Va a ser difícil convencer a Microsoft para que arregle esto, ha funcionado de esta manera durante demasiado tiempo, por lo que cualquier cambio va a romper algo". xkcd.com/1172
iCodeSometime
19

EDIT2

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

Este código estará alineado en 8 bytes, por lo que la estructura tendrá 16 bytes. En comparación esto:

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

Estará alineado en 4 bytes, por lo que esta estructura también tendrá 16 bytes. Entonces, la razón aquí es que la alineación de estructura en CLR está determinada por el número de campos más alineados, las clases obviamente no pueden hacer eso, por lo que permanecerán alineados en 8 bytes.

Ahora si combinamos todo eso y creamos struct:

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

Tendrá 24 bytes {x, y} tendrá 4 bytes cada uno y {z, s} tendrá 8 bytes. Una vez que introduzcamos un tipo de referencia en la estructura, CLR siempre alineará nuestra estructura personalizada para que coincida con la alineación de la clase.

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

Este código tendrá 24 bytes ya que Int32Wrapper estará alineado igual que siempre. Por lo tanto, el contenedor de estructura personalizado siempre se alineará con el campo más alto / mejor alineado de la estructura o con sus propios campos internos más significativos. Entonces, en el caso de una cadena de referencia que esté alineada con 8 bytes, el contenedor de estructura se alineará con eso.

El campo de estructura personalizado final dentro de la estructura siempre se alineará con el campo de instancia alineado más alto en la estructura. Ahora, si no estoy seguro de si esto es un error, pero sin alguna evidencia, voy a mantener mi opinión de que esta podría ser una decisión consciente.


EDITAR

Los tamaños son realmente precisos solo cuando se asignan en un montón, pero las propias estructuras tienen tamaños más pequeños (los tamaños exactos de sus campos). Un análisis más detallado sugiere que esto podría ser un error en el código CLR, pero debe respaldarse con evidencia.

Inspeccionaré el código cli y publicaré más actualizaciones si se encuentra algo útil.


Esta es una estrategia de alineación utilizada por el asignador de mem .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();
}

Este código compilado con .net40 bajo x64, en WinDbg hagamos lo siguiente:

Busquemos primero el tipo en el montón:

    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

Una vez que lo tengamos, veamos qué hay debajo de esa dirección:

    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 este es un ValueType y es el que creamos. Como se trata de una matriz, necesitamos obtener la definición de ValueType de un único elemento en la 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

La estructura es en realidad de 32 bytes ya que sus 16 bytes están reservados para el relleno, por lo que en realidad cada estructura tiene al menos 16 bytes de tamaño desde el principio.

si agrega 16 bytes de ints y una referencia de cadena a: 0000000003e72d18 + 8 bytes EE / relleno, terminará en 0000000003e72d30 y este es el punto de partida para la referencia de cadena, y dado que todas las referencias están rellenadas con 8 bytes desde su primer campo de datos real Esto compensa nuestros 32 bytes para esta estructura.

Veamos si la cadena está realmente acolchada de esa manera:

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  <<

Ahora analicemos el programa anterior de la misma manera:

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

Nuestra estructura es de 48 bytes ahora.

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

Aquí la situación es la misma, si agregamos a 0000000003c22d18 + 8 bytes de cadena de referencia terminaremos al comienzo del primer contenedor Int donde el valor realmente apunta a la dirección en la que estamos.

Ahora podemos ver que cada valor es una referencia de objeto nuevamente, confirmemos al mirar 0000000003c22d20.

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

En realidad, eso es correcto, ya que es una estructura, la dirección no nos dice nada si se trata de un obj o 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

Entonces, en realidad, esto es más parecido a un tipo de Unión que obtendrá 8 bytes alineados esta vez (todos los rellenos estarán alineados con la estructura principal). Si no fuera así, terminaríamos con 20 bytes y eso no es óptimo, por lo que el asignador de memoria nunca permitirá que suceda. Si vuelve a hacer los cálculos, resultará que la estructura tiene un tamaño de 40 bytes.

Entonces, si desea ser más conservador con la memoria, nunca debe empacarlo en un tipo de estructura personalizada de estructura, sino que debe usar matrices simples. Otra forma es asignar memoria fuera del montón (VirtualAllocEx, por ejemplo) de esta manera se le da su propio bloque de memoria y lo administra de la manera que desee.

La pregunta final aquí es por qué de repente podríamos obtener un diseño como ese. Bueno, si compara el código editado y el rendimiento de un incremento int [] con struct [] con un incremento de campo de contador, el segundo generará una dirección alineada de 8 bytes que es una unión, pero cuando se edita esto se traduce en un código de ensamblaje más optimizado (singe LEA vs MOV múltiple). Sin embargo, en el caso descrito aquí, el rendimiento será realmente peor, así que mi opinión es que esto es consistente con la implementación CLR subyacente, ya que es un tipo personalizado que puede tener múltiples campos, por lo que puede ser más fácil / mejor poner la dirección de inicio en lugar de un valor (ya que sería imposible) y estructurar el relleno allí, lo que resulta en un tamaño de byte más grande.

BartoszAdamczewski
fuente
1
Mirando esto yo mismo, el tamaño RefAndTwoInt32Wrappers no es de 32 bytes, es 24, que es lo mismo que se informa con mi código. Si mira en la vista de memoria en lugar de usar dumparray, y mira en la memoria una matriz con (digamos) 3 elementos con valores distinguibles, puede ver claramente que cada elemento consta de una referencia de cadena de 8 bytes y dos enteros de 8 bytes . Sospecho que dumparraymuestra los valores como referencias simplemente porque no sabe cómo mostrar los Int32Wrappervalores. Esas "referencias" apuntan a sí mismas; No son valores separados.
Jon Skeet
1
No estoy muy seguro de dónde obtienes el "relleno de 16 bytes", pero sospecho que puede ser porque estás viendo el tamaño del objeto de matriz, que será "16 bytes + cuenta * tamaño del elemento". Entonces, una matriz con el recuento 2 tiene un tamaño de 72 (16 + 2 * 24), que es lo que se dumparraymuestra.
Jon Skeet
@jon volcó su estructura y comprobó cuánto espacio ocupa en el montón? Normalmente, el tamaño de la matriz se mantiene al comienzo de la matriz, esto también se puede verificar.
BartoszAdamczewski
@jon el tamaño informado también contiene el desplazamiento de la cadena que comienza en 8. No creo que esos 8 bytes adicionales mencionados provengan de la matriz, ya que la mayoría de las cosas de la matriz residen antes de la dirección del primer elemento, pero comprobaré dos veces y comentar sobre eso.
BartoszAdamczewski
1
No, ThreeInt32Wrappers termina como 12 bytes, FourInt32Wrappers como 16, FiveInt32Wrappers como 20. No veo nada lógico en la adición de un campo de tipo de referencia que cambie el diseño de manera tan drástica. Y tenga en cuenta que es bastante feliz ignorar la alineación de 8 bytes cuando los campos son de tipo Int32. No estoy demasiado preocupado por lo que hace en la pila, para ser honesto, pero no lo he comprobado.
Jon Skeet
9

Resumen ver la respuesta de @Hans Passant probablemente arriba. Secuencial de diseño no funciona


Algunas pruebas:

Definitivamente es solo en 64 bits y el objeto de referencia "envenena" la estructura. 32 bit hace lo que esperas:

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

Tan pronto como se agrega la referencia del objeto, todas las estructuras se expanden para tener 8 bytes en lugar de su tamaño de 4 bytes. Ampliando las pruebas:

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 puede ver tan pronto como se agrega la referencia, cada Int32Wrapper se convierte en 8 bytes, por lo que no es una simple alineación. Reduje la asignación de matriz en caso de que fuera una asignación de LoH que está alineada de manera diferente.

Ben Adams
fuente
4

Solo para agregar algunos datos a la mezcla: creé un tipo más de los que tenía:

struct RefAndTwoInt32Wrappers2
{
    string text;
    TwoInt32Wrappers z;
}

El programa escribe:

RefAndTwoInt32Wrappers2: 16

Por lo tanto, parece que la TwoInt32Wrappersestructura se alinea correctamente en la nueva RefAndTwoInt32Wrappers2estructura.

Jesse C. Slicer
fuente
¿Estás ejecutando 64 bit? La alineación está bien en 32 bits
Ben Adams
Mis hallazgos son los mismos que los de los demás entornos.
Jesse C. Slicer