Pourquoi l'alignement de structure dépend-il du fait qu'un type de champ est primitif ou défini par l'utilisateur?

121

Dans Noda Time v2, nous passons à une résolution nanoseconde. Cela signifie que nous ne pouvons plus utiliser un entier de 8 octets pour représenter toute la plage de temps qui nous intéresse. Cela m'a incité à étudier l'utilisation de la mémoire des (nombreuses) structures de Noda Time, ce qui m'a conduit pour découvrir une légère bizarrerie dans la décision d'alignement du CLR.

Tout d'abord, je me rends compte qu'il s'agit d' une décision de mise en œuvre et que le comportement par défaut peut changer à tout moment. Je me rends compte que je peux le modifier en utilisant [StructLayout]et [FieldOffset], mais je préfère trouver une solution qui ne l'exige pas si possible.

Mon scénario principal est que j'ai un structqui contient un champ de type référence et deux autres champs de type valeur, où ces champs sont de simples wrappers pour int. J'avais espéré que cela serait représenté par 16 octets sur le CLR 64 bits (8 pour la référence et 4 pour chacun des autres), mais pour une raison quelconque, il utilise 24 octets. Je mesure l'espace à l'aide de tableaux, au fait - je comprends que la disposition peut être différente dans différentes situations, mais cela me semblait être un point de départ raisonnable.

Voici un exemple de programme illustrant le problème:

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);
    }
}

Et la compilation et la sortie sur mon ordinateur portable:

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

Alors:

  • Si vous n'avez pas de champ de type de référence, le CLR est heureux de Int32Wrapperregrouper les champs ( TwoInt32Wrappersa une taille de 8)
  • Même avec un champ de type de référence, le CLR est toujours heureux d'emballer int regrouper les champs ( RefAndTwoInt32sa une taille de 16)
  • En combinant les deux, chaque Int32Wrapperchamp semble être rempli / aligné sur 8 octets. (RefAndTwoInt32Wrappers a une taille de 24.)
  • L'exécution du même code dans le débogueur (mais toujours une version de version) affiche une taille de 12.

Quelques autres expériences ont donné des résultats similaires:

  • Mettre le champ de type de référence après les champs de type de valeur n'aide pas
  • Utiliser objectau lieu destring n'aide pas (je suppose que c'est "n'importe quel type de référence")
  • Utiliser une autre structure comme "wrapper" autour de la référence n'aide pas
  • Utiliser une structure générique comme wrapper autour de la référence n'aide pas
  • Si je continue d'ajouter des champs (par paires pour plus de simplicité), les intchamps comptent toujours pour 4 octets, etInt32Wrapper champs comptent pour 8 octets
  • L'ajout [StructLayout(LayoutKind.Sequential, Pack = 4)]à chaque structure en vue ne change pas les résultats

Quelqu'un a-t-il une explication à ce sujet (idéalement avec une documentation de référence) ou une suggestion sur la façon dont je peux obtenir un indice au CLR que je voudrais que les champs soient compilés sans spécifier un décalage de champ constant?

Jon Skeet
la source
1
Vous ne semblez pas réellement utiliser Ref<T>mais utilisez à la stringplace, non pas que cela devrait faire une différence.
tvanfosson le
2
Que se passe-t-il si vous mettez deux créer une structure avec deux TwoInt32Wrappers, ou un Int64et un TwoInt32Wrappers? Et si vous créez un générique Pair<T1,T2> {public T1 f1; public T2 f2;}puis créez Pair<string,Pair<int,int>>et Pair<string,Pair<Int32Wrapper,Int32Wrapper>>? Quelles combinaisons obligent le JITter à rembourrer les choses?
supercat du
7
@supercat: Il est probablement préférable pour vous de copier le code et d'expérimenter par vous-même - mais Pair<string, TwoInt32Wrappers> ne donne que 16 octets, ce qui résoudrait le problème. Fascinant.
Jon Skeet
9
@SLaks: Parfois, lorsqu'une structure est transmise au code natif, le Runtime copie toutes les données dans une structure avec une disposition différente. Marshal.SizeOfrenverra la taille de la structure qui serait passée au code natif, qui ne doit avoir aucune relation avec la taille de la structure dans le code .NET.
supercat du
5
L'observation intéressante: Mono donne des résultats corrects. Environnement: CLR 4.0.30319.17020 sous Unix 3.13.0.24 (64 bits) Int32Wrapper: 4 TwoInt32s: 8 TwoInt32Wrappers: 8 RefAndTwoInt32s: 16 RefAndTwoInt32Wrappers: 16
AndreyAkinshin

