[C#] 구조체 정렬이 필드 유형이 기본인지 사용자 정의인지에 따라 달라지는 이유는 무엇입니까?

에서 노다 시간 V2, 우리는 나노초 해상도로 이동하고 있습니다. 이는 우리가 관심있는 전체 시간 범위를 나타 내기 위해 더 이상 8 바이트 정수를 사용할 수 없음을 의미합니다. 이로 인해 Noda Time의 (많은) 구조체의 메모리 사용량을 조사하게되었고 결과적으로 저를 이끌었습니다. CLR의 정렬 결정에서 약간의 이상한 점을 발견했습니다.

첫째, 나는 이것이 실현 이다 기본 동작은 언제든지 변경 될 수 있습니다 구현 결정하고있다. 나는 것을 깨닫게 사용하여 수정 [StructLayout]하고 [FieldOffset],하지만 난 오히려 가능하면이 필요하지 않은 솔루션을 가지고 올 것입니다.

내 핵심 시나리오는 struct참조 유형 필드와 두 개의 다른 값 유형 필드를 포함하는이 필드에 대한 간단한 래퍼입니다 int. 나는 그것이 64 비트 CLR에서 16 바이트로 표현 되기를 바랐 지만 (참조 용으로 8 바이트, 나머지 용으로 4 바이트) 어떤 이유로 24 바이트를 사용하고 있습니다. 그런데 저는 배열을 사용하여 공간을 측정하고 있습니다. 상황에 따라 레이아웃이 다를 수 있다는 것을 이해하지만 이것은 합리적인 시작점처럼 느껴졌습니다.

다음은 문제를 보여주는 샘플 프로그램입니다.

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

그리고 내 랩톱의 컴파일 및 출력 :

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

그래서:

  • 참조 유형 필드가없는 경우 CLR은 Int32Wrapper필드를 함께 압축 할 수 있습니다 ( TwoInt32Wrappers크기는 8).
  • 참조 유형 필드가 있어도 CLR은 여전히 int필드를 함께 압축 할 수 있습니다 ( RefAndTwoInt32s크기는 16).
  • 두 가지를 결합하면 각 Int32Wrapper필드가 8 바이트로 채워지거나 정렬 된 것처럼 보입니다. ( RefAndTwoInt32Wrappers크기는 24입니다.)
  • 디버거에서 동일한 코드를 실행하면 (하지만 여전히 릴리스 빌드) 크기가 12로 표시됩니다.

몇 가지 다른 실험에서도 비슷한 결과가 나왔습니다.

  • 값 유형 필드 뒤에 참조 유형 필드를 두는 것은 도움이되지 않습니다.
  • object대신 사용 하는 string것은 도움 이 되지 않습니다 ( “모든 참조 유형”일 것으로 예상).
  • 참조 주위의 “래퍼”로 다른 구조체를 사용하는 것은 도움이되지 않습니다.
  • 참조 주위의 래퍼로 일반 구조체를 사용하는 것은 도움이되지 않습니다.
  • 필드를 계속 추가하면 (단순성을 위해 쌍으로) int필드는 여전히 4 바이트로 Int32Wrapper계산 되고 필드는 8 바이트로 계산됩니다.
  • [StructLayout(LayoutKind.Sequential, Pack = 4)]시야에있는 모든 구조체에 추가해도 결과가 변경되지는 않습니다.

누구든지 이에 대한 설명 (이상적으로는 참조 문서 포함)이 있거나 상수 필드 오프셋 지정 하지 않고 필드를 압축하고 싶다는 CLR에 대한 힌트를 얻을 수있는 방법에 대한 제안이 있습니까?



답변

나는 이것이 버그라고 생각합니다. 자동 레이아웃의 부작용이 있습니다. 64 비트 모드에서 8 바이트의 배수 인 주소에 중요하지 않은 필드를 정렬하는 것을 좋아합니다. [StructLayout(LayoutKind.Sequential)]속성 을 명시 적으로 적용한 경우에도 발생 합니다. 그것은 일어나지 않아야합니다.

구조체 멤버를 공개하고 다음과 같이 테스트 코드를 추가하여 확인할 수 있습니다.

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

중단 점에 도달하면 Debug + Windows + Memory + Memory 1을 사용합니다. 4 바이트 정수로 전환 &test하고 Address 필드에 입력합니다.

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0내 컴퓨터의 문자열 포인터입니다 (당신의 것이 아닙니다). 쉽게 볼 수 있습니다Int32Wrappers크기를 24 바이트로 바꾸는 추가 4 바이트의 패딩으로을 . 구조체로 돌아가서 문자열을 마지막에 넣으십시오. 반복하면 문자열 포인터가 여전히 첫 번째 임을 알 수 있습니다 . 위반 LayoutKind.Sequential, 당신은 LayoutKind.Auto.

마이크로 소프트가이 문제를 고치도록 설득하는 것은 어려울 것입니다. 너무 오랫동안 이런 식으로 작동했기 때문에 어떤 변화도 깨질 것입니다. 무언가를 뜨리게 것 입니다. CLR [StructLayout]은 struct의 관리 버전 을 존중 하고 blittable로 만들 려고 시도 할 뿐이며 일반적으로 빠르게 포기합니다. DateTime을 포함하는 모든 구조체에 대해 유명합니다. 구조체를 마샬링 할 때만 진정한 LayoutKind 보장을받습니다. 마샬링 된 버전은 확실히 16 바이트 Marshal.SizeOf()입니다.

LayoutKind.Explicit당신이 듣고 싶었던 것이 아니라 그것을 고쳐 사용 하십시오.


답변

EDIT2

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

이 코드는 8 바이트로 정렬되므로 구조체는 16 바이트를 갖게됩니다. 비교하면 다음과 같습니다.

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

4 바이트로 정렬되므로이 구조체도 16 바이트를 갖게됩니다. 따라서 여기의 이론적 근거는 CLR의 구조체 결합이 가장 많이 정렬 된 필드의 수에 의해 결정된다는 것입니다. clases는 분명히 그렇게 할 수 없으므로 8 바이트 정렬 상태로 유지됩니다.

이제 모든 것을 결합하고 구조체를 생성하면 :

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

24 바이트 {x, y}는 각각 4 바이트, {z, s}는 8 바이트입니다. 구조체에 ref 유형을 도입하면 CLR은 항상 사용자 지정 구조체를 클래스 정렬과 일치하도록 정렬합니다.

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

Int32Wrapper는 길이와 동일하게 정렬되므로이 코드는 24 바이트를 갖게됩니다. 따라서 사용자 지정 구조체 래퍼는 항상 구조에서 가장 높은 / 가장 정렬 된 필드 또는 자체 내부 가장 중요한 필드에 정렬됩니다. 따라서 8 바이트 정렬 된 참조 문자열의 경우 구조체 래퍼가 이에 정렬됩니다.

구조체 내부의 최종 사용자 지정 구조체 필드는 항상 구조체에서 가장 높게 정렬 된 인스턴스 필드에 정렬됩니다. 이제 이것이 버그인지 확실하지 않지만 증거가 없으면 이것이 의식적인 결정일 수 있다는 내 의견을 고수 할 것입니다.


편집하다

크기는 실제로 힙에 할당 된 경우에만 정확하지만 구조체 자체의 크기는 더 작습니다 (필드의 정확한 크기). 추가 분석을 통해 이것이 CLR 코드의 버그 일 수 있지만 증거로 백업해야 함을 제안합니다.

나는 cli 코드를 검사하고 유용한 것이 발견되면 추가 업데이트를 게시 할 것입니다.


이것은 .NET mem 할당 자에서 사용하는 정렬 전략입니다.

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

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

    Console.ReadKey();
}

