Start Debugging

Что такое transparent-раскладка структуры и почему структура-обёртка с одним полем меняет соглашение о вызовах в .NET?

Transparent-структура - это структура, которую ABI обрабатывает точно так же, как её единственное поле. В .NET 11 такой гарантии нет: структура, оборачивающая double, передаётся в RCX в Windows x64, но в XMM0 в Linux и macOS. Здесь разобран дизассемблированный код JIT-компилятора .NET 11 для трёх ABI, ошибки P/Invoke, которые из этого возникают, и способы писать типы-обёртки, безопасные на любой границе.

Короткий ответ: “transparent layout” - это гарантия того, что структура ровно с одним полем размещается в памяти и передаётся при вызовах точно так же, как это поле. В Rust это записывается как #[repr(transparent)]. В .NET 11 такой гарантии нет. Структура C# вроде readonly record struct Meters(double Value) имеет ту же 8-байтовую раскладку в памяти, что и double, но на границе вызова JIT классифицирует её как агрегат, а то, как агрегаты передаются, решает ABI каждой платформы. В Linux x64, macOS x64 и на всех целях ARM64 обёртка по-прежнему передаётся в регистре с плавающей точкой, поэтому вы ничего не замечаете. В Windows x64 она передаётся в RCX, то есть в целочисленном регистре, а возвращается в RAX, а не в XMM0. В управляемом коде это стоит пары перемещений между регистрами, а если использовать обёртку в сигнатуре P/Invoke, где нативная сторона принимает обычный double, значения молча портятся.

Всё ниже запускалось на .NET 11 RC1 (среда выполнения 11.0.0-rc.1.26425.128, SDK 11.0.100-rc.1.26425.128) с C# 15. Управляемый дизассемблированный код для Windows x64 и Linux x64 получен кросс-компилятором crossgen2 из RC1 с JitDisasm, листинги для macOS получены запуском приложения нативно на arm64 и под Rosetta на x64, а нативные листинги получены из Apple clang 21 для каждого ABI.

Раскладка в памяти и соглашение о вызовах - это два разных контракта

Когда говорят, что структура с одним полем “бесплатна”, обычно имеют в виду раскладку в памяти. Это верно. Meters занимает 8 байт с выравниванием 8, как и double. Unsafe.SizeOf<Meters>() возвращает 8, массив Meters побитово совместим с массивом double, а MemoryMarshal.Cast<Meters, double> работает.

Соглашение о вызовах - отдельный контракт. Он отвечает на вопрос: если это значение является аргументом или возвращаемым значением, какой регистр или слот стека его хранит? ABI принимает это решение, классифицируя тип, и большинство ABI сначала определяют, скаляр это или агрегат, и только потом заглядывают внутрь. Структура является агрегатом, даже если у неё одно поле. Будет ли ABI после этого смотреть сквозь неё на double внутри, целиком зависит от платформы:

JIT-компилятор .NET следует ABI платформы при вызовах между управляемыми методами, а не только для P/Invoke. Так что это не особенность одного лишь взаимодействия с нативным кодом. Она проявляется и в обычном коде C#.

Нативный ABI прямо из компилятора

Вот самый маленький файл на C, который показывает разницу. Компиляция для трёх целей с clang -O2 -S показывает, чего ожидает каждый ABI:

// abi.c, Apple clang 21, -O2
typedef struct { double value; } Meters;
typedef struct { float x, y; } Vec2;

double take_double(double d) { return d * 2.0; }
double take_meters(Meters m) { return m.value * 2.0; }
Meters ret_meters(double d) { Meters m = { d }; return m; }
float  take_vec2(Vec2 v) { return v.x + v.y; }
float  take_two_floats(float x, float y) { return x + y; }

Для x86_64-pc-windows-msvc:

; Windows x64
take_double:
    addsd   %xmm0, %xmm0      ; double arrives in xmm0
    retq
take_meters:
    movq    %rcx, %xmm0       ; Meters arrives in rcx, moved to xmm0 first
    addsd   %xmm0, %xmm0
    retq
ret_meters:
    movq    %xmm0, %rax       ; Meters is returned in rax, not xmm0
    retq

Для x86_64-apple-macos (System V) и arm64-apple-macos (AAPCS64) take_meters компилируется в точно такие же инструкции, как и take_double (addsd %xmm0, %xmm0 и fadd d0, d0, d0 соответственно), а ret_meters компилируется в голую ret, потому что значение уже находится в регистре возврата.

Получается, что на двух из трёх ABI обёртка прозрачна по случайности из-за правил классификации, а на Windows x64 непрозрачна.