Réponses:

85

Je pense que c'est un bug. Vous voyez l'effet secondaire de la mise en page automatique, il aime aligner les champs non triviaux sur une adresse multiple de 8 octets en mode 64 bits. Cela se produit même lorsque vous appliquez explicitement le[StructLayout(LayoutKind.Sequential)] attribut. Ce n'est pas censé arriver.

Vous pouvez le voir en rendant les membres de la structure publics et en ajoutant du code de test comme ceci:

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

Lorsque le point d'arrêt atteint, utilisez Debug + Windows + Memory + Memory 1. Passez à des entiers de 4 octets et mettez &testdans le champ Adresse:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0est le pointeur de chaîne sur ma machine (pas la vôtre). Vous pouvez facilement voir leInt32Wrappers , avec les 4 octets supplémentaires de remplissage qui ont transformé la taille en 24 octets. Revenez à la structure et mettez la chaîne en dernier. Répétez et vous verrez que le pointeur de chaîne est toujours le premier. Violer LayoutKind.Sequential, vous avezLayoutKind.Auto .

Il sera difficile de convaincre Microsoft de résoudre ce problème, cela fonctionne de cette façon depuis trop longtemps, donc tout changement va être rompu quelque chose . Le CLR ne fait qu'une tentative d'honorer [StructLayout]la version gérée d'une structure et de la rendre blittable, il abandonne rapidement en général. Notoirement pour toute structure qui contient un DateTime. Vous n'obtenez la véritable garantie LayoutKind que lors du marshaling d'une structure. La version marshalée est certainement de 16 octets, comme Marshal.SizeOf()vous le direz.

Utiliser le LayoutKind.Explicitcorrige, pas ce que vous vouliez entendre.

Hans Passant
la source
7
"Il sera difficile de convaincre Microsoft de résoudre ce problème, cela fonctionne de cette façon depuis trop longtemps, donc tout changement va casser quelque chose." Le fait que cela ne se manifeste apparemment pas en 32 bits ou en mono peut aider (comme par d'autres commentaires).
NPSF3000
La documentation de StructLayoutAttribute est assez intéressante. Fondamentalement, seuls les types blittables sont contrôlés via StructLayout en mémoire gérée. Intéressant, je ne l'ai jamais su.
Michael Stum
@Soner non ça ne résout pas ça. Avez-vous mis la mise en page sur les deux champs pour être décalée 8? Si tel est le cas, alors x et y sont identiques et le fait de changer l'un change l'autre. Clairement pas ce que Jon cherche.
BartoszAdamczewski
Le remplacement stringpar un autre nouveau type de référence ( class) auquel on s'est appliqué [StructLayout(LayoutKind.Sequential)]ne semble rien changer. Dans la direction opposée, en appliquant [StructLayout(LayoutKind.Auto)]aux struct Int32Wrapperchangements l'utilisation de la mémoire dans TwoInt32Wrappers.
Jeppe Stig Nielsen
1
"Il va être difficile de convaincre Microsoft de résoudre ce problème, cela fonctionne de cette façon depuis trop longtemps, donc tout changement va casser quelque chose." xkcd.com/1172
iCodeSometime
19

MODIFIER2

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

Ce code sera aligné sur 8 octets, donc la structure aura 16 octets. Par comparaison ceci:

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

Sera aligné sur 4 octets, donc cette structure aura également 16 octets. Donc, la logique ici est que l'alignement de structure dans CLR est déterminé par le nombre de champs les plus alignés, les classes ne peuvent évidemment pas le faire, elles resteront donc alignées sur 8 octets.

Maintenant, si nous combinons tout cela et créons une structure:

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

Il aura 24 octets {x, y} aura 4 octets chacun et {z, s} aura 8 octets. Une fois que nous introduisons un type ref dans la structure, CLR alignera toujours notre structure personnalisée pour correspondre à l'alignement de classe.

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

Ce code aura 24 octets puisque Int32Wrapper sera aligné de la même manière que long. Ainsi, l'encapsuleur de structure personnalisé s'alignera toujours sur le champ le plus haut / le mieux aligné de la structure ou sur ses propres champs internes les plus significatifs. Donc, dans le cas d'une chaîne de référence alignée sur 8 octets, l'encapsuleur de structure s'alignera sur cela.

Le champ struct personnalisé final à l'intérieur de struct sera toujours aligné sur le champ d'instance aligné le plus haut de la structure. Maintenant, si je ne suis pas sûr qu'il s'agisse d'un bug, mais sans aucune preuve, je vais m'en tenir à mon opinion que cela pourrait être une décision consciente.


ÉDITER

Les tailles sont en fait précises uniquement lorsqu'elles sont allouées sur un tas, mais les structures elles-mêmes ont des tailles plus petites (les tailles exactes de ses champs). Une analyse plus approfondie suggère que cela pourrait être un bogue dans le code CLR, mais qu'il doit être soutenu par des preuves.

J'inspecterai le code cli et publierai d'autres mises à jour si quelque chose d'utile sera trouvé.


Il s'agit d'une stratégie d'alignement utilisée par l'allocateur de mémoire .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();
}