x64에서 .net40으로 컴파일 된이 코드는 WinDbg에서 다음을 수행 할 수 있습니다.

먼저 힙에서 유형을 찾을 수 있습니다.

    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

일단 우리가 그 주소 아래에 무엇이 있는지 볼 수 있습니다.

    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

이것이 ValueType이고 우리가 생성 한 것임을 알 수 있습니다. 이것이 배열이기 때문에 배열에있는 단일 요소의 ValueType def를 가져와야합니다.

    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

16 바이트가 패딩 용으로 예약되어 있으므로 구조는 실제로 32 바이트이므로 실제로 모든 구조는 처음부터 최소 16 바이트 크기입니다.

int와 문자열 참조에서 16 바이트를 추가하면 0000000003e72d18 + 8 바이트 EE / 패딩이됩니다. 0000000003e72d30이되고 이것은 문자열 참조의 시작 지점이며 모든 참조는 첫 번째 실제 데이터 필드에서 8 바이트가 채워 지므로 이것은이 구조의 32 바이트를 차지합니다.

문자열이 실제로 그렇게 채워져 있는지 살펴 보겠습니다.

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

이제 위의 프로그램을 같은 방식으로 분석해 보겠습니다.

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

이제 구조체는 48 바이트입니다.

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

여기서 상황은 동일합니다. 0000000003c22d18 + 8 바이트의 문자열 참조를 추가하면 값이 실제로 우리가있는 주소를 가리키는 첫 번째 Int 래퍼의 시작 부분에서 끝납니다.