Что JIT .NET 11 генерирует для структуры-обёртки

Теперь управляемая сторона. Два метода, отличающиеся только обёрткой:

// .NET 11 RC1, C# 15
using System.Runtime.CompilerServices;

public readonly record struct Meters(double Value);

static class Managed
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static double ScaleDouble(double d) => d * 2.0;

    [MethodImpl(MethodImplOptions.NoInlining)]
    public static Meters ScaleMeters(Meters m) => new(m.Value * 2.0);
}

В Linux x64 (crossgen2 --targetos:linux --targetarch:x64) оба метода компилируются в одни и те же 5 байт:

; Managed:ScaleMeters(Meters):Meters, .NET 11 RC1, linux-x64
       vaddsd   xmm0, xmm0, xmm0
       ret
; Total bytes of code 5

В macOS arm64 при нативном запуске с DOTNET_JitDisasm оба метода занимают одинаковые 20 байт, а единственная реальная работа - fadd d0, d0, d0.

В Windows x64 (crossgen2 --targetos:windows --targetarch:x64) ScaleDouble по-прежнему занимает 5 байт, а ScaleMeters превращается в следующее:

; Managed:ScaleMeters(Meters):Meters, .NET 11 RC1, win-x64
       vmovq    xmm0, rcx          ; argument arrives in an integer register
       vaddsd   xmm0, xmm0, xmm0
       vmovq    rax, xmm0          ; result leaves in an integer register
       ret
; Total bytes of code 15

Два лишних перемещения между доменами регистров на каждый вызов и втрое больший размер кода. Внутри тела метода JIT заменяет структуру её единственным полем и работает с обычным регистром double; вся цена платится на границе. Если вызов встраивается, граница исчезает, а вместе с ней и цена. Поэтому накладные расходы редко имеют значение на практике и заметны только на горячих путях без встраивания: в виртуальных вызовах, при диспетчеризации через интерфейсы, в делегатах, в методах с NoInlining и в методах, слишком больших для встраивания.

Обёртка вокруг целого числа или ссылки на этих ABI такой проблемы не имеет. readonly record struct UserId(int Value) передаётся в ECX на Windows x64, в EDI на System V и в W0 на ARM64, точно так же, как простой int. Структура, оборачивающая ссылку на объект, передаётся как указатель. Расхождение специфично для полей с плавающей точкой (и, как показано ниже, для структур с несколькими полями), потому что только у значений с плавающей точкой есть отдельный файл регистров, который ABI может решить пропустить.

Настоящая ошибка: типы-обёртки в сигнатурах P/Invoke

Разница в производительности - сноска. Разница в корректности - нет. Если вы используете строго типизированную обёртку в сигнатуре LibraryImport или DllImport, у которой нативный аналог принимает базовый примитив, вы утверждаете, что обёртка прозрачна. Маршалер этого не проверяет, потому что структура blittable и передаётся как есть.

Вот воспроизведение на библиотеке abi.c выше:

// .NET 11 RC1, C# 15
using System.Runtime.InteropServices;

Console.WriteLine($"{RuntimeInformation.ProcessArchitecture} / {RuntimeInformation.FrameworkDescription}");
Console.WriteLine($"take_double(Meters 21)  = {Native.TakeDoubleAsMeters(new Meters(21)).Value}");
Console.WriteLine($"take_two_floats(Vec2)   = {Native.TakeTwoFloatsAsVec2(new Vec2(1f, 2f))}");
Console.WriteLine($"take_two_floats(f, f)   = {Native.TakeTwoFloats(1f, 2f)}");

public readonly record struct Meters(double Value);
public readonly record struct Vec2(float X, float Y);

static partial class Native
{
    // Wrong on purpose: the C side is double take_double(double)
    [LibraryImport("libabi", EntryPoint = "take_double")]
    public static partial Meters TakeDoubleAsMeters(Meters m);

    // Wrong on purpose: the C side is float take_two_floats(float, float)
    [LibraryImport("libabi", EntryPoint = "take_two_floats")]
    public static partial float TakeTwoFloatsAsVec2(Vec2 v);

    [LibraryImport("libabi", EntryPoint = "take_two_floats")]
    public static partial float TakeTwoFloats(float x, float y);
}

В macOS arm64:

take_double(Meters 21)  = 42
take_two_floats(Vec2)   = 3
take_two_floats(f, f)   = 3

Всё “работает”. Meters - это HFA из одного double, Vec2 - HFA из двух float, и AAPCS64 помещает их в D0 и S0/S1, ровно туда, куда смотрит нативный код.