Ce code compilé avec .net40 sous x64, dans WinDbg permet de faire ce qui suit:

Permet de trouver d'abord le type sur le tas:

    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

Une fois que nous l'avons, voyons ce qu'il y a sous cette adresse:

    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

Nous voyons que c'est un ValueType et c'est celui que nous avons créé. Puisqu'il s'agit d'un tableau, nous devons obtenir le ValueType def d'un seul élément du tableau:

    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 structure est en fait de 32 octets car ses 16 octets sont réservés pour le remplissage, donc en réalité, chaque structure a une taille d'au moins 16 octets dès le départ.

si vous ajoutez 16 octets à partir des entiers et une chaîne de référence à: 0000000003e72d18 + 8 octets EE / padding, vous vous retrouverez à 0000000003e72d30 et c'est le point de départ pour la référence de chaîne, et puisque toutes les références sont remplies de 8 octets à partir de leur premier champ de données réel cela compense nos 32 octets pour cette structure.

Voyons si la chaîne est réellement remplie de cette façon:

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

Analysons maintenant le programme ci-dessus de la même manière:

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

Notre structure est maintenant de 48 octets.

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

Ici, la situation est la même, si nous ajoutons à 0000000003c22d18 + 8 octets de string ref, nous nous retrouverons au début du premier wrapper Int où la valeur pointe réellement vers l'adresse à laquelle nous nous trouvons.

Maintenant, nous pouvons voir que chaque valeur est une référence d'objet à nouveau, confirmons cela en jetant un œil à 0000000003c22d20.

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

En fait, c'est correct car c'est une structure, l'adresse ne nous dit rien s'il s'agit d'un obj ou d'un 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

Donc, en réalité, cela ressemble plus à un type Union qui obtiendra 8 octets alignés cette fois-ci (tous les bourrages seront alignés avec la structure parent). Si ce n'était pas le cas, nous nous retrouverions avec 20 octets et ce n'est pas optimal, donc l'allocateur de mémoire ne permettra jamais que cela se produise. Si vous refaites le calcul, il s'avérera que la structure a en effet une taille de 40 octets.

Donc, si vous voulez être plus conservateur avec la mémoire, vous ne devez jamais la mettre dans un type struct personnalisé struct mais utiliser à la place des tableaux simples. Une autre façon est d'allouer de la mémoire hors tas (VirtualAllocEx par exemple) de cette façon, vous disposez de votre propre bloc de mémoire et vous le gérez comme vous le souhaitez.

La dernière question ici est de savoir pourquoi tout à coup nous pourrions obtenir une mise en page comme celle-là. Eh bien, si vous comparez le code jited et les performances d'une incrémentation int [] avec struct [] avec une incrémentation de champ de compteur, le second générera une adresse alignée de 8 octets étant une union, mais lorsque jited cela se traduit par un code d'assemblage plus optimisé (singe LEA vs MOV multiple). Cependant, dans le cas décrit ici, les performances seront en fait pires, donc je pense que cela est cohérent avec l'implémentation CLR sous-jacente car c'est un type personnalisé qui peut avoir plusieurs champs, il peut donc être plus facile / meilleur de mettre l'adresse de départ au lieu d'un value (car ce serait impossible) et effectuez un remplissage de structure à cet endroit, ce qui entraîne une taille d'octet plus grande.

BartoszAdamczewski
la source
1
En regardant cela moi-même, la taille de RefAndTwoInt32Wrappers n'est pas de 32 octets - c'est 24, ce qui est le même que celui indiqué avec mon code. Si vous regardez dans la vue de la mémoire au lieu d'utiliser dumparray, et regardez la mémoire pour un tableau avec (disons) 3 éléments avec des valeurs distinctes, vous pouvez clairement voir que chaque élément se compose d'une référence de chaîne de 8 octets et de deux entiers de 8 octets . Je soupçonne que cela dumparraymontre les valeurs comme références simplement parce qu'il ne sait pas comment afficher les Int32Wrappervaleurs. Ces «références» se désignent elles-mêmes; ce ne sont pas des valeurs distinctes.
Jon Skeet
1
Je ne sais pas trop d'où vous obtenez le "remplissage de 16 octets", mais je suppose que c'est peut-être parce que vous regardez la taille de l'objet tableau, qui sera "16 octets + nombre * taille d'élément". Ainsi, un tableau avec le compte 2 a une taille de 72 (16 + 2 * 24), ce qui est dumparrayindiqué.
Jon Skeet
@jon avez-vous vidé votre structure et vérifié combien d'espace occupe-t-elle sur le tas? Normalement, la taille du tableau est conservée au début du tableau, cela peut également être vérifié.
BartoszAdamczewski
@jon la taille rapportée contient également le décalage de la chaîne qui commence à 8. Je ne pense pas que ces 8 octets supplémentaires mentionnés proviennent du tableau car la plupart des éléments du tableau se trouvent avant l'adresse du premier élément, mais je vais vérifier et commenter cela.
BartoszAdamczewski
1
Non, ThreeInt32Wrappers se termine par 12 octets, FourInt32Wrappers par 16, FiveInt32Wrappers par 20. Je ne vois rien de logique à propos de l'ajout d'un champ de type de référence modifiant la disposition de manière aussi drastique. Et notez qu'il est tout à fait heureux d'ignorer l'alignement de 8 octets lorsque les champs sont de type Int32. Je ne suis pas trop dérangé par ce qu'il fait sur la pile, pour être honnête - mais je ne l'ai pas vérifié.
Jon Skeet
9

Résumé voir la réponse de @Hans Passant probablement ci-dessus. La mise en page séquentielle ne fonctionne pas


Quelques tests:

Ce n'est certainement que sur 64 bits et la référence d'objet "empoisonne" la structure. 32 bits fait ce que vous attendez:

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

Dès que la référence d'objet est ajoutée, toutes les structures se développent pour être de 8 octets plutôt que leur taille de 4 octets. Élargir les tests:

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

Comme vous pouvez le voir dès que la référence est ajoutée, chaque Int32Wrapper devient 8 octets, ce n'est donc pas un simple alignement. J'ai réduit l'allocation du tableau au cas où il s'agissait d'une allocation LoH qui est alignée différemment.

Ben Adams
la source
4

Juste pour ajouter des données au mix - j'ai créé un autre type à partir de ceux que vous aviez:

struct RefAndTwoInt32Wrappers2
{
    string text;
    TwoInt32Wrappers z;
}

Le programme écrit:

RefAndTwoInt32Wrappers2: 16

Donc il ressemble au TwoInt32Wrappers structure s'aligne correctement dans la nouvelle RefAndTwoInt32Wrappers2structure.

Jesse C. Slicer
la source
Utilisez-vous 64 bits? L'alignement est bien en 32 bits
Ben Adams
Mes résultats sont les mêmes que tout le monde pour les différents environnements.
Jesse C. Slicer