이제 각 값이 객체 참조임을 다시 확인할 수 있습니다. 0000000003c22d20을 살펴보면서 확인할 수 있습니다.

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

실제로 그것이 obj 또는 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

따라서 실제로 이것은 이번에 8 바이트 정렬되는 Union 유형과 더 비슷합니다 (모든 패딩은 부모 구조체와 정렬 됨). 그렇지 않다면 20 바이트로 끝날 것이고 그것은 최적이 아니므로 mem 할당자는 결코 그것을 허용하지 않을 것입니다. 다시 수학을하면 구조체의 크기가 실제로 40 바이트임을 알 수 있습니다.

따라서 메모리를 좀 더 보수적으로 사용하려면 구조체 사용자 지정 구조체 형식으로 압축하지 말고 대신 간단한 배열을 사용하십시오. 또 다른 방법은 힙 (예 : VirtualAllocEx)에서 메모리를 할당하는 것입니다. 이렇게하면 고유 한 메모리 블록이 제공되고 원하는 방식으로 관리 할 수 ​​있습니다.

여기서 마지막 질문은 왜 갑자기 우리가 그런 레이아웃을 얻을 수 있는지입니다. int [] 증분의 지 티드 코드와 성능을 struct []와 카운터 필드 증분과 비교하면 두 번째는 유니온이되는 8 바이트 정렬 주소를 생성하지만 지팅하면 더 최적화 된 어셈블리 코드로 변환됩니다 (단일 LEA 대 다중 MOV). 그러나 여기에 설명 된 경우 성능이 실제로 더 나빠질 것이므로 여러 필드를 가질 수있는 사용자 지정 형식이므로 기본 CLR 구현과 일치하므로 시작 주소 대신 시작 주소를 입력하는 것이 더 쉬울 수 있습니다. value (불가능하기 때문에) 그리고 거기에서 구조체 패딩을 수행하여 더 큰 바이트 크기를 만듭니다.


답변

요약은 아마도 위의 @Hans Passant의 답변을 참조하십시오. Layout Sequential이 작동하지 않습니다.


일부 테스트 :

그것은 확실히 64 비트에만 있고 객체 참조는 구조체를 “독”시킵니다. 32 비트는 당신이 기대하는 것을합니다 :

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

객체 참조가 추가 되 자마자 모든 구조체는 4 바이트 크기가 아닌 8 바이트로 확장됩니다. 테스트 확장 :

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

참조가 추가 되 자마자 모든 Int32Wrapper가 8 바이트가되므로 단순한 정렬이 아닙니다. LoH 할당이 다르게 정렬 된 경우 배열 할당을 줄였습니다.


답변

믹스에 데이터를 추가하기 위해 보유한 유형에서 하나 이상의 유형을 만들었습니다.

struct RefAndTwoInt32Wrappers2
{
    string text;
    TwoInt32Wrappers z;
}

프로그램은 다음을 기록합니다.

RefAndTwoInt32Wrappers2: 16

따라서 TwoInt32Wrappers구조체가 새 RefAndTwoInt32Wrappers2구조체 에서 제대로 정렬 된 것처럼 보입니다 .


답변