В macOS x64 (System V), тот же бинарный файл, та же библиотека:

take_double(Meters 21)  = 42
take_two_floats(Vec2)   = 1
take_two_floats(f, f)   = 3

Meters по-прежнему работает, потому что единственный eightbyte класса SSE передаётся в XMM0. Vec2 не работает: System V упаковывает оба float в один eightbyte, поэтому вся структура приходит в младшие 64 бита XMM0. Нативная функция читает x из XMM0, а y из XMM1, где лежит то, что осталось от прежних операций. В этом запуске там оказался ноль, поэтому ответ был 1. В другом запуске там может быть что угодно.

В Windows x64 ломаются оба неверных объявления. Meters передаётся в RCX, в то время как take_double читает XMM0, а результат читается из RAX, в то время как нативный код записал его в XMM0. Vec2 (8 байт) тоже передаётся в RCX. Нативную сторону можно подтвердить по листингу x86_64-pc-windows-msvc выше; управляемая сторона подчиняется тому же правилу, которое показывает дизассемблированный код ScaleMeters.

Это классическая ошибка “на моём Mac работает, на Windows-агенте сборки получается мусор”. Код, проверенный и протестированный на ноутбуках с ARM64, проходит, а первый запуск на Windows x64 выдаёт бессмыслицу или значение, которое слегка отличается от верного.

Как писать типы-обёртки, безопасные на любой границе

В .NET 11 нет атрибута, который делал бы структуру прозрачной. [StructLayout(LayoutKind.Sequential)], Pack и Size управляют раскладкой в памяти, а не классификацией по регистрам. Поэтому решение состоит в том, чтобы держать обёртки по управляемую сторону границы.

  1. Объявляйте нативные сигнатуры с точными нативными типами. Если C принимает double, P/Invoke принимает double. Оборачивайте и разворачивайте в тонком управляемом методе:

    // .NET 11 RC1, C# 15
    static partial class Native
    {
        [LibraryImport("libabi", EntryPoint = "take_double")]
        private static partial double TakeDouble(double d);
    
        public static Meters Scale(Meters m) => new(TakeDouble(m.Value));
    }

    Метод-обёртка встраивается, так что за типобезопасность вы ничего не платите.

  2. Используйте структуру в P/Invoke только тогда, когда нативная сторона использует структуру с теми же полями. Если в заголовке C написано Vec2 v, то Vec2 в C# с теми же полями в том же порядке корректна на любом ABI, потому что обе стороны применяют одну и ту же классификацию. Ошибка возникает только тогда, когда с одной стороны структура, а с другой россыпь скаляров.

  3. Так же относитесь к указателям на функции и UnmanagedCallersOnly. У delegate* unmanaged<Meters, Meters> та же проблема, что и у LibraryImport, как и у экспорта [UnmanagedCallersOnly], который нативный хост вызывает с double. Если вы создаёте так аддоны Node или хосты плагинов, как в написании аддонов Node.js на .NET Native AOT, оставляйте экспортируемые сигнатуры примитивными.

  4. Для горячих управляемых путей на Windows x64 сначала проверьте, встраивается ли вызов. Если профилировщик указывает на метод без встраивания, который принимает или возвращает обёртку с плавающей точкой, посмотрите на дизассемблированный код. Просмотрщик ASM в Rider для дизассемблирования JIT и Native AOT или DOTNET_JitDisasm покажут пару vmovq. После этого можно передавать примитив через горячую границу или перестроить код так, чтобы вызов встраивался.

Подводные камни и граничные случаи

Появится ли в .NET настоящая transparent-раскладка?

Не в .NET 11. Ближайшее, что есть в дорожной карте, - это предложение о раскладке структур для взаимодействия в dotnet/runtime#100896, в рамках которого одобрен CustomLayoutAttribute с видами раскладки для структур в стиле C, объединений и типов Swift. В обсуждении прямо упоминаются запросы на механизм вроде repr(transparent) в Rust, но одобренная форма его не включает, а в июле 2026 года задачу перенесли с этапа 11.0.0 на 12.0.0. Если transparent-вид когда-нибудь появится, он позволит JIT и маршалеру классифицировать обёртку с одним полем как само это поле на любом ABI, и именно это делает RFC 1758 в Rust для своих newtype.

Пока что правило короткое: обёртки бесплатны в памяти и бесплатны после встраивания, но на границе ABI они являются агрегатами, и только платформа решает, имеет ли это значение. Не используйте их в нативных сигнатурах, а если вам всё же нужно передать их через горячий вызов без встраивания на Windows x64, сначала прочитайте дизассемблированный код.